Compare commits

..
Author SHA1 Message Date
Tomasz Czajęcki 822b703cbe [eas-cli] Remove output suppression from credential setup in go command
SetUpBuildCredentials can trigger interactive prompts (e.g. certificate
revocation selection when Apple's cert limit is hit). Wrapping it in
withSuppressedOutputAsync hid these prompts, causing an invisible prompt
that led to an infinite retry loop.
2026-03-17 23:06:24 +02:00
Tomasz Czajęcki ae6e9d86c6 [eas-cli] Remove local project generation from go command
The `go` command previously created a full local project in /tmp
(app.json, eas.json, package.json, npm install, git init) to satisfy
credentials setup and workflow upload. This was unnecessary because:

- Credential setup (SetUpBuildCredentials, SetUpAscApiKey, SetUpPushKey)
  operates entirely through GraphQL/Apple APIs and never reads from disk
- CredentialsContext only needs an in-memory ExpoConfig and projectId
- The workflow can generate its own project independently

Now the command:
- Constructs ExpoConfig in-memory instead of writing/reading app.json
- Passes a no-op VcsClient since credential actions don't use it
- No longer runs npm install or git init
- Creates minimal temp files only for the workflow upload tarball
- No longer changes process.cwd() or preserves/cleans up project dirs
2026-03-17 18:48:35 +02:00
135 changed files with 9733 additions and 9101 deletions

No files matched your search

-11
View File
@@ -1,11 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
-1
View File
@@ -16,7 +16,6 @@
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/eas-cli/src/graphql/generated.ts",
"packages/build-tools/schema.graphql"
]
}
-1
View File
@@ -3,4 +3,3 @@
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+1 -15
View File
@@ -8,26 +8,12 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.4.0",
"version": "18.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -2
View File
@@ -17,7 +17,7 @@
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
@@ -30,7 +30,6 @@
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
},
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.4.0",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -24,12 +24,12 @@
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
@@ -38,14 +38,14 @@
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-build-job": "18.2.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.4.0",
"@expo/steps": "18.2.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
@@ -75,7 +75,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.4.1",
"@expo/repack-app": "~0.2.5",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -91,7 +91,6 @@
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
@@ -67,26 +67,28 @@ export async function resolveBuildPhaseErrorAsync(
logLines: string[],
errorContext: ErrorContext,
buildLogsDirectory: string
): Promise<errors.ExpoError> {
): Promise<errors.BuildError> {
const { phase } = errorContext;
if (error instanceof errors.ExpoError) {
error.buildPhase ??= phase;
if (error instanceof errors.BuildError) {
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userError =
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase);
error instanceof errors.UserError
? error
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
return new errors.UserError(userError.errorCode, userError.message, {
return new errors.BuildError(userError.message, {
errorCode: userError.errorCode,
trackingCode,
docsUrl: userError.docsUrl,
cause: error,
buildPhase: phase,
metadata: buildError?.metadata,
cause: error,
});
}
+3 -19
View File
@@ -14,12 +14,8 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
@@ -85,12 +81,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -103,7 +93,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: await ctx.appConfig,
appConfig: ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -206,12 +196,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
+1 -1
View File
@@ -103,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: await ctx.appConfig,
appConfig: ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
+3 -4
View File
@@ -113,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
const appConfig = await ctx.appConfig;
const appConfig = ctx.appConfig;
ctx.logger.info('Using app configuration:');
ctx.logger.info(JSON.stringify(appConfig, null, 2));
await validateAppConfigAsync(ctx, appConfig);
@@ -121,13 +121,12 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
const appConfig = await ctx.appConfig;
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
throw new Error(
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
);
}
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
throw new Error(
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
);
+4 -4
View File
@@ -96,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhase?: BuildPhase;
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private _appConfig?: ExpoConfig;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
@@ -175,14 +175,14 @@ export class BuildContext<TJob extends Job = Job> {
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): Promise<ExpoConfig> {
public get appConfig(): ExpoConfig {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).then(config => config.exp);
}).exp;
}
return this._appConfig;
}
@@ -287,7 +287,7 @@ export class BuildContext<TJob extends Job = Job> {
private async handleBuildPhaseErrorAsync(
err: any,
buildPhase: BuildPhase
): Promise<errors.ExpoError> {
): Promise<errors.BuildError> {
const buildError = await resolveBuildPhaseErrorAsync(
err,
this.logBuffer.getPhaseLogs(buildPhase),
-7
View File
@@ -19,12 +19,5 @@ export { PackageManager } from './utils/packageManager';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export {
maybeStartPreparingPrecompiledModules,
PRECOMPILED_MODULES_PATH,
shouldUsePrecompiledDependencies,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from './utils/precompiledModules';
export * from './generic';
@@ -1,127 +0,0 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import {
startPreparingPrecompiledDependencies,
PRECOMPILED_MODULES_PATH,
} from '../../utils/precompiledModules';
import { installPods } from '../pod';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('@expo/turtle-spawn');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe(installPods.name, () => {
const extract = jest.fn();
const close = jest.fn();
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
let mkdtempCallCount = 0;
jest.mocked(mkdtemp).mockImplementation(async () => {
mkdtempCallCount += 1;
return mkdtempCallCount === 1
? ('/tmp/precompiled-modules-archive' as any)
: ('/tmp/precompiled-modules-staging' as any);
});
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
it('waits for precompiled dependencies before running pod install', async () => {
let resolvePreparation: (() => void) | undefined;
const preparationPromise = new Promise<void>(resolve => {
resolvePreparation = resolve;
});
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, [
'https://example.com/precompiled-modules-0.zip',
'https://example.com/precompiled-modules-1.zip',
]);
jest.mocked(spawn).mockResolvedValue({} as any);
const installPodsPromise = installPods(ctx, {});
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
resolvePreparation?.();
await installPodsPromise;
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
jest.mocked(spawn).mockResolvedValue({} as any);
await expect(installPods(ctx, {})).resolves.toEqual({
spawnPromise: expect.any(Promise),
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
});
});
-10
View File
@@ -3,21 +3,11 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
import path from 'path';
import { BuildContext } from '../context';
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
ctx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
@@ -51,6 +51,7 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Simulator`,
command: `
# shopt -s nullglob is necessary not to try to install
@@ -96,6 +97,7 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Emulator`,
command: `
# shopt -s globstar is necessary to add /**/ support
@@ -132,6 +134,7 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'maestro_test',
ifCondition: '${ always() }',
displayName: `maestro test ${flowPath}`,
command: `maestro test ${flowPath}`,
@@ -1,59 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
import { createInstallPodsBuildFunction } from '../installPods';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/precompiledModules', () => ({
...jest.requireActual('../../../utils/precompiledModules'),
waitForPrecompiledModulesPreparationAsync: jest.fn(),
}));
describe(createInstallPodsBuildFunction, () => {
it('waits for precompiled dependencies before running pod install', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await buildStep.executeAsync();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest
.mocked(waitForPrecompiledModulesPreparationAsync)
.mockRejectedValue(new Error('precompiled dependencies failed'));
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
});
@@ -177,34 +177,6 @@ describe(createInternalEasMaestroTestFunction, () => {
);
});
it('generates per-attempt report filenames when retrying', async () => {
// Mock sequence:
// 1. xcrun simctl shutdown → succeeds (beforeEach default is fine)
// 2. maestro test attempt 0 → fails (triggers retry)
// 3. maestro test attempt 1 → succeeds (beforeEach default)
mockedSpawnAsync
.mockResolvedValueOnce(undefined as any) // xcrun simctl shutdown
.mockRejectedValueOnce(new Error('Maestro test failed')); // maestro attempt 0
// Attempt 1 falls through to beforeEach's mockResolvedValue default
const step = createStep({
callInputs: { output_format: 'junit', retries: 2 },
});
await step.executeAsync();
// Filter to just the maestro test command calls (skip xcrun simctl shutdown)
const maestroCalls = mockedSpawnAsync.mock.calls.filter(([cmd]) => cmd === 'maestro');
expect(maestroCalls).toHaveLength(2);
// Extract --output paths from maestro calls
const outputArgs = maestroCalls.map(([, args]) => args[args.indexOf('--output') + 1]);
expect(outputArgs[0]).toContain('attempt-0');
expect(outputArgs[1]).toContain('attempt-1');
// Filenames must be unique (not overwriting)
expect(outputArgs[0]).not.toBe(outputArgs[1]);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
@@ -217,217 +217,6 @@ describe(parseMaestroResults, () => {
]);
});
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
vol.fromJSON({
// Attempt 0: login FAILED
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: login PASSED
'/junit/junit-report-flow-1-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Should return 2 results — one per attempt
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Timeout',
duration: 5000,
retryCount: 0,
}),
expect.objectContaining({
name: 'login',
path: '.maestro/login.yml',
status: 'passed',
errorMessage: null,
duration: 3000,
retryCount: 1,
}),
]);
});
it('returns per-attempt results for reuse_devices=true (all flows in every attempt)', async () => {
vol.fromJSON({
// Attempt 0: home FAILED, login PASSED
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: both PASSED
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
expect(results).toEqual([
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
]);
});
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
vol.fromJSON({
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: all passed across shards
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
expect(results).toHaveLength(4);
expect(results).toEqual([
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
]);
});
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
vol.fromJSON({
// Single overwritten JUnit (only has final attempt's results)
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// 2 timestamp dirs — both flows appear in both (entire suite retried)
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
// Both flows have 2 occurrences → retryCount = 1 for both
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
])
);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
@@ -238,45 +238,6 @@ describe(createReportMaestroTestResultsFunction, () => {
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('sends per-attempt results with same name but different retryCount', async () => {
vol.fromJSON({
// Two JUnit files for same flow (per-attempt)
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults).toHaveLength(2);
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
);
expect(variables.input.testCaseResults[1]).toEqual(
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
);
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
@@ -26,7 +26,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
@@ -35,15 +34,14 @@ const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
return Object.assign(step, { logger });
}
function createStartResult(serialId: string) {
@@ -59,9 +57,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
@@ -101,12 +97,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
@@ -157,24 +147,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -202,14 +174,4 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
const step = createStep(undefined, {
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
});
await step.executeAsync();
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,6 @@ import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -59,20 +58,6 @@ describe(isMissingPurposeStringError, () => {
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
@@ -34,20 +34,18 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { env, inputs, outputs }) => {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
}).exp;
const platform =
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
@@ -42,20 +42,18 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
}).exp;
const channelInput = inputs.channel.value as string | undefined;
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
@@ -1,8 +1,6 @@
import { BuildFunction } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
export function createInstallPodsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -10,14 +8,6 @@ export function createInstallPodsBuildFunction(): BuildFunction {
name: 'Install Pods',
__metricsId: 'eas/install_pods',
fn: async (stepsCtx, { env }) => {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
stepsCtx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
stepsCtx.logger.info('Installing pods');
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag =
@@ -214,17 +214,18 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
stepCtx.logger.info('');
// If output_format is empty or noop, we won't use this.
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
// Generate unique report path per attempt (not overwritten on retry)
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
| IosSimulatorName
| AndroidVirtualDeviceName;
@@ -275,11 +276,12 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
if (logsResult?.ok) {
try {
const extension = path.extname(logsResult.value.outputPath);
const destinationPath = path.join(
deviceLogsDir,
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
);
const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
await fs.promises.rm(destinationPath, {
force: true,
recursive: true,
});
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
@@ -38,79 +38,6 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// Internal helper — not exported. Parses a single JUnit XML file.
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
const results: JUnitTestCaseResult[] = [];
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
return results;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
}
return results;
}
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
@@ -125,9 +52,83 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
try {
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
continue;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
// Use @_status as primary indicator (more robust than checking <failure> presence)
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// Extract error message from <failure> or <error> elements
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Extract properties
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
continue;
}
}
return results;
}
@@ -166,32 +167,9 @@ export async function parseMaestroResults(
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files, tracking which file each result came from
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
return [];
}
interface JUnitResultWithSource {
result: JUnitTestCaseResult;
sourceFile: string;
}
const junitResultsWithSource: JUnitResultWithSource[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
junitResultsWithSource.push({ result, sourceFile: xmlFile });
}
}
if (junitResultsWithSource.length === 0) {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
return [];
}
@@ -239,63 +217,25 @@ export async function parseMaestroResults(
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Parse attempt index from filename pattern: *-attempt-N.*
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
group.push(entry);
resultsByName.set(entry.result.name, group);
}
for (const [flowName, flowEntries] of resultsByName) {
const flowFilePath = flowPathMap.get(flowName);
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: flowName;
: junit.name; // fallback: use flow name if ai-*.json not found
if (flowEntries.length === 1) {
// Single result for this flow — use ai-*.json occurrence count for retryCount
// (backward compat with old-style single JUnit file that gets overwritten)
const { result } = flowEntries[0];
const occurrences = flowOccurrences.get(flowName) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
results.push({
name: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount,
tags: result.tags,
properties: result.properties,
});
} else {
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
results.push({
name: junit.name,
path: relativePath,
status: junit.status,
errorMessage: junit.errorMessage,
duration: junit.duration,
retryCount,
tags: junit.tags,
properties: junit.properties,
});
}
return results;
@@ -23,7 +23,6 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -52,11 +51,6 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'js_bundle_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -88,10 +82,7 @@ export function createRepackBuildFunction(): BuildFunction {
);
}
const repackSpawnAsync = createSpawnAsyncStepAdapter({
verbose,
logger: stepsCtx.logger,
});
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
const workingDirectory = path.join(tmpDir, 'working-directory');
@@ -107,20 +98,13 @@ export function createRepackBuildFunction(): BuildFunction {
sourcemapOutput: undefined,
}
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: resolvedRepackVersion,
version: inputs.repack_version.value as string,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -134,7 +118,6 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
@@ -163,7 +146,6 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
androidSigningOptions,
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -64,21 +64,14 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
return;
}
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
// share the same name (Maestro config override), which we can't disambiguate.
// Same name with different retryCount is expected (per-attempt results from retries).
const seen = new Set<string>();
const conflicting = new Set<string>();
for (const r of flowResults) {
const key = `${r.name}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
}
seen.add(key);
}
if (conflicting.size > 0) {
// Maestro allows overriding flow names via config, so different flow files can share
// the same name. JUnit XML only contains names (not file paths), making it impossible
// to map duplicates back to their original flow files. Skip and let the user fix it.
const names = flowResults.map(r => r.name);
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
if (duplicates.length > 0) {
logger.error(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
@@ -7,10 +7,7 @@ import {
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -19,7 +16,6 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
@@ -54,15 +50,6 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -189,106 +176,6 @@ export async function restoreCcacheAsync({
}
}
export async function restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
try {
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
await fs.promises.writeFile(
gradlePropertiesPath,
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
);
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
// org.gradle.cache.cleanup property was removed in Gradle 9)
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
[
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
'',
'beforeSettings { settings ->',
' try {',
' settings.caches {',
' cleanup = Cleanup.ALWAYS',
' buildCache {',
' setRemoveUnusedEntriesAfterDays(3)',
' }',
' }',
' println "[EAS] Configured Gradle cache cleanup via init script"',
' } catch (Exception e) {',
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
' }',
'}',
'',
'gradle.buildFinished {',
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
'}',
'',
].join('\n')
);
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [buildCachePath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
});
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
await decompressCacheAsync({
archivePath,
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
logger.info(
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
);
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
} else {
logger.warn('Failed to restore Gradle cache: ', err);
}
}
}
export async function cacheStatsAsync({
logger,
env,
@@ -10,13 +10,8 @@ import {
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
return new BuildFunction({
@@ -51,15 +46,6 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -149,66 +135,3 @@ export async function saveCcacheAsync({
logger.error({ err }, 'Failed to save cache');
}
}
export async function saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
try {
await fs.promises.access(buildCachePath);
} catch {
logger.warn('No Gradle build cache found, skipping save');
return;
}
try {
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Saving Gradle cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing Gradle build cache...');
const { archivePath } = await compressCacheAsync({
paths: [buildCachePath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const { size } = await fs.promises.stat(archivePath);
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath],
size,
platform: Platform.ANDROID,
});
} catch (err) {
logger.error({ err }, 'Failed to save Gradle cache');
}
}
@@ -57,15 +57,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
logger.info('Making sure system image is installed');
await retryAsync(
@@ -116,13 +107,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
@@ -207,13 +191,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: cloneSerialId,
});
}
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
@@ -266,31 +266,12 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
@@ -298,12 +279,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
);
}
if (isMissingPurposeStringError(errors)) {
@@ -355,10 +331,6 @@ export function isClosedVersionTrainError(messages: { code: string }[]): boolean
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -34,13 +34,11 @@ export namespace AscApiUtils {
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
@@ -110,26 +108,19 @@ export namespace AscApiUtils {
throw error;
}
}
}
export async function getAppsAsync({
client,
limit,
}: {
client: Pick<AscApiClient, 'getAsync'>;
limit?: number;
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: limit ?? 10,
});
return appsResponse.data;
async function getVisibleAppsSummaryAsync(
client: Pick<AscApiClient, 'getAsync'>
): Promise<string | null> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: 10,
});
if (appsResponse.data.length === 0) {
return ' (none)';
}
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
return (
apps
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n') || ' (none)'
);
}
return appsResponse.data
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n');
}
@@ -60,8 +60,8 @@ describe('AscApiUtils', () => {
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringMatching(
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
),
})
);
@@ -96,31 +96,6 @@ describe('AscApiUtils', () => {
});
});
describe('formatAppsList', () => {
it('formats visible apps', () => {
expect(
AscApiUtils.formatAppsList([
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
{
type: 'apps',
id: '2222222222',
attributes: { name: 'Second App', bundleId: 'com.second.app' },
},
])
).toBe(
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
);
});
it('returns a placeholder when no visible apps are found', () => {
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
});
});
describe('createBuildUploadAsync', () => {
it('throws UserError when ASC duplicate version error is returned', async () => {
const payload = {
@@ -391,38 +391,6 @@ export namespace AndroidEmulatorUtils {
);
}
export async function disableWindowAndTransitionAnimationsAsync({
serialId,
env,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
logger.info('Disabling Android emulator window animations.');
try {
await spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'],
{ env }
);
} catch (err) {
logger.warn({ err }, 'Failed to disable Android emulator window animations.');
}
logger.info('Disabling Android emulator transition animations.');
try {
await spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'],
{ env }
);
} catch (err) {
logger.warn({ err }, 'Failed to disable Android emulator transition animations.');
}
}
async function hasNetworkConnectionAsync({
serialId,
env,
@@ -1,120 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('precompiledModules integration', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.enableNetConnect();
});
afterEach(() => {
nock.cleanAll();
});
it('merges the extracted debug and release xcframework trees', async () => {
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
const previousHome = process.env.HOME;
process.env.HOME = homeDirectory;
try {
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
const debugArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
);
const releaseArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
);
nock('https://fixtures.expo.test')
.get('/xcframeworks-Debug.zip')
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
.get('/xcframeworks-Release.zip')
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
jest.resetModules();
jest.doMock('os', () => ({
...jest.requireActual('os'),
homedir: () => homeDirectory,
}));
const {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} = await import('../precompiledModules');
startPreparingPrecompiledDependencies(
{
logger: createMockLogger(),
env: {},
} as any,
[
'https://fixtures.expo.test/xcframeworks-Debug.zip',
'https://fixtures.expo.test/xcframeworks-Release.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(PRECOMPILED_MODULES_PATH).toBe(
path.join(homeDirectory, '.expo', 'precompiled-modules')
);
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-camera fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-application fixture\n');
} finally {
jest.dontMock('os');
jest.resetModules();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.remove(homeDirectory);
}
});
});
@@ -1,6 +1,5 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
@@ -111,87 +110,6 @@ describe('AndroidEmulatorUtils', () => {
});
});
describe(AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync, () => {
it('sets window and transition animation scales to zero', async () => {
const logger = createMockLogger();
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
serialId: 'emulator-5554' as any,
env: process.env,
logger,
});
expect(logger.info).toHaveBeenNthCalledWith(
1,
'Disabling Android emulator window animations.'
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'-s',
'emulator-5554',
'shell',
'settings',
'put',
'global',
'window_animation_scale',
'0',
],
{ env: process.env }
);
expect(logger.info).toHaveBeenNthCalledWith(
2,
'Disabling Android emulator transition animations.'
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'-s',
'emulator-5554',
'shell',
'settings',
'put',
'global',
'transition_animation_scale',
'0',
],
{ env: process.env }
);
});
it('logs and swallows failures when disabling animations', async () => {
const logger = createMockLogger();
mockedSpawn
.mockRejectedValueOnce(new Error('window failed'))
.mockRejectedValueOnce(new Error('transition failed'));
await expect(
AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
serialId: 'emulator-5554' as any,
env: process.env,
logger,
})
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
err: expect.objectContaining({ message: 'window failed' }),
}),
'Failed to disable Android emulator window animations.'
);
expect(logger.warn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
err: expect.objectContaining({ message: 'transition failed' }),
}),
'Failed to disable Android emulator transition animations.'
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
@@ -1,108 +0,0 @@
import { bunyan } from '@expo/logger';
import { readAppConfig } from '../appConfig';
jest.mock('@expo/env', () => ({
load: jest.fn(() => ({ FROM_DOTENV: 'true' })),
}));
jest.mock('@expo/config', () => ({
getConfig: jest.fn(() => ({
exp: { name: 'fallback-app', slug: 'fallback-app' },
})),
}));
jest.mock('../expoCli');
const { expoCommandAsync } = jest.requireMock('../expoCli') as {
expoCommandAsync: jest.Mock;
};
const { getConfig } = jest.requireMock('@expo/config') as {
getConfig: jest.Mock;
};
const { load: loadEnv } = jest.requireMock('@expo/env') as {
load: jest.Mock;
};
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan;
const baseParams = {
projectDir: '/project',
env: { NODE_ENV: 'production' },
logger,
};
describe(readAppConfig, () => {
it('returns config from expo CLI when it succeeds', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
const result = await readAppConfig(baseParams);
expect(result).toEqual(config);
expect(expoCommandAsync).toHaveBeenCalledWith(
'/project',
['config', '--json', '--full', '--type', 'public'],
{ env: baseParams.env }
);
expect(getConfig).not.toHaveBeenCalled();
});
it('falls back to @expo/config when expo CLI fails', async () => {
expoCommandAsync.mockRejectedValue(new Error('expo not found'));
const result = await readAppConfig(baseParams);
expect(result).toEqual({ exp: { name: 'fallback-app', slug: 'fallback-app' } });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('expo not found'));
expect(getConfig).toHaveBeenCalledWith('/project', {
skipSDKVersionRequirement: true,
isPublicConfig: true,
});
});
it('throws when expo CLI returns invalid JSON and @expo/config also fails', async () => {
expoCommandAsync.mockResolvedValue({ stdout: 'not json' });
getConfig.mockImplementation(() => {
throw new Error('@expo/config failed');
});
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse JSON output from 'expo config'")
);
});
it('throws when expo CLI output is missing exp field and @expo/config also fails', async () => {
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify({ other: 'data' }) });
getConfig.mockImplementation(() => {
throw new Error('@expo/config failed');
});
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 'exp' field"));
});
it('loads env vars from dotenv for SDK >= 49', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
await readAppConfig({ ...baseParams, sdkVersion: '49.0.0' });
expect(loadEnv).toHaveBeenCalledWith('/project');
expect(expoCommandAsync).toHaveBeenCalledWith('/project', expect.any(Array), {
env: { NODE_ENV: 'production', FROM_DOTENV: 'true' },
});
});
it('does not load env vars from dotenv for SDK < 49', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
await readAppConfig({ ...baseParams, sdkVersion: '48.0.0' });
expect(loadEnv).not.toHaveBeenCalled();
});
});
@@ -1,78 +0,0 @@
import resolveFrom from 'resolve-from';
import spawnAsync from '@expo/turtle-spawn';
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
jest.mock('resolve-from', () => ({
__esModule: true,
default: Object.assign(jest.fn(), {
silent: jest.fn(),
}),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue({ stdout: '', stderr: '' }),
}));
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
silent: jest.MockedFunction<typeof resolveFrom.silent>;
};
describe(expoCommandAsync, () => {
it('resolves expo CLI and spawns with correct args', async () => {
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
await expoCommandAsync('/project', ['config', '--json'], { env: { FOO: 'bar' } });
expect(spawnAsync).toHaveBeenCalledWith(
'/project/node_modules/expo/bin/cli',
['config', '--json'],
expect.objectContaining({
cwd: '/project',
stdio: 'pipe',
env: { FOO: 'bar', EXPO_DEBUG: '0' },
})
);
});
it('falls back to expo/bin/cli.js when silent resolve returns null', async () => {
mockedResolveFrom.silent.mockReturnValue(undefined);
mockedResolveFrom.mockReturnValue('/project/node_modules/expo/bin/cli.js');
await expoCommandAsync('/project', ['config'], { env: {} });
expect(spawnAsync).toHaveBeenCalledWith(
'/project/node_modules/expo/bin/cli.js',
['config'],
expect.any(Object)
);
});
it('throws ExpoCLIModuleNotFoundError when expo is not installed', async () => {
mockedResolveFrom.silent.mockReturnValue(undefined);
const moduleError = new Error('Cannot find module');
(moduleError as any).code = 'MODULE_NOT_FOUND';
mockedResolveFrom.mockImplementation(() => {
throw moduleError;
});
await expect(expoCommandAsync('/project', ['config'], { env: {} })).rejects.toThrow(
ExpoCLIModuleNotFoundError
);
});
it('overrides EXPO_DEBUG to 0', async () => {
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
await expoCommandAsync('/project', ['config'], { env: { EXPO_DEBUG: '1' } });
expect(spawnAsync).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({ EXPO_DEBUG: '0' }),
})
);
});
});
@@ -1,14 +1,11 @@
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -71,33 +68,6 @@ describe(resolvePackageManager, () => {
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as bunyan;
it('should resolve version from package name and dist tag', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'latest',
});
expect(semver.valid(version)).toBeTruthy();
});
it('should return null if version cannot be resolved', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'nonexistent-dist-tag',
});
expect(version).toBeNull();
});
});
describe(findPackagerRootDir, () => {
beforeEach(() => {
vol.reset();
@@ -1,168 +0,0 @@
import downloadFile from '@expo/downloader';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from '../precompiledModules';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe('precompiledModules', () => {
const extract = jest.fn();
const close = jest.fn();
const getMkdtempPath = jest.fn();
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
({
logger: createMockLogger(),
env,
}) as unknown as BuildContext;
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
getMkdtempPath.mockReset();
getMkdtempPath
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
jest.mocked(downloadFile).mockReset();
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
afterEach(() => {
jest.useRealTimers();
});
it('downloads through CocoaPods proxy when enabled', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
jest
.mocked(downloadFile)
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('extracts all configured archives into the well-known destination path', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
]);
await waitForPrecompiledModulesPreparationAsync();
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
{ retry: 3 }
);
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
});
it('leaves the destination empty when preparation fails', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
extract.mockRejectedValueOnce(new Error('extract failed'));
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]);
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
expect(move).not.toHaveBeenCalled();
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
jest.useFakeTimers();
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
startPreparingPrecompiledDependencies(createCtx(), [
'https://example.com/xcframeworks-Debug.zip',
]);
const waitPromise = waitForPrecompiledModulesPreparationAsync();
const waitExpectation = expect(waitPromise).rejects.toThrow(
'Timed out waiting for precompiled dependencies after 30 seconds'
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(30_000);
await waitExpectation;
});
});
@@ -24,7 +24,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
when(mockCtx.appConfig).thenReturn(expoConfig);
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -38,7 +38,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
when(mockCtx.appConfig).thenReturn(expoConfig);
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -52,7 +52,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.PNPM);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
when(mockCtx.appConfig).thenReturn(expoConfig);
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -66,7 +66,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.BUN);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
when(mockCtx.appConfig).thenReturn(expoConfig);
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
+11 -60
View File
@@ -3,70 +3,18 @@ import { Env } from '@expo/eas-build-job';
import { load } from '@expo/env';
import { LoggerLevel, bunyan } from '@expo/logger';
import semver from 'semver';
import { expoCommandAsync } from './expoCli';
interface ReadAppConfigParams {
export function readAppConfig({
projectDir,
env,
logger,
sdkVersion,
}: {
projectDir: string;
env: Env;
logger: bunyan;
sdkVersion?: string;
}
export async function readAppConfig(params: ReadAppConfigParams): Promise<ProjectConfig> {
const shouldLoadEnvVarsFromDotenvFile =
params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49');
if (shouldLoadEnvVarsFromDotenvFile) {
const envVarsFromDotenvFile = load(params.projectDir) as Env;
const env = { ...params.env, ...envVarsFromDotenvFile };
params = { ...params, env };
}
// Reading the app config is done in two steps/attempts. We first attempt to run `expo config` as a CLI,
try {
return await getAppConfigFromExpo(params);
} catch (error: any) {
params.logger.warn(
'Failed to read the app config file with `expo config` command:\n' +
`${error?.message || error}`
);
}
// If this fails, we fall back to directly using `@expo/config`
// This can fail, since it's tied to a specific SDK version, so reading for older SDKs isn't guaranteed to work
return getAppConfigFromExpoConfig(params);
}
async function getAppConfigFromExpo({
projectDir,
env,
}: ReadAppConfigParams): Promise<ProjectConfig> {
const result = await expoCommandAsync(
projectDir,
['config', '--json', '--full', '--type', 'public'],
{ env }
);
let parsed: any;
try {
parsed = JSON.parse(result.stdout);
} catch {
throw new Error(
`Failed to parse JSON output from 'expo config'.\nOutput: ${result.stdout.slice(0, 500)}`
);
}
if (!('exp' in parsed)) {
throw new Error(`Unexpected output from 'expo config': missing 'exp' field.`);
}
return parsed;
}
function getAppConfigFromExpoConfig({
projectDir,
env,
logger,
}: ReadAppConfigParams): ProjectConfig {
}): ProjectConfig {
const originalProcessExit = process.exit;
const originalProcessCwd = process.cwd;
const originalStdoutWrite = process.stdout.write;
@@ -74,8 +22,11 @@ function getAppConfigFromExpoConfig({
const originalProcessEnv = process.env;
const stdoutStore: { text: string; level: LoggerLevel }[] = [];
const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49');
const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {};
const newEnvsToUse = { ...env, ...envVarsFromDotenvFile };
try {
process.env = env;
process.env = newEnvsToUse;
process.exit = () => {
throw new Error('Failed to evaluate app config file');
};
-36
View File
@@ -1,36 +0,0 @@
import resolveFrom from 'resolve-from';
import spawnAsync, { type SpawnOptions } from '@expo/turtle-spawn';
export class ExpoCLIModuleNotFoundError extends Error {}
function resolveExpoCLI(projectRoot: string): string {
try {
return (
resolveFrom.silent(projectRoot, 'expo/bin/cli') ?? resolveFrom(projectRoot, 'expo/bin/cli.js')
);
} catch (e: any) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new ExpoCLIModuleNotFoundError(`The \`expo\` package was not found.`);
}
throw e;
}
}
export async function expoCommandAsync(
projectDir: string,
args: string[],
options: Omit<SpawnOptions, 'cwd'>
) {
const expoCliPath = resolveExpoCLI(projectDir);
return spawnAsync(expoCliPath, args, {
cwd: projectDir,
stdio: 'pipe',
...options,
env: {
...options.env,
// NOTE: If we're reading user configs, if a user has set this, it might cause excessive output
// that can stop the command from being readable
EXPO_DEBUG: '0',
},
});
}
@@ -120,7 +120,7 @@ This would cause any updates published on the local machine to not be compatible
);
}
if (await isEASUpdateConfigured(ctx)) {
if (isEASUpdateConfigured(ctx)) {
if (ctx.job.updates?.channel !== undefined) {
await configureEASExpoUpdatesAsync(ctx);
} else {
@@ -134,7 +134,7 @@ This would cause any updates published on the local machine to not be compatible
} else if (isDevelopmentClient) {
// NO-OP: Development clients don't need to have a channel set
} else {
const easUpdateUrl = (await ctx.appConfig).updates?.url ?? null;
const easUpdateUrl = ctx.appConfig.updates?.url ?? null;
const jobProfile = ctx.job.buildProfile ?? null;
ctx.logger.warn(
`This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${
@@ -220,8 +220,8 @@ export async function getRuntimeVersionAsync(ctx: BuildContext<Job>): Promise<st
}
}
export async function isEASUpdateConfigured(ctx: BuildContext<Job>): Promise<boolean> {
const rawUrl = (await ctx.appConfig).updates?.url;
export function isEASUpdateConfigured(ctx: BuildContext<Job>): boolean {
const rawUrl = ctx.appConfig.updates?.url;
if (!rawUrl) {
return false;
}
@@ -1,21 +0,0 @@
import * as PackageManagerUtils from '@expo/package-manager';
import { hashFiles } from '@expo/steps';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
const packagerRunDir = findPackagerRootDir(workingDirectory);
const manager = PackageManagerUtils.createForProject(packagerRunDir);
const lockPath = path.join(packagerRunDir, manager.lockFile);
try {
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
} catch (err: unknown) {
throw new Error(
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
);
}
}
@@ -1,4 +1,3 @@
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
@@ -28,34 +27,6 @@ export function resolvePackageManager(directory: string): PackageManager {
}
}
/**
* Get the version of a package from the dist-tags.
* Returns null if the version cannot be resolved.
*/
export async function resolvePackageVersionAsync({
logger,
packageName,
distTag,
}: {
logger: bunyan;
packageName: string;
distTag: string;
}): Promise<string | null> {
try {
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
stdio: 'pipe',
});
const distTags = JSON.parse(stdout);
if (distTag in distTags) {
return distTags[distTag];
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
}
return null;
}
export function findPackagerRootDir(currentDir: string): string {
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
}
@@ -1,174 +0,0 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { BuildContext } from '../context';
let precompiledModulesPreparationPromise: Promise<void> | null = null;
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
return env.EAS_USE_PRECOMPILED_MODULES === '1';
}
export function maybeStartPreparingPrecompiledModules(
ctx: BuildContext,
config: { precompiledModulesUrls: string[] }
): void {
if (!shouldUsePrecompiledDependencies(ctx.env)) {
return;
}
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
}
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
logger: ctx.logger,
urls,
destinationDirectory: PRECOMPILED_MODULES_PATH,
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
}
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
if (!precompiledModulesPreparationPromise) {
return;
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
precompiledModulesPreparationPromise,
new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(
new Error(
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
)
);
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
timeoutHandle.unref?.();
}),
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function preparePrecompiledDependenciesAsync({
logger,
urls,
destinationDirectory,
cocoapodsProxyUrl,
}: {
logger: bunyan;
urls: string[];
destinationDirectory: string;
cocoapodsProxyUrl?: string;
}): Promise<void> {
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
const destinationParentDirectory = path.dirname(destinationDirectory);
await fs.mkdirp(destinationParentDirectory);
const stagingDirectory = await fs.mkdtemp(
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
);
logger.info('');
logger.info(
{
destinationDirectory,
urls,
},
'Starting precompiled dependencies download'
);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
try {
const archives = urls.map((url, index) => ({
archiveName: path.basename(new URL(url).pathname),
url,
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
}));
await Promise.all(
archives.map(async ({ url, archivePath }) => {
await downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
});
})
);
for (const { archiveName, url, archivePath } of archives) {
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
await extractZipAsync(archivePath, stagingDirectory);
logger.info(
{ destinationDirectory: stagingDirectory },
`Extracted ${archiveName} into staging precompiled dependencies directory`
);
}
await fs.remove(destinationDirectory);
await fs.move(stagingDirectory, destinationDirectory);
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
} catch (error) {
await fs.remove(stagingDirectory);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
throw error;
} finally {
await fs.remove(archiveDirectory);
}
}
async function downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
}: {
url: string;
archivePath: string;
cocoapodsProxyUrl?: string;
logger: bunyan;
}): Promise<void> {
const proxiedUrl = cocoapodsProxyUrl
? (() => {
const parsedUrl = new URL(url);
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
})()
: null;
if (proxiedUrl) {
try {
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
return;
} catch (err) {
logger.warn(
{ err, proxiedUrl },
'Failed to download through CocoaPods proxy, retrying directly'
);
}
}
await downloadFile(url, archivePath, { retry: 3 });
}
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
const zip = new StreamZip.async({ file: archivePath });
try {
await zip.extract(null, destinationDirectory);
} finally {
await zip.close();
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.4.0",
"version": "18.2.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.4.0"
"@expo/steps": "18.2.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+2 -3
View File
@@ -19,10 +19,10 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"fs-extra": "^11.2.0",
@@ -33,7 +33,6 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
@@ -26,9 +26,9 @@
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"test": "jest --passWithNoTests",
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
},
"dependencies": {
"@babel/code-frame": "7.23.5",
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.4.0",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -19,10 +19,10 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"@expo/logger": "18.0.1",
@@ -34,7 +34,6 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
@@ -1,10 +1,8 @@
import { ErrorCode, ExpoError, SystemError, UserError } from '../errors';
import { BuildError, ExpoError, UserError } from '../errors';
import { BuildPhase } from '../logs';
type ExpoErrorDetails = ConstructorParameters<typeof ExpoError>[1];
class TestExpoError extends ExpoError {
constructor(message: string, details: ExpoErrorDetails) {
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
super(message, details);
}
}
@@ -29,7 +27,27 @@ describe(ExpoError, () => {
});
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
expect(error.toExternalExpoError()).toEqual({
expect(error.format()).toEqual({
errorCode: 'ERR_CODE',
message: 'canonical message',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
});
});
});
describe(BuildError, () => {
it('formats using canonical message and errorCode', () => {
const error = new BuildError('canonical message', {
errorCode: 'ERR_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@expo/config' },
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.metadata).toEqual({ packageName: '@expo/config' });
expect(error.format()).toEqual({
errorCode: 'ERR_CODE',
message: 'canonical message',
docsUrl: 'https://docs.example.dev',
@@ -56,16 +74,6 @@ describe(UserError, () => {
expect(error.cause).toBe(cause);
});
it('supports trackingCode and buildPhase in options', () => {
const error = new UserError('ERR_CODE', 'message', {
trackingCode: 'TRACKING_CODE',
buildPhase: BuildPhase.PREBUILD,
});
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
});
it('supports docsUrl, metadata, and cause in options', () => {
const cause = new Error('root cause');
const error = new UserError('ERR_CODE', 'message', {
@@ -79,30 +87,3 @@ describe(UserError, () => {
expect(error.cause).toBe(cause);
});
});
describe(SystemError, () => {
it('always formats with SERVER_ERROR and preserves trackingCode', () => {
const cause = new Error('root cause');
const error = new SystemError('system message', {
trackingCode: 'TRACKING_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@expo/config' },
cause,
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.errorCode).toBe(ErrorCode.SERVER_ERROR);
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({ packageName: '@expo/config' });
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
expect(error.toExternalExpoError()).toEqual({
errorCode: ErrorCode.SERVER_ERROR,
message: 'system message',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
});
});
});
+19 -39
View File
@@ -54,10 +54,7 @@ export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata>
this.buildPhase = details.buildPhase;
}
/**
* Serialized error payload used by the orchestrator-worker API.
*/
public toExternalExpoError(): ExternalBuildError {
public format(): ExternalBuildError {
return {
errorCode: this.errorCode,
message: this.message,
@@ -67,49 +64,29 @@ export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata>
}
}
export class BuildError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, details);
}
}
export class UserError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
public errorCode: string,
message: string,
public message: string,
options?: {
trackingCode?: string;
docsUrl?: string;
buildPhase?: BuildPhase;
metadata?: TMetadata;
cause?: unknown;
}
) {
super(message, {
errorCode,
trackingCode: options?.trackingCode,
docsUrl: options?.docsUrl,
buildPhase: options?.buildPhase,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
export class SystemError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
message: string,
options?: {
trackingCode?: string;
docsUrl?: string;
buildPhase?: BuildPhase;
metadata?: TMetadata;
cause?: unknown;
}
) {
super(message, {
errorCode: ErrorCode.SERVER_ERROR,
trackingCode: options?.trackingCode,
docsUrl: options?.docsUrl,
buildPhase: options?.buildPhase,
metadata: options?.metadata,
cause: options?.cause,
});
@@ -122,25 +99,28 @@ export class UnknownError extends UserError {
ErrorCode.UNKNOWN_ERROR,
buildPhase
? `Unknown error. See logs of the ${buildPhaseDisplayName[buildPhase]} build phase for more information.`
: 'Unknown error. See logs for more information.',
{ buildPhase }
: 'Unknown error. See logs for more information.'
);
}
}
export class UnknownBuildError extends UserError {
export class UnknownBuildError extends BuildError {
constructor() {
const errorCode = ErrorCode.UNKNOWN_ERROR;
const message = 'Unknown error. See logs for more information.';
super(errorCode, message);
super(message, {
errorCode,
});
}
}
export class UnknownCustomBuildError extends UserError {
export class UnknownCustomBuildError extends BuildError {
constructor() {
const errorCode = ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR;
const message = 'Unknown custom build error. See logs for more information.';
super(errorCode, message);
super(message, {
errorCode,
});
}
}
+93 -94
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -422,7 +422,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -442,7 +442,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -460,7 +460,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -488,20 +488,19 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
```
USAGE
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
emulator/simulator internal distribution dev client builds. The "development-simulator"
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
emulator/simulator internal distribution dev client builds. The "development-simulator"
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -522,7 +521,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -560,7 +559,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -600,7 +599,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -630,7 +629,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -656,7 +655,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -708,7 +707,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -726,7 +725,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -744,7 +743,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -761,7 +760,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -782,7 +781,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -803,7 +802,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -825,7 +824,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -845,7 +844,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -867,7 +866,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -889,7 +888,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -924,7 +923,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -947,7 +946,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -968,7 +967,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -985,7 +984,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1003,7 +1002,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1032,7 +1031,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1057,7 +1056,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1078,7 +1077,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1099,7 +1098,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1136,7 +1135,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1156,7 +1155,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1177,7 +1176,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1198,7 +1197,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1212,7 +1211,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1226,7 +1225,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1258,7 +1257,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1284,7 +1283,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1306,7 +1305,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1334,7 +1333,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1362,7 +1361,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1385,7 +1384,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1407,7 +1406,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1441,7 +1440,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1485,7 +1484,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1516,7 +1515,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1625,7 +1624,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1642,7 +1641,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1659,7 +1658,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1711,7 +1710,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1725,7 +1724,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1748,7 +1747,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1772,7 +1771,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1790,7 +1789,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1823,7 +1822,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1866,7 +1865,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1887,7 +1886,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1908,7 +1907,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1933,7 +1932,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1960,7 +1959,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1998,7 +1997,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2026,7 +2025,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2056,7 +2055,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2077,7 +2076,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2097,7 +2096,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2118,7 +2117,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2140,7 +2139,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2160,7 +2159,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2179,7 +2178,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2202,7 +2201,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2219,7 +2218,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2340,7 +2339,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2360,7 +2359,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2384,7 +2383,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2424,7 +2423,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2446,7 +2445,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2474,7 +2473,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2494,7 +2493,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2516,5 +2515,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+359 -3067
View File
File diff suppressed because it is too large. Load diff
-8
View File
@@ -1,12 +1,4 @@
/* eslint-disable no-console */
// Simulate an interactive environment so that commands default to interactive mode in tests.
// Without this, isNonInteractiveByDefault() returns true because:
// 1. Jest runs in a non-TTY environment (!process.stdin.isTTY)
// 2. CI environments set CI=1 (boolish('CI', false))
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
delete process.env.CI;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.4.0",
"version": "18.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -28,7 +28,7 @@
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
@@ -39,16 +39,16 @@
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
"clean": "rimraf dist build tmp node_modules yarn-error.log *.tsbuildinfo",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.15",
"@expo/apple-utils": "2.1.13",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-json": "18.4.0",
"@expo/eas-build-job": "18.2.0",
"@expo/eas-json": "18.2.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.0.1",
@@ -63,12 +63,11 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.4.0",
"@expo/steps": "18.2.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@segment/ajv-human-errors": "^2.1.2",
"@sentry/node": "7.77.0",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"ajv": "8.11.0",
@@ -1,7 +1,6 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowCustomJobFragment,
getMockWorkflowRunWithBuildJobsFragment,
getMockWorkflowRunWithJobsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
@@ -125,8 +124,7 @@ describe(WorkflowLogView, () => {
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
let stepPrompt: any;
jest.mocked(promptAsync).mockImplementation(async prompt => {
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
@@ -134,14 +132,13 @@ describe(WorkflowLogView, () => {
promptCalls++;
return { selectedJob: 0 };
} else {
stepPrompt = prompt;
return { selectedStep: 'step-id-1' };
return { selectedStep: 'step1' };
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
@@ -151,14 +148,6 @@ describe(WorkflowLogView, () => {
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(stepPrompt.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Install dependencies - success',
value: 'step-id-1',
}),
])
);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
@@ -170,9 +159,6 @@ describe(WorkflowLogView, () => {
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withBuildJobs: 1 }));
jest
.mocked(WorkflowRunQuery.withJobsByIdAsync)
.mockResolvedValue(getMockWorkflowRunWithBuildJobsFragment());
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
id: 'build1',
status: BuildStatus.Finished,
@@ -195,8 +181,7 @@ describe(WorkflowLogView, () => {
logFiles: ['https://example.com/log1'],
});
let promptCalls = 0;
let stepPrompt: any;
jest.mocked(promptAsync).mockImplementation(async prompt => {
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
@@ -204,14 +189,13 @@ describe(WorkflowLogView, () => {
promptCalls++;
return { selectedJob: 0 };
} else {
stepPrompt = prompt;
return { selectedStep: 'step-id-1' };
return { selectedStep: 'step1' };
}
});
jest
.mocked(fetchRawLogsForBuildJobAsync)
.mockResolvedValue(
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
@@ -221,65 +205,9 @@ describe(WorkflowLogView, () => {
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(stepPrompt.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'step1 - success',
value: 'step-id-1',
}),
])
);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
test('view logs keeps duplicate display names separated by step id', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
let stepPrompt: any;
jest.mocked(promptAsync).mockImplementation(async prompt => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
stepPrompt = prompt;
return { selectedStep: 'step-id-2' };
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
[
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
'{"result":"fail","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-2","time":"2022-01-01T00:00:01.000Z","msg":"second"}',
].join('\n')
);
await cmd.run();
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(stepPrompt.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Install - success',
value: 'step-id-1',
}),
expect.objectContaining({
title: 'Install - fail',
value: 'step-id-2',
}),
])
);
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:01.000Z second');
});
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
@@ -12,8 +12,8 @@ import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { BuildContext } from '../context';
import { getActorUsername } from '../../user/User';
interface JobData {
projectArchive: ArchiveSource;
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Android.Job> {
const username = getActorUsername(ctx.user) ?? undefined;
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
+8 -36
View File
@@ -455,9 +455,6 @@ const queueProgressBar = new cliProgress.SingleBar(
cliProgress.Presets.rect
);
let statusNewSetAt: number | null = null;
const NEW_STATUS_GRACE_PERIOD_MS = 15_000;
async function handleSingleBuildProgressAsync(
{
build,
@@ -489,19 +486,11 @@ async function handleSingleBuildProgressAsync(
case BuildStatus.Finished:
spinner.succeed('Build finished');
return { refetch: false };
case BuildStatus.New: {
const now = Date.now();
statusNewSetAt ??= now;
const newStatusDurationMs = now - statusNewSetAt;
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
spinner.text = 'Waiting for build to get enqueued…';
} else {
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
}
case BuildStatus.New:
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
break;
}
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
const progressBarPayload =
@@ -564,7 +553,7 @@ const priorityToQueueDisplayName: Record<BuildPriority, string> = {
};
const statusToDisplayName: Record<BuildStatus, string> = {
[BuildStatus.New]: 'waiting to enter the queue',
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
[BuildStatus.InQueue]: 'in queue',
[BuildStatus.InProgress]: 'in progress',
[BuildStatus.PendingCancel]: 'canceled',
@@ -582,7 +571,6 @@ async function handleMultipleBuildsProgressAsync(
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
const someNew = builds.some(build => build.status === BuildStatus.New);
const allFinished =
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
const allSettled =
@@ -595,10 +583,6 @@ async function handleMultipleBuildsProgressAsync(
].includes(build.status)
).length === buildCount;
if (someNew && statusNewSetAt === null) {
statusNewSetAt = Date.now();
}
if (allSettled) {
if (allFinished) {
spinner.succeed(formatSettledBuildsText(builds));
@@ -607,11 +591,7 @@ async function handleMultipleBuildsProgressAsync(
}
return { refetch: false };
} else {
const showConcurrencyWarning =
someNew &&
statusNewSetAt !== null &&
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
spinner.text = formatPendingBuildsText(originalSpinnerText, builds);
return { refetch: true };
}
}
@@ -630,20 +610,12 @@ function formatSettledBuildsText(builds: BuildFragment[]): string {
.join('\n ');
}
function formatPendingBuildsText(
originalSpinnerText: string,
builds: BuildFragment[],
showConcurrencyWarning: boolean
): string {
function formatPendingBuildsText(originalSpinnerText: string, builds: BuildFragment[]): string {
return [
originalSpinnerText,
...platforms.map(platform => {
const build = builds.find(build => build.platform === platform);
const status = build ? statusToDisplayName[build.status] : 'unknown';
const concurrencyWarning =
showConcurrencyWarning && build?.status === BuildStatus.New
? ' (concurrency limit reached)'
: '';
let extraInfo = '';
if (
build?.status === BuildStatus.InQueue &&
@@ -663,7 +635,7 @@ function formatPendingBuildsText(
}
return `${appPlatformEmojis[platform]} ${
appPlatformDisplayNames[platform]
} build - status: ${chalk.bold(status)}${concurrencyWarning}${extraInfo}`;
} build - status: ${chalk.bold(status)}${extraInfo}`;
}),
].join('\n ');
}
+2 -2
View File
@@ -14,8 +14,8 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { IosJobSecretsInput } from '../../graphql/generated';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { BuildContext } from '../context';
import { getActorUsername } from '../../user/User';
interface JobData {
projectArchive: ArchiveSource;
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Ios.Job> {
const username = getActorUsername(ctx.user) ?? undefined;
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
+2 -2
View File
@@ -10,12 +10,12 @@ import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import {
getUsernameForBuildMetadataAndBuildJob,
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
import { getActorUsername } from '../user/User';
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>,
@@ -49,7 +49,7 @@ export async function collectMetadataAsync<T extends Platform>(
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
? false
: await ctx.vcsClient.hasUncommittedChangesAsync(),
username: getActorUsername(ctx.user) ?? undefined,
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
message: ctx.message,
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
@@ -27,9 +27,7 @@ import {
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import Log, { link } from '../log';
import Sentry from '../sentry';
import SessionManager from '../user/SessionManager';
import { getActorDisplayName } from '../user/User';
import { Client } from '../vcs/vcs';
export type ContextInput<
@@ -171,6 +169,7 @@ export default abstract class EasCommand extends Command {
* It is set up here to ensure a consistent setup.
*/
private analyticsInternal?: AnalyticsWithOrchestration;
/**
* Execute the context in the contextDefinition to satisfy command prerequisites.
*/
@@ -231,13 +230,7 @@ export default abstract class EasCommand extends Command {
// this is needed for logEvent call below as it identifies the user in the analytics system
// if possible
const actor = await this.sessionManager.getUserAsync();
if (actor) {
Sentry.setUser({
id: actor.id,
username: getActorDisplayName(actor),
});
}
await this.sessionManager.getUserAsync();
this.analytics.logEvent(CommandEvent.ACTION, {
// id is assigned by oclif in constructor based on the filepath:
@@ -251,13 +244,11 @@ export default abstract class EasCommand extends Command {
// eslint-disable-next-line async-protect/async-suffix
override async finally(err: Error): Promise<any> {
await this.analytics.flushAsync();
await Sentry.flush(2000);
return await super.finally(err);
}
protected override catch(err: Error): Promise<any> {
const commandId = this.id ?? 'unknown';
let baseMessage = `${commandId} command failed.`;
let baseMessage = `${this.id} command failed.`;
if (err instanceof EasCommandError) {
Log.error(err.message);
} else if (err instanceof CombinedError && err?.graphQLErrors) {
@@ -302,17 +293,6 @@ export default abstract class EasCommand extends Command {
Log.error(err.message);
}
Log.debug(err);
Sentry.withScope(scope => {
scope.setTag('command', commandId);
scope.setTag('error_name', err.name);
scope.setExtra('commandId', commandId);
scope.setExtra('commandMessage', baseMessage);
scope.setExtra('originalMessage', err.message);
if (err instanceof CombinedError) {
scope.setExtra('graphQLErrorCount', err.graphQLErrors.length);
}
Sentry.captureException(err);
});
const sanitizedError = new Error(baseMessage);
sanitizedError.stack = err.stack;
throw sanitizedError;
@@ -1,20 +1,14 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
import Log from '../../log';
import SessionManager from '../../user/SessionManager';
import EasCommand from '../EasCommand';
jest.mock('../../user/User');
jest.mock('../../user/SessionManager');
jest.mock('../../sentry', () => ({
__esModule: true,
default: {
init: jest.fn(),
setTag: jest.fn(),
setUser: jest.fn(),
withScope: jest.fn(),
captureException: jest.fn(),
flush: jest.fn(async (): Promise<void> => {}),
},
}));
jest.mock('../../analytics/AnalyticsManager', () => {
const { CommandEvent } = jest.requireActual('../../analytics/AnalyticsManager');
return {
@@ -44,22 +38,20 @@ const analytics: AnalyticsWithOrchestration = {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
});
const createTestEasCommand = (): any => {
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
createAnalyticsAsync.mockResolvedValue(analytics);
const EasCommand = require('../EasCommand').default;
const createTestEasCommand = () => {
class TestEasCommand extends EasCommand {
async runAsync(): Promise<void> {}
override async runAsync(): Promise<void> {}
}
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
return TestEasCommand;
};
describe('EasCommand', () => {
describe(EasCommand.name, () => {
describe('without exceptions', () => {
// The first test in this suite should have an increased timeout
// because of the implementation of Command from @oclif/command.
@@ -72,7 +64,6 @@ describe('EasCommand', () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
const SessionManager = jest.requireMock('../../user/SessionManager').default;
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
expect(sessionManagerSpy).toBeCalledTimes(1);
}, 60_000);
@@ -81,7 +72,6 @@ describe('EasCommand', () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
expect(createAnalyticsAsync).toHaveBeenCalled();
});
@@ -92,14 +82,6 @@ describe('EasCommand', () => {
expect(analytics.flushAsync).toHaveBeenCalled();
});
it('flushes Sentry', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
const Sentry = jest.requireMock('../../sentry').default;
expect(Sentry.flush).toHaveBeenCalled();
});
it('logs events', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
@@ -123,59 +105,8 @@ describe('EasCommand', () => {
});
describe('catch', () => {
it('captures unexpected errors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
const sentryScope = {
setTag: jest.fn(),
setExtra: jest.fn(),
};
const Sentry = jest.requireMock('../../sentry').default;
Sentry.withScope.mockImplementation((cb: (scope: typeof sentryScope) => void) => {
cb(sentryScope);
});
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
expect(sentryScope.setTag).toHaveBeenCalledWith('error_name', error.name);
expect(sentryScope.setExtra).toHaveBeenCalledWith('commandId', TestEasCommand.id);
expect(sentryScope.setExtra).toHaveBeenCalledWith(
'commandMessage',
`${TestEasCommand.id} command failed.`
);
expect(sentryScope.setExtra).toHaveBeenCalledWith('originalMessage', error.message);
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
it('captures expected command errors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { EasCommandError } = require('../errors');
const Sentry = jest.requireMock('../../sentry').default;
Sentry.withScope.mockImplementation((cb: (scope: any) => void) => {
cb({ setTag: jest.fn(), setExtra: jest.fn() });
});
runAsyncMock.mockImplementation(() => {
throw new EasCommandError('Expected user-facing error');
});
try {
await TestEasCommand.run();
} catch {}
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
});
it('logs the message', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
@@ -193,11 +124,9 @@ describe('EasCommand', () => {
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const graphQLErrors = ['Unexpected GraphQL error message'];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
@@ -213,12 +142,9 @@ describe('EasCommand', () => {
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
@@ -248,12 +174,9 @@ describe('EasCommand', () => {
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
@@ -304,7 +227,6 @@ describe('EasCommand', () => {
it('re-throws the error with a different default base message in case of CombinedError', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = ['Unexpected GraphQL error message'];
throw new CombinedError({ graphQLErrors });
@@ -320,8 +242,6 @@ describe('EasCommand', () => {
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
@@ -1,46 +1,4 @@
import { isNonInteractiveByDefault, resolveNonInteractiveAndJsonFlags } from '../flags';
describe(isNonInteractiveByDefault, () => {
const originalEnv = process.env;
const originalIsTTY = process.stdin.isTTY;
afterEach(() => {
process.env = originalEnv;
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true });
});
test('returns false when stdin is a TTY and CI is not set', () => {
process.env = { ...originalEnv };
delete process.env.CI;
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
expect(isNonInteractiveByDefault()).toBe(false);
});
test('returns true when CI=1', () => {
process.env = { ...originalEnv, CI: '1' };
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
expect(isNonInteractiveByDefault()).toBe(true);
});
test('returns true when CI=true', () => {
process.env = { ...originalEnv, CI: 'true' };
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
expect(isNonInteractiveByDefault()).toBe(true);
});
test('returns true when stdin is not a TTY', () => {
process.env = { ...originalEnv };
delete process.env.CI;
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true });
expect(isNonInteractiveByDefault()).toBe(true);
});
test('returns true when both CI is set and stdin is not a TTY', () => {
process.env = { ...originalEnv, CI: '1' };
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true });
expect(isNonInteractiveByDefault()).toBe(true);
});
});
import { resolveNonInteractiveAndJsonFlags } from '../flags';
describe(resolveNonInteractiveAndJsonFlags, () => {
test('returns both false when no flags are set', () => {
@@ -189,8 +189,6 @@ export async function validateOrSetProjectIdAsync({
return user.username;
case 'SSOUser':
return user.username;
case 'PartnerActor':
return user.username;
case 'Robot':
throw new Error(
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
@@ -1,9 +1,4 @@
import { Flags } from '@oclif/core';
import { boolish } from 'getenv';
export function isNonInteractiveByDefault(): boolean {
return boolish('CI', false) || !process.stdin.isTTY;
}
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
@@ -12,8 +7,6 @@ export const EasNonInteractiveAndJsonFlags = {
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
default: () => Promise.resolve(isNonInteractiveByDefault()),
noCacheDefault: true,
}),
};
@@ -71,11 +64,6 @@ export const EASEnvironmentVariableScopeFlag = {
export const EASNonInteractiveFlag = {
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
// eslint-disable-next-line async-protect/async-suffix
default: async () => {
return isNonInteractiveByDefault();
},
noCacheDefault: true,
}),
};
@@ -1,35 +0,0 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
import { infoForActiveWorkflowRunAsync } from '../utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
jest.mock('../fetchLogs');
describe('workflow utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('shows the display name for the current step while keying logs by step id', async () => {
const workflowRun = getMockWorkflowRunWithJobsFragment();
workflowRun.jobs = workflowRun.jobs.map(job => ({
...job,
status: WorkflowJobStatus.InProgress,
}));
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
[
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
].join('\n')
);
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
expect(output).toContain('Current step');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('step-id-1');
});
});
@@ -22,17 +22,6 @@ export async function fetchRawLogsForBuildJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
// Prefer turtleJobRun logs, which contain JSONL-formatted step logs
// for both built-in and custom build steps
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (turtleLogFileUrl) {
const response = await fetch(turtleLogFileUrl, {
method: 'GET',
});
return await response.text();
}
// Fall back to build logFiles
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
@@ -47,5 +36,6 @@ export async function fetchRawLogsForBuildJobAsync(
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
return await response.text();
const rawLogs = await response.text();
return rawLogs;
}
@@ -50,11 +50,4 @@ export type WorkflowLogLine = {
err?: any;
};
export type WorkflowLogs = Map<
string,
{
key: string;
label: string;
logLines: WorkflowLogLine[];
}
>;
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
@@ -84,17 +84,16 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, logLines }) => {
return Array.from(logs.keys())
.map(step => {
const logLines = logs.get(step);
const stepStatus =
logLines?.filter(
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
)[0]?.result ?? '';
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
return {
title: `${label} - ${stepStatus}`,
name: label,
title: `${step} - ${stepStatus}`,
name: step,
status: stepStatus,
value: key,
value: step,
logLines,
};
})
@@ -140,27 +139,20 @@ export async function fetchAndProcessLogsFromJobAsync(
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const logs: WorkflowLogs = new Map();
const logKeys = new Set<string>();
rawLogs.split('\n').forEach((line, index) => {
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
try {
const parsedLine = JSON.parse(line);
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
parsedLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (stepKey && stepLabel) {
if (!logs.has(stepKey)) {
logs.set(stepKey, {
key: stepKey,
label: stepLabel,
logLines: [],
});
const stepId = buildStepDisplayName ?? buildStepInternalId;
if (stepId) {
if (!logKeys.has(stepId)) {
logKeys.add(stepId);
logs.set(stepId, []);
}
const logGroup = logs.get(stepKey)!;
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
logGroup.logLines.push({ time, msg, result, marker, err });
logs.get(stepId)?.push({ time, msg, result, marker, err });
}
} catch {}
});
@@ -246,7 +238,7 @@ export async function infoForFailedWorkflowRunAsync(
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
if (steps.length > 0) {
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
const failedStep = steps.find(step => step.status === 'fail');
if (failedStep) {
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
statusValues.push({ label: ' Failed step', value: failedStep.name });
+219 -208
View File
@@ -1,14 +1,14 @@
import { ExpoConfig } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import * as tar from 'tar';
import { v4 as uuidv4 } from 'uuid';
import { Analytics } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { CredentialsContext } from '../credentials/context';
import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils';
import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils';
@@ -29,14 +29,14 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { Actor, getActorDisplayName } from '../user/User';
import { getTmpDirectory } from '../utils/paths';
import { sleepAsync } from '../utils/promise';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../uploads';
import { AccountUploadSessionType } from '../graphql/generated';
import NoVcsClient from '../vcs/clients/noVcs';
// Expo Go release info - update when releasing a new version
const EXPO_GO_SDK_VERSION = '55';
@@ -191,89 +191,71 @@ export default class Go extends EasCommand {
const appName = flags.name ?? 'My Expo Go';
const slug = bundleId.split('.').pop() || 'my-expo-go';
const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`);
await fs.emptyDir(projectDir);
const setupSpinner = ora('Setting up project...').start();
const originalCwd = process.cwd();
process.chdir(projectDir);
// Step 1: Create/link EAS project
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, bundleId)
);
const setupSpinner = ora('Creating project...').start();
// Step 2: Build in-memory Expo config for credentials
const exp = this.buildExpoConfig(slug, bundleId, appName, projectId);
// Step 1: Create project files and initialize git (silently)
try {
await withSuppressedOutputAsync(async () => {
await this.createProjectFilesAsync(projectDir, bundleId, appName);
await this.initGitRepoAsync(projectDir);
});
const vcsClient = resolveVcsClient();
// Step 2: Create/link EAS project (silently)
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId)
);
// Step 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
projectDir,
projectId,
bundleId,
appName,
graphqlClient,
actor,
analytics,
vcsClient,
flags.credentials,
() => {
setupSpinner.stop();
Log.markFreshLine();
}
);
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectDir,
projectId,
actor,
vcsClient
);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Workflow failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Workflow was canceled');
// Step 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
exp,
projectId,
bundleId,
appName,
graphqlClient,
actor,
analytics,
flags.credentials,
() => {
setupSpinner.stop();
Log.markFreshLine();
}
);
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectId,
actor,
slug,
bundleId,
appName,
ascApp.id
);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
Log.newLine();
Log.succeed(
`Done! Your custom Expo Go has been submitted to TestFlight. ${learnMore(
`https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight`,
{ learnMoreMessage: 'Open it on App Store Connect' }
)}`
);
Log.log(
`App Store processing may take several minutes to complete. ${learnMore(
'https://expo.fyi/personal-expo-go',
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
)}`
);
await fs.remove(projectDir);
} catch (error) {
Log.gray(`Project files preserved for debugging: ${projectDir}`);
throw error;
} finally {
process.chdir(originalCwd);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Workflow failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Workflow was canceled');
}
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
Log.newLine();
Log.succeed(
`Done! Your custom Expo Go has been submitted to TestFlight. ${learnMore(
`https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight`,
{ learnMoreMessage: 'Open it on App Store Connect' }
)}`
);
Log.log(
`App Store processing may take several minutes to complete. ${learnMore(
'https://expo.fyi/personal-expo-go',
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
)}`
);
}
private generateBundleId(actor: Actor): string {
@@ -287,87 +269,57 @@ export default class Go extends EasCommand {
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
}
private async createProjectFilesAsync(
projectDir: string,
private buildExpoConfig(
slug: string,
bundleId: string,
appName: string
): Promise<void> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
appName: string,
projectId: string
): ExpoConfig {
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: {
usesNonExemptEncryption: false,
},
return {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: {
usesNonExemptEncryption: false,
},
extra: {
eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
extra: {
eas: {
projectId,
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
_internal: {
isDebug: false,
projectRoot: '.',
dynamicConfigPath: null as any,
staticConfigPath: null as any,
packageJsonPath: null as any,
},
sdkVersion: `${EXPO_GO_SDK_VERSION}.0.0`,
platforms: ['ios'],
};
const easJson = {
cli: {
version: '>= 5.0.0',
},
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: {},
},
},
};
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: {
expo: '~54.0.0',
},
};
await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 });
await spawnAsync('npm', ['install'], { cwd: projectDir });
}
private async initGitRepoAsync(projectDir: string): Promise<void> {
await spawnAsync('git', ['init'], { cwd: projectDir });
await spawnAsync('git', ['add', '.'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir });
}
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
bundleId: string
): Promise<string> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
@@ -380,42 +332,37 @@ export default class Go extends EasCommand {
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
const projectId = await AppMutation.createAppAsync(graphqlClient, {
return await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
projectDir: string,
exp: ExpoConfig,
projectId: string,
bundleId: string,
appName: string,
graphqlClient: ExpoGraphqlClient,
actor: Actor,
analytics: Analytics,
vcsClient: VcsClient,
customizeCreds: boolean,
onBeforeAppleAuth?: () => void
): Promise<App> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive: false,
autoAcceptCredentialReuse: !customizeCreds,
projectDir,
projectDir: process.cwd(),
user: actor,
graphqlClient,
analytics,
vcsClient,
vcsClient: new NoVcsClient(),
});
onBeforeAppleAuth?.();
@@ -437,38 +384,23 @@ export default class Go extends EasCommand {
},
];
const ascApp = await withSuppressedOutputAsync(async () => {
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const ascAppResult = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
const easJsonPath = path.join(projectDir, 'eas.json');
const easJson = await fs.readJson(easJsonPath);
easJson.submit = easJson.submit || {};
easJson.submit.production = easJson.submit.production || {};
easJson.submit.production.ios = easJson.submit.production.ios || {};
easJson.submit.production.ios.ascAppId = ascAppResult.id;
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir });
return ascAppResult;
const ascApp = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
// Set up push notifications (outside suppressed block so prompts are visible)
@@ -494,36 +426,115 @@ export default class Go extends EasCommand {
private async runWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
vcsClient: VcsClient
slug: string,
bundleId: string,
appName: string,
ascAppId: string
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = actor.accounts[0];
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
});
const tmpDir = path.join(getTmpDirectory(), `eas-go-upload-${uuidv4()}`);
await fs.mkdirp(tmpDir);
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
try {
// Write minimal project files for upload
const easJson = {
cli: { version: '>= 5.0.0' },
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: { ascAppId },
},
},
};
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: { expo: '~54.0.0' },
};
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: { usesNonExemptEncryption: false },
},
extra: {
eas: {
projectId,
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
},
};
const easJsonPath = path.join(tmpDir, 'eas.json');
const packageJsonPath = path.join(tmpDir, 'package.json');
const appJsonPath = path.join(tmpDir, 'app.json');
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
await fs.writeJson(appJsonPath, appJson, { spaces: 2 });
// Create tarball directly (no git required)
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
await tar.create({ cwd: tmpDir, file: tarPath, prefix: 'project', gzip: true }, ['.']);
const projectArchiveBucketKey = await uploadAccountScopedFileAtPathToGCSAsync(
graphqlClient,
{
accountId: account.id,
type: AccountUploadSessionType.WorkflowsProjectSources,
path: tarPath,
handleProgressEvent: () => {},
}
);
await fs.remove(tarPath);
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: easJsonPath,
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: packageJsonPath,
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
} finally {
await fs.remove(tmpDir);
}
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
+4 -11
View File
@@ -21,10 +21,6 @@ export default class MetadataPull extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
};
static override contextDefinition = {
@@ -38,18 +34,18 @@ export default class MetadataPull extends EasCommand {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPull);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(MetadataPull, {
nonInteractive,
nonInteractive: false,
withServerSideEnvironment: null,
});
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
// this command is interactive (all nonInteractive flags passed to utility functions are false)
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
const submitProfiles = await getProfilesAsync({
type: 'submit',
@@ -70,7 +66,7 @@ export default class MetadataPull extends EasCommand {
user: actor,
graphqlClient,
analytics,
nonInteractive,
nonInteractive: false,
vcsClient,
});
@@ -81,9 +77,6 @@ export default class MetadataPull extends EasCommand {
credentialsCtx,
projectDir,
profile: submitProfile,
nonInteractive,
graphqlClient,
projectId,
});
const relativePath = path.relative(process.cwd(), filePath);
+4 -11
View File
@@ -19,10 +19,6 @@ export default class MetadataPush extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
};
static override contextDefinition = {
@@ -36,18 +32,18 @@ export default class MetadataPush extends EasCommand {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPush);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(MetadataPush, {
nonInteractive,
nonInteractive: false,
withServerSideEnvironment: null,
});
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
// this command is interactive (all nonInteractive flags passed to utility functions are false)
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
const submitProfiles = await getProfilesAsync({
type: 'submit',
@@ -68,7 +64,7 @@ export default class MetadataPush extends EasCommand {
user: actor,
graphqlClient,
analytics,
nonInteractive,
nonInteractive: false,
vcsClient,
});
@@ -79,9 +75,6 @@ export default class MetadataPush extends EasCommand {
credentialsCtx,
projectDir,
profile: submitProfile,
nonInteractive,
graphqlClient,
projectId,
});
Log.addNewLineIfNone();
@@ -73,12 +73,6 @@ export default class Onboarding extends EasCommand {
);
}
if (actor.__typename === 'PartnerActor') {
throw new Error(
'This command is not available for partner actors. Make sure you are using a user token and try again.'
);
}
if (!actor.preferences.onboarding) {
throw new Error(
'This command can only be run as part of the onboarding process started on the Expo website. Visit https://expo.new to start a new project.'
+12 -16
View File
@@ -12,14 +12,14 @@ import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
function printLogsForAllSteps(logs: WorkflowLogs): void {
[...logs.values()].forEach(({ label, logLines }) => {
if (logLines.length === 0) {
return;
[...logs.keys()].forEach(step => {
const logLines = logs.get(step);
if (logLines) {
Log.log(`Step: ${step}`);
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
Log.log(`Step: ${label}`);
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
Log.addNewLineIfNone();
});
}
@@ -89,25 +89,21 @@ export default class WorkflowLogView extends EasCommand {
if (allSteps) {
if (logs) {
if (flags.json) {
printJsonOnlyOutput(
Object.fromEntries(
Array.from(logs.entries()).map(([key, value]) => [key, value.logLines])
)
);
printJsonOnlyOutput(Object.fromEntries(logs));
} else {
printLogsForAllSteps(logs);
}
}
} else {
const selectedStep = finalSelectionState?.step as unknown as string;
const logGroup = logs?.get(selectedStep);
if (logGroup) {
const logLines = logs?.get(selectedStep);
if (logLines) {
if (flags.json) {
const output: { [key: string]: WorkflowLogLine[] | null } = {};
output[selectedStep] = logGroup.logLines;
output[selectedStep] = logLines ?? null;
printJsonOnlyOutput(output);
} else {
logGroup.logLines.forEach(line => {
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
+8236 -1605
View File
File diff suppressed because it is too large. Load diff
@@ -43,9 +43,6 @@ export const UserQuery = {
id
...AccountFragment
}
... on PartnerActor {
username
}
featureGates
isExpoAdmin
}
@@ -23,9 +23,6 @@ export const UpdateFragmentNode = gql`
... on Robot {
firstName
}
... on PartnerActor {
username
}
}
branch {
id
@@ -1,232 +0,0 @@
import { App } from '@expo/apple-utils';
import { SubmitProfile } from '@expo/eas-json';
import { getAppStoreAuthAsync } from '../auth';
import { AuthenticationMode } from '../../credentials/ios/appstore/authenticateTypes';
// Mock all external dependencies
jest.mock('@expo/apple-utils', () => ({
App: { findAsync: jest.fn() },
}));
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
getRequestContext: jest.fn(() => ({ token: 'mock-token' })),
}));
jest.mock('../../credentials/ios/appstore/resolveCredentials', () => ({
hasAscEnvVars: jest.fn(() => false),
}));
jest.mock('../../project/ios/bundleIdentifier', () => ({
getBundleIdentifierAsync: jest.fn(() => 'com.example.app'),
}));
jest.mock('../../project/projectUtils', () => ({
getOwnerAccountForProjectIdAsync: jest.fn(() => ({
id: 'account-id',
name: 'testaccount',
})),
}));
jest.mock('../../credentials/ios/api/GraphqlClient', () => ({
getAscApiKeyForAppSubmissionsAsync: jest.fn(() => null),
}));
jest.mock('../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
AppStoreConnectApiKeyQuery: {
getByIdAsync: jest.fn(),
},
}));
jest.mock('fs', () => ({
promises: {
readFile: jest.fn(() => 'mock-key-p8-content'),
},
}));
jest.mock('../../log');
const { hasAscEnvVars } =
require('../../credentials/ios/appstore/resolveCredentials') as jest.Mocked<
typeof import('../../credentials/ios/appstore/resolveCredentials')
>;
const { getAscApiKeyForAppSubmissionsAsync } =
require('../../credentials/ios/api/GraphqlClient') as jest.Mocked<
typeof import('../../credentials/ios/api/GraphqlClient')
>;
const { AppStoreConnectApiKeyQuery } =
require('../../graphql/queries/AppStoreConnectApiKeyQuery') as jest.Mocked<
typeof import('../../graphql/queries/AppStoreConnectApiKeyQuery')
>;
const mockApp = { id: '123', bundleId: 'com.example.app' };
function createMockCredentialsCtx(ensureAuthResult?: any) {
return {
appStore: {
ensureAuthenticatedAsync: jest.fn(() => ({
authState: { context: { token: 'mock-token' } },
...ensureAuthResult,
})),
},
vcsClient: {},
} as any;
}
function createBaseArgs(overrides: Record<string, any> = {}) {
return {
projectDir: '/app',
profile: { bundleIdentifier: 'com.example.app' } as SubmitProfile,
exp: { slug: 'my-app', name: 'My App' } as any,
credentialsCtx: createMockCredentialsCtx(),
nonInteractive: false,
graphqlClient: {} as any,
projectId: 'test-project-id',
...overrides,
};
}
describe(getAppStoreAuthAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
(App.findAsync as jest.Mock).mockResolvedValue(mockApp);
hasAscEnvVars.mockReturnValue(false);
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue(null);
});
it('uses API key auth when ASC env vars are set', async () => {
hasAscEnvVars.mockReturnValue(true);
const args = createBaseArgs();
const result = await getAppStoreAuthAsync(args);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({ mode: AuthenticationMode.API_KEY })
);
expect(result.app).toBe(mockApp);
});
it('uses API key from submit profile when ascApiKeyPath/Id/IssuerId are set', async () => {
const profile = {
bundleIdentifier: 'com.example.app',
ascApiKeyPath: '/path/to/key.p8',
ascApiKeyId: 'KEY123',
ascApiKeyIssuerId: 'ISSUER456',
} as any;
const args = createBaseArgs({ profile });
const result = await getAppStoreAuthAsync(args);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({
mode: AuthenticationMode.API_KEY,
ascApiKey: {
keyP8: 'mock-key-p8-content',
keyId: 'KEY123',
issuerId: 'ISSUER456',
},
})
);
expect(result.app).toBe(mockApp);
});
it('uses API key from EAS credentials service when available', async () => {
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
id: 'asc-key-id',
keyIdentifier: 'KEY789',
issuerIdentifier: 'ISSUER012',
appleTeam: {
appleTeamIdentifier: 'TEAM123',
appleTeamName: 'Test Team',
},
});
(AppStoreConnectApiKeyQuery.getByIdAsync as jest.Mock).mockResolvedValue({
keyP8: 'stored-key-p8',
keyIdentifier: 'KEY789',
issuerIdentifier: 'ISSUER012',
});
const args = createBaseArgs();
const result = await getAppStoreAuthAsync(args);
expect(AppStoreConnectApiKeyQuery.getByIdAsync).toHaveBeenCalledWith(
args.graphqlClient,
'asc-key-id'
);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({
mode: AuthenticationMode.API_KEY,
ascApiKey: {
keyP8: 'stored-key-p8',
keyId: 'KEY789',
issuerId: 'ISSUER012',
},
teamId: 'TEAM123',
teamName: 'Test Team',
})
);
expect(result.app).toBe(mockApp);
});
it('falls back to interactive cookie auth when no API key and interactive mode', async () => {
const args = createBaseArgs({ nonInteractive: false });
const result = await getAppStoreAuthAsync(args);
// Should call ensureAuthenticatedAsync without API key options
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
expect(result.app).toBe(mockApp);
});
it('throws in non-interactive mode when no API key is available', async () => {
const args = createBaseArgs({ nonInteractive: true });
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
});
it('falls back gracefully when credentials service lookup fails', async () => {
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
// Interactive mode: should fall back to cookie auth
const args = createBaseArgs({ nonInteractive: false });
const result = await getAppStoreAuthAsync(args);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
expect(result.app).toBe(mockApp);
});
it('falls back gracefully when credentials service fails in non-interactive mode', async () => {
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
const args = createBaseArgs({ nonInteractive: true });
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
});
it('prefers submit profile key over credentials service', async () => {
const profile = {
bundleIdentifier: 'com.example.app',
ascApiKeyPath: '/path/to/key.p8',
ascApiKeyId: 'PROFILE_KEY',
ascApiKeyIssuerId: 'PROFILE_ISSUER',
} as any;
// Also set up a stored key that should NOT be used
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
id: 'stored-key-id',
keyIdentifier: 'STORED_KEY',
issuerIdentifier: 'STORED_ISSUER',
});
const args = createBaseArgs({ profile });
await getAppStoreAuthAsync(args);
// Should use profile key, not the stored one
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({
ascApiKey: expect.objectContaining({ keyId: 'PROFILE_KEY' }),
})
);
// Should NOT have called getByIdAsync since profile key was found first
expect(AppStoreConnectApiKeyQuery.getByIdAsync).not.toHaveBeenCalled();
});
});
@@ -1,100 +0,0 @@
import fs from 'fs-extra';
import { downloadMetadataAsync } from '../download';
import { MetadataValidationError } from '../errors';
jest.mock('../auth', () => ({
getAppStoreAuthAsync: jest.fn(() => ({
app: { id: '123' },
auth: { context: { token: 'mock' } },
})),
}));
jest.mock('../apple/tasks', () => ({
createAppleTasks: jest.fn(() => []),
}));
jest.mock('../config/resolve', () => ({
createAppleWriter: jest.fn(() => ({
toSchema: jest.fn(() => ({ configVersion: 0 })),
})),
getStaticConfigFilePath: jest.fn(() => '/app/store.config.json'),
}));
jest.mock('../utils/telemetry', () => ({
subscribeTelemetryAsync: jest.fn(() => ({
unsubscribeTelemetry: jest.fn(),
executionId: 'exec-123',
})),
}));
jest.mock('fs-extra', () => ({
pathExists: jest.fn(() => false),
writeJSON: jest.fn(),
}));
jest.mock('../../log');
jest.mock('../../prompts', () => ({
confirmAsync: jest.fn(),
}));
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
function createArgs(overrides: Record<string, any> = {}) {
return {
projectDir: '/app',
profile: { bundleIdentifier: 'com.example.app' } as any,
exp: { slug: 'my-app', name: 'My App' } as any,
analytics: {} as any,
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
nonInteractive: false,
graphqlClient: {} as any,
projectId: 'test-project-id',
...overrides,
};
}
describe(downloadMetadataAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
(fs.pathExists as jest.Mock).mockResolvedValue(false);
});
it('skips overwrite prompt and auto-overwrites in non-interactive mode', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
await downloadMetadataAsync(createArgs({ nonInteractive: true }));
expect(confirmAsync).not.toHaveBeenCalled();
expect(fs.writeJSON).toHaveBeenCalled();
});
it('prompts for overwrite in interactive mode when file exists', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
(confirmAsync as jest.Mock).mockResolvedValue(true);
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('overwrite') })
);
});
it('throws MetadataValidationError when user declines overwrite in interactive mode', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
(confirmAsync as jest.Mock).mockResolvedValue(false);
await expect(downloadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
MetadataValidationError
);
});
it('does not prompt when file does not exist', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
expect(confirmAsync).not.toHaveBeenCalled();
});
});
@@ -1,103 +0,0 @@
import { uploadMetadataAsync } from '../upload';
import { MetadataValidationError } from '../errors';
jest.mock('../auth', () => ({
getAppStoreAuthAsync: jest.fn(() => ({
app: { id: '123' },
auth: { context: { token: 'mock' } },
})),
}));
jest.mock('../apple/tasks', () => ({
createAppleTasks: jest.fn(() => []),
}));
jest.mock('../config/resolve', () => ({
createAppleReader: jest.fn(() => ({
getVersion: jest.fn(() => ({ versionString: '1.0.0' })),
})),
loadConfigAsync: jest.fn(() => ({ configVersion: 0 })),
}));
jest.mock('../utils/telemetry', () => ({
subscribeTelemetryAsync: jest.fn(() => ({
unsubscribeTelemetry: jest.fn(),
executionId: 'exec-123',
})),
}));
jest.mock('../../log');
jest.mock('../../prompts', () => ({
confirmAsync: jest.fn(),
}));
const { loadConfigAsync } = require('../config/resolve') as jest.Mocked<
typeof import('../config/resolve')
>;
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
function createArgs(overrides: Record<string, any> = {}) {
return {
projectDir: '/app',
profile: { metadataPath: 'store.config.json', bundleIdentifier: 'com.example.app' } as any,
exp: { slug: 'my-app', name: 'My App' } as any,
analytics: {} as any,
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
nonInteractive: false,
graphqlClient: {} as any,
projectId: 'test-project-id',
...overrides,
};
}
describe(uploadMetadataAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
(loadConfigAsync as jest.Mock).mockResolvedValue({ configVersion: 0 });
});
it('throws MetadataValidationError directly in non-interactive mode without prompting', async () => {
(loadConfigAsync as jest.Mock).mockRejectedValue(
new MetadataValidationError('validation errors found')
);
await expect(uploadMetadataAsync(createArgs({ nonInteractive: true }))).rejects.toThrow(
MetadataValidationError
);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('prompts user about validation errors in interactive mode', async () => {
(loadConfigAsync as jest.Mock).mockRejectedValueOnce(
new MetadataValidationError('validation errors found')
);
(confirmAsync as jest.Mock).mockResolvedValue(true);
// Second call (with skipValidation) succeeds
(loadConfigAsync as jest.Mock).mockResolvedValueOnce({ configVersion: 0 });
await uploadMetadataAsync(createArgs({ nonInteractive: false }));
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('still want to push') })
);
});
it('re-throws validation error when user declines to continue in interactive mode', async () => {
(loadConfigAsync as jest.Mock).mockRejectedValue(
new MetadataValidationError('validation errors found')
);
(confirmAsync as jest.Mock).mockResolvedValue(false);
await expect(uploadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
MetadataValidationError
);
});
it('uploads successfully when config is valid', async () => {
const result = await uploadMetadataAsync(createArgs());
expect(result).toHaveProperty('appleLink');
expect(result.appleLink).toContain('appstoreconnect.apple.com');
});
});
+2 -140
View File
@@ -2,22 +2,10 @@ import { App, Session } from '@expo/apple-utils';
import { ExpoConfig } from '@expo/config';
import { SubmitProfile } from '@expo/eas-json';
import assert from 'assert';
import fs from 'fs';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { AppLookupParams } from '../credentials/ios/api/graphql/types/AppLookupParams';
import {
Options as AuthOptions,
getRequestContext,
} from '../credentials/ios/appstore/authenticate';
import { AppleTeamType, AuthenticationMode } from '../credentials/ios/appstore/authenticateTypes';
import { hasAscEnvVars } from '../credentials/ios/appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials/ios/credentials';
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../log';
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { Client } from '../vcs/vcs';
export type MetadataAppStoreAuthentication = {
@@ -44,107 +32,20 @@ async function resolveAppStoreBundleIdentifierAsync(
return await getBundleIdentifierAsync(projectDir, exp, vcsClient);
}
type ResolvedAscApiKey = {
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
};
/**
* Try to resolve an ASC API key from the submit profile or EAS credentials service.
* Returns null if no key is available from these sources.
*/
async function tryResolveAscApiKeyAsync({
profile,
graphqlClient,
projectId,
exp,
bundleId,
}: {
profile: SubmitProfile;
graphqlClient: ExpoGraphqlClient;
projectId: string;
exp: ExpoConfig;
bundleId: string;
}): Promise<ResolvedAscApiKey | null> {
// 1. Check submit profile for ASC API key fields
if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) {
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile;
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8');
// Also try to get teamId from the profile if available
const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined;
return {
ascApiKey: { keyP8, keyId: ascApiKeyId, issuerId: ascApiKeyIssuerId },
teamId,
};
}
}
// 2. Look up stored credentials via EAS credentials service
try {
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const appLookupParams: AppLookupParams = {
account,
projectName: exp.slug,
bundleIdentifier: bundleId,
};
// Import dynamically to avoid circular dependency issues
const { getAscApiKeyForAppSubmissionsAsync } =
await import('../credentials/ios/api/GraphqlClient');
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, appLookupParams);
if (ascKeyFragment) {
Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(
graphqlClient,
ascKeyFragment.id
);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
} catch (error: any) {
// If we can't look up credentials, that's fine — we'll fall back
Log.warn(`Could not look up stored ASC API key: ${error.message}`);
}
return null;
}
/**
* To start syncing ASC entities, we need access to the apple utils App instance.
* This resolves both the authentication and that App instance.
*
* Resolution order for authentication:
* 1. ASC API key from environment variables (EXPO_ASC_API_KEY_PATH, etc.)
* 2. ASC API key from submit profile (ascApiKeyPath, etc. in eas.json)
* 3. ASC API key from EAS credentials service
* 4. Interactive cookie auth (only when not in non-interactive mode)
*/
export async function getAppStoreAuthAsync({
projectDir,
profile,
exp,
credentialsCtx,
nonInteractive,
graphqlClient,
projectId,
}: {
projectDir: string;
profile: SubmitProfile;
exp: ExpoConfig;
credentialsCtx: CredentialsContext;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<MetadataAppStoreAuthentication> {
const bundleId = await resolveAppStoreBundleIdentifierAsync(
projectDir,
@@ -153,49 +54,10 @@ export async function getAppStoreAuthAsync({
credentialsCtx.vcsClient
);
// Try to resolve an ASC API key from profile or credentials service
const resolvedKey = await tryResolveAscApiKeyAsync({
profile,
graphqlClient,
projectId,
exp,
bundleId,
});
if (resolvedKey || hasAscEnvVars()) {
const authOptions: AuthOptions = {
mode: AuthenticationMode.API_KEY,
...(resolvedKey
? {
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
// Default to COMPANY_OR_ORGANIZATION to avoid prompting for team type
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
}
: {}),
};
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync(authOptions);
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
return { app, auth: authCtx.authState };
}
if (nonInteractive) {
throw new Error(
'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' +
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
' - EAS credentials service: run `eas credentials` to set up an API key'
);
}
// Fall back to interactive cookie auth
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync();
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
// TODO: improve error handling by mentioning possible configuration errors
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
+6 -20
View File
@@ -10,7 +10,6 @@ import { createAppleWriter, getStaticConfigFilePath } from './config/resolve';
import { MetadataDownloadError, MetadataValidationError } from './errors';
import { subscribeTelemetryAsync } from './utils/telemetry';
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import Log from '../log';
import { confirmAsync } from '../prompts';
@@ -25,33 +24,23 @@ export async function downloadMetadataAsync({
exp,
analytics,
credentialsCtx,
nonInteractive,
graphqlClient,
projectId,
}: {
projectDir: string;
profile: SubmitProfile;
exp: ExpoConfig;
analytics: Analytics;
credentialsCtx: CredentialsContext;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<string> {
const filePath = getStaticConfigFilePath({ projectDir, profile });
const fileExists = await fs.pathExists(filePath);
if (fileExists) {
if (nonInteractive) {
Log.log(`Overwriting existing store config at "${path.relative(projectDir, filePath)}".`);
} else {
const filePathRelative = path.relative(projectDir, filePath);
const overwrite = await confirmAsync({
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
});
if (!overwrite) {
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
}
const filePathRelative = path.relative(projectDir, filePath);
const overwrite = await confirmAsync({
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
});
if (!overwrite) {
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
}
}
@@ -60,9 +49,6 @@ export async function downloadMetadataAsync({
credentialsCtx,
projectDir,
profile,
nonInteractive,
graphqlClient,
projectId,
});
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
+2 -22
View File
@@ -9,7 +9,6 @@ import { MetadataConfig } from './config/schema';
import { MetadataUploadError, MetadataValidationError, logMetadataValidationError } from './errors';
import { subscribeTelemetryAsync } from './utils/telemetry';
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import Log from '../log';
import { confirmAsync } from '../prompts';
@@ -24,32 +23,19 @@ export async function uploadMetadataAsync({
exp,
analytics,
credentialsCtx,
nonInteractive,
graphqlClient,
projectId,
}: {
projectDir: string;
profile: SubmitProfile;
exp: ExpoConfig;
analytics: Analytics;
credentialsCtx: CredentialsContext;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<{ appleLink: string }> {
const storeConfig = await loadConfigWithValidationPromptAsync(
projectDir,
profile,
nonInteractive
);
const storeConfig = await loadConfigWithValidationPromptAsync(projectDir, profile);
const { app, auth } = await getAppStoreAuthAsync({
exp,
credentialsCtx,
projectDir,
profile,
nonInteractive,
graphqlClient,
projectId,
});
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
@@ -98,18 +84,12 @@ export async function uploadMetadataAsync({
async function loadConfigWithValidationPromptAsync(
projectDir: string,
profile: SubmitProfile,
nonInteractive: boolean
profile: SubmitProfile
): Promise<MetadataConfig> {
try {
return await loadConfigAsync({ projectDir, profile });
} catch (error) {
if (error instanceof MetadataValidationError) {
if (nonInteractive) {
logMetadataValidationError(error);
throw error;
}
logMetadataValidationError(error);
Log.newLine();
Log.warn(
@@ -137,81 +137,21 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { android: { package: 'com.jester.myapp' } },
});
});
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
it('throws an error in non-interactive mode', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
'app.json': '{ "blah": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: '' } as any,
projectId: '',
exp: {} as any,
vcsClient,
nonInteractive: true,
})
@@ -148,8 +148,15 @@ async function configureApplicationIdAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
const paths = getConfigFilePaths(projectDir);
if (nonInteractive) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -159,50 +166,25 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
let packageName: string;
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
if (nonInteractive) {
let suggestedAndroidApplicationId: string | undefined;
try {
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedAndroidApplicationId) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
packageName = suggestedAndroidApplicationId;
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
} else {
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const result = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
packageName = result.packageName;
}
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -115,81 +115,20 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
it('throws an error in non-interactive mode', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
'app.json': '{ "blah": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
});
});
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: '' } as any,
exp: {} as any,
vcsClient,
nonInteractive: true,
})
@@ -137,8 +137,15 @@ async function configureBundleIdentifierAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
const paths = getConfigFilePaths(projectDir);
if (nonInteractive) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -148,51 +155,26 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
let bundleIdentifier: string;
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
if (nonInteractive) {
let suggestedBundleIdentifier: string | undefined;
try {
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedBundleIdentifier) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
bundleIdentifier = suggestedBundleIdentifier;
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
} else {
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
const result = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
});
bundleIdentifier = result.bundleIdentifier;
}
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -10,8 +10,21 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { AccountFragment } from '../graphql/generated';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { learnMore } from '../log';
import { Actor } from '../user/User';
import { expoCommandAsync } from '../utils/expoCli';
export function getUsernameForBuildMetadataAndBuildJob(user: Actor): string | undefined {
switch (user.__typename) {
case 'User':
return user.username;
case 'SSOUser':
return user.username;
case 'Robot':
// robot users don't have usernames
return undefined;
}
}
/**
* Return a useful name describing the project config.
* - dynamic: app.config.js
-22
View File
@@ -1,22 +0,0 @@
import * as Sentry from '@sentry/node';
import { easCliVersion } from './utils/easCli';
Sentry.init({
dsn: process.env.EAS_CLI_SENTRY_DSN,
enabled: !!process.env.EAS_CLI_SENTRY_DSN,
environment: getSentryEnvironment(),
release: easCliVersion ? `eas-cli@${easCliVersion}` : undefined,
});
Sentry.setTag('source', 'eas-cli');
function getSentryEnvironment(): string {
if (process.env.EXPO_LOCAL) {
return 'local';
} else if (process.env.EXPO_STAGING) {
return 'staging';
}
return 'production';
}
export default Sentry;
-3
View File
@@ -7,7 +7,6 @@ import semver from 'semver';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
PartnerActor,
Robot,
SsoUser,
Update,
@@ -30,7 +29,6 @@ export type FormatUpdateParameter = Pick<Update, 'id' | 'createdAt' | 'message'>
| Pick<Robot, '__typename' | 'firstName'>
| Pick<User, '__typename' | 'username'>
| Pick<SsoUser, '__typename' | 'username'>
| Pick<PartnerActor, '__typename' | 'username'>
| null;
};
@@ -196,7 +194,6 @@ export function formatUpdateTitle(update: UpdateFragment): string {
let actorName: string;
switch (actor?.__typename) {
case 'User':
case 'PartnerActor':
case 'SSOUser': {
actorName = actor.username;
break;
+1 -5
View File
@@ -1,4 +1,4 @@
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
import { CurrentUserQuery, Robot, SsoUser, User } from '../graphql/generated';
export type Actor = NonNullable<CurrentUserQuery['meActor']>;
@@ -12,7 +12,6 @@ export function getActorDisplayName(
| Pick<Robot, '__typename' | 'firstName'>
| Pick<User, '__typename' | 'username'>
| Pick<SsoUser, '__typename' | 'username'>
| Pick<PartnerActor, '__typename' | 'username'>
| null
): string {
switch (actor?.__typename) {
@@ -22,8 +21,6 @@ export function getActorDisplayName(
return actor.firstName ? `${actor.firstName} (robot)` : 'robot';
case 'SSOUser':
return actor.username;
case 'PartnerActor':
return actor.username;
case undefined:
return 'unknown';
}
@@ -33,7 +30,6 @@ export function getActorUsername(actor?: Actor): string | null {
switch (actor?.__typename) {
case 'User':
case 'SSOUser':
case 'PartnerActor':
return actor.username;
case 'Robot':
case undefined:
@@ -1,86 +1,24 @@
import assert from 'assert';
import openBrowserAsync from 'better-opn';
import crypto from 'crypto';
import http from 'http';
import { Socket } from 'node:net';
import querystring from 'querystring';
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../api';
import fetch from '../fetch';
import { getExpoWebsiteBaseUrl } from '../api';
import Log from '../log';
const CLIENT_ID = 'eas-cli';
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(codeVerifier: string): string {
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
}
function generateState(): string {
return crypto.randomBytes(32).toString('base64url');
}
async function exchangeCodeForSessionSecretAsync({
code,
codeVerifier,
redirectUri,
}: {
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<string> {
const tokenUrl = `${getExpoApiBaseUrl()}/v2/auth/token`;
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
client_id: CLIENT_ID,
}),
});
const result: any = await response.json();
const sessionSecret = result?.data?.session_secret;
if (!sessionSecret) {
throw new Error('Failed to obtain session secret from token exchange.');
}
return sessionSecret;
}
export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Promise<string> {
const scheme = 'http';
const hostname = 'localhost';
const callbackPath = '/auth/callback';
const path = '/auth/callback';
const expoWebsiteUrl = getExpoWebsiteBaseUrl();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
const buildRedirectUri = (port: number): string =>
`${scheme}://${hostname}:${port}${callbackPath}`;
const buildExpoLoginUrl = (port: number, sso: boolean): string => {
// Note: we avoid URLSearchParams here because better-opn calls encodeURI()
// on the URL before passing it to AppleScript, which would double-encode
// the percent-encoded values from URLSearchParams.toString().
const params = [
`client_id=${CLIENT_ID}`,
`redirect_uri=${buildRedirectUri(port)}`,
`response_type=code`,
`code_challenge=${codeChallenge}`,
`code_challenge_method=S256`,
`state=${state}`,
`confirm_account=true`,
].join('&');
const params = querystring.stringify({
confirm_account: true,
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
});
return `${expoWebsiteUrl}${sso ? '/sso-login' : '/login'}?${params}`;
};
@@ -101,39 +39,22 @@ export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Prom
}
};
const handleRequestAsync = async (): Promise<void> => {
try {
if (!(request.method === 'GET' && request.url?.includes('/auth/callback'))) {
throw new Error('Unexpected login response.');
}
const url = new URL(request.url, `http:${request.headers.host}`);
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
const sessionSecret = url.searchParams.get('session_secret');
if (!code) {
throw new Error('Request missing code search parameter.');
if (!sessionSecret) {
throw new Error('Request missing session_secret search parameter.');
}
if (returnedState !== state) {
throw new Error('State mismatch. Possible CSRF attack.');
}
const address = server.address();
assert(address !== null && typeof address === 'object');
const redirectUri = buildRedirectUri(address.port);
const sessionSecret = await exchangeCodeForSessionSecretAsync({
code,
codeVerifier,
redirectUri,
});
resolve(sessionSecret);
redirectAndCleanup('success');
};
handleRequestAsync().catch(error => {
} catch (error) {
redirectAndCleanup('error');
reject(error);
});
}
}
);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "18.4.0",
"version": "18.2.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -26,13 +26,13 @@
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"test": "jest",
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-build-job": "18.2.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.4.0",
"version": "18.2.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -21,13 +21,13 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --passWithNoTests",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"@expo/build-tools": "18.4.0",
"@expo/eas-build-job": "18.4.0",
"@expo/build-tools": "18.2.0",
"@expo/eas-build-job": "18.2.0",
"@expo/logger": "18.0.1",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "18.0.1",
@@ -50,7 +50,6 @@
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.7",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
},
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more