Compare commits

..
21 Commits
Author SHA1 Message Date
Expo CI 5f400d7766 v18.2.0 2026-03-09 22:17:42 +00:00
Hassan Khan 4338aea702 feat(cli): add flag to prevent uploading server sourcemaps (#3478) 2026-03-09 18:45:07 +00:00
Stanisław Chmiela ecf42e68f3 [eas-build-job] Add configure Android version build phase (#3483)
# Why

Preparation for https://github.com/expo/eas-cli/pull/3483.

# How

- add `BuildPhase.CONFIGURE_ANDROID_VERSION`
- add display metadata for the new phase
- add website ID metadata for the new phase

# Test Plan

CI should pass.
2026-03-09 16:25:01 +01:00
Stanisław Chmiela f23579fa32 [eas-cli] Upgrade eas-cli to oclif v4 (#3472)
## Why

Old task I asked Codex to do.

## How

- upgrade `eas-cli` from oclif v3 to v4 using the native v4 APIs
- migrate legacy array-style `static args = [...]` declarations to native v4 object-style `Args.*` definitions
- keep command typing fully native and type-safe, including `Flags.option(...).options` const assertions where needed for inference
- update tests and shared test helpers for the v4 config/hook behavior

Notes:
- argument order is preserved in oclif v4 because `static args = { ... }` is treated as an ordered object; positional args follow object insertion order
- the bin entrypoints now use `@oclif/core` `execute(...)`, which is the supported v4 boot path
- `execute(...)` already performs the old `run().then(flush).catch(handle)` flow internally, so the explicit `flush` and `handle` chaining was removed as redundant rather than as a behavior change
- `bin/dev` keeps the dev entrypoint behavior through `execute({ development: true, ... })`, which enables the development boot path and verbose debugging behavior in v4

## Test plan

- `./node_modules/.bin/tsc --project packages/eas-cli/tsconfig.build.json`
- `node packages/eas-cli/bin/run --help`
- `node packages/eas-cli/bin/dev --help`
- `npx -y @yarnpkg/[email protected].0 workspace eas-cli test --runInBand`
2026-03-09 11:18:15 +01:00
Stanisław Chmiela afdc626fcb [build-tools] Introduce ExpoError as the shared build error base (#3470)
## Summary
- add `ExpoError` as the shared base for build-related errors
- centralize shared metadata and formatting there
- keep runtime behavior unchanged

## Changes
- introduce `ExpoError` with `errorCode`, `trackingCode`, `docsUrl`, `cause`, and `buildPhase`
- make `BuildError` and `UserFacingError` extend `ExpoError`
- add unit coverage for the shared base behavior

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn build && npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-09 10:57:56 +01:00
Evan BaconandClaude Opus 4.5 f9f4583a39 [eas-cli] Auto-generate Android keystore in non-interactive mode (#3477)
Instead of throwing an error when running `eas build -p android --non-interactive`
without existing credentials, automatically generate a keystore using the existing
`generateRandomKeystoreAsync()` function.

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-03-07 13:11:03 -08:00
Krystof WoldrichandClaude Opus 4.6 2d955440ca feat(cli): enable --non-interactive when --json (#3476)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-07 11:45:07 +01:00
Mustafa Abban 642bb6fa4d [build-tools] Remove cache support check in restoreCache for build jobs (#3475) 2026-03-06 14:42:30 -06:00
Douglas Lowder fb1f2d2add [cli] CI check and environment flag override for update (#3442)
# Why

Provide a way to override the new requirement for the `--environment` flag in `eas update`.

# How

Modify the check to skip when the `EAS_UPDATE_SKIP_ENVIRONMENT_CHECK` variable is set to a boolish value (1 or true).

If the command is run interactively, we now prompt the user for the environment, instead of throwing an error. This is skipped if the `--auto` flag is set. A missing environment flag already throws an error if `--non-interactive` is set.

Add the `CI` flag to the tests for whether we should skip the check in a CI environment.

# Test Plan

New unit tests added.
2026-03-06 06:55:52 -08:00
Stanisław Chmiela dbcca53f1d [worker] Add HttpLogStream (#3391)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We want to push logs to a logs service.

# How

Initially I wanted to set up a WebSocket connection between worker and the logs service to reduce the HTTP overhead, but I found having a persistent/reconnecting WebSocket connection is not trivial and HTTP should be ok.

Confirmed failure to upload is not logged in the GCS log.

# Test Plan

Tested manually.


https://github.com/user-attachments/assets/f80f0482-73f5-4dfb-855f-2361ed04aa4f
2026-03-06 14:43:58 +01:00
Stanisław Chmiela 4a0dc9f426 [worker] Remove duplicated user-facing fields from build errors (#3469)
## Summary
- remove the duplicated `userFacingMessage` and `userFacingErrorCode` fields from the build error flow
- keep current runtime behavior and unknown-error semantics
- preserve the current worker protocol shape

Stanley: I don't want to have separate `userFacingMessage` and `message` / `userFacingErrorCode` and `errorCode`. It looks like the reason we had them separate was to log "UNKNOWN_ERROR" to the user if we encounter an internal issue like "NPM_CORRUPTED_PACKAGE". This pull request inverts the logic — we're going to have a `trackingCode` so we keep logging "NPM_CORRUPTED_PACKAGE" to Sentry and the default `errorCode` is the one we log to the user.

## Changes
- make `BuildError.format()` use canonical `message` and `errorCode`
- make `build-tools` store internal-only classification in `trackingCode` instead of parallel public fields
- make worker derive `internalErrorCode` from `trackingCode ?? errorCode`
- delete the obsolete `userFacing...` fields from `BuildError`

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-06 14:11:10 +01:00
Stanisław Chmiela 146656c7f7 [build-tools] Handle ASC 90478 as closed version train (#3460)
## Why
`submit_ios_from_linux` UNKNOWN_ERROR analysis showed App Store Connect responses that clearly indicate the same version-train failure class but with mixed codes (`90062`, `90186`, `90478`).

Current handling recognizes only `90062` and `90186`, so runs that include `90478` fall back to `UNKNOWN_ERROR`.

## How
- extend `isClosedVersionTrainError` to include ASC code `90478` while keeping strict `.every` matching
- broaden `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` user-facing message to cover both closed train and lower-than-approved version cases
- update unit test to include `90478` in positive closed-train detection

## Examples
Existing `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` runs:
- https://expo.dev/uuid/019cbe83-ac3b-756f-b7ef-447bae5ef4dc
- https://expo.dev/uuid/019cbe66-4f9b-7d95-a35e-06886b646434

Would-be closed-train runs currently persisted as `UNKNOWN_ERROR` (log errors include `90478` with `90062`/`90186`):
- https://expo.dev/uuid/019cbe71-62fa-7fee-9e77-bdc051cc6e8b
- https://expo.dev/uuid/019cbe44-5c75-7211-af66-35fe96a86903

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-05 16:29:48 +01:00
Phil Pluckthun e37411040f [build-tools] Add --include=dev for npm install and --production false for Yarn classic (#3459)
* Add missing `--include=dev` for regular npm install

* Override NODE_ENV globally for yarn

* Add `--production false` for Yarn Classic and remove NODE_ENV change

* Add CHANGELOG entry
2026-03-05 14:39:03 +00:00
Mustafa Abban b71226d90f [worker] Handle non-OK HTTP responses before JSON parsing in upload session (#3457) 2026-03-04 15:35:09 -06:00
Stanisław Chmiela 9a9fe11f73 [build-tools] Handle ASC missing purpose strings as user-facing error (#3456)
## Why
Uploads to App Store Connect can fail with code `90683` when required `*UsageDescription` privacy keys are missing in `Info.plist`.

Without dedicated handling this ends up as a generic upload failure, even though ASC already returns structured details that can guide the user.

## How
- add ASC error recognition for `90683` in `uploadToAsc` failed-state handling
- throw a specific `UserFacingError` with code `EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING`
- parse ASC error descriptions to extract and list missing `*UsageDescription` keys
- provide concrete remediation for both CNG (`ios.infoPlist`) and non-CNG (`Info.plist`) workflows
- set `docsUrl` on the user-facing error to Expo permissions docs
- add unit tests for detection and missing-key parsing helpers

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-04 21:27:59 +01:00
Stanisław Chmiela 1b62b6feae [build-tools] Add ASC invalid bundle ID user-facing error (#3455)
## Why
Some App Store Connect uploads currently end up as generic failures even when ASC returns explicit bundle identifier mismatch validation ([`90054`](https://expo.dev/accounts/dobrokruh/projects/dobrokruh/job-runs/019cb865-ef97-71d1-9137-f6ae4c05d94d)/[`90055`](https://expo.dev/accounts/central-studio/projects/hfc-fuels/job-runs/019cb882-1550-7801-9b4b-9647e1bce364)). We want a user-facing error that explains the mismatch and shows both identifiers so users can quickly determine whether they selected the wrong app or the wrong build profile/build.

## How
- add `isInvalidBundleIdentifierError` detector in `uploadToAsc` for ASC error codes `90054`/`90055`
- when matched, throw `UserFacingError` with code `EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID`
- include both identifiers in message:
  - IPA bundle identifier (best-effort read via `readIpaInfoAsync`)
  - selected ASC app bundle identifier
- provide remediation covering both paths:
  - wrong selected app identifier in submit profile
  - wrong build selected / rebuild with different profile
- use `asyncResult` for best-effort IPA introspection (no nested catch needed)
- add unit tests for invalid bundle-id detector

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
2026-03-04 13:58:10 +01:00
Stanisław Chmiela ebb215db1a [build-tools] Remove -z from nc (#3454)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Older (default) Android system image package does not support `-z`.

```
  Forward stdin/stdout to a file or network connection.

  -4	Force IPv4
  -6	Force IPv6
  -L	Listen for multiple incoming connections (server mode)
  -U	Use a UNIX domain socket
  -W	SECONDS timeout for more data on an idle connection
  -f	Use FILENAME (ala /dev/ttyS0) instead of network
  -l	Listen for one incoming connection
  -p	Local port number
  -q	Quit SECONDS after EOF on stdin, even if stdout hasn't closed yet
  -s	Local source address
  -t	Allocate tty (must come before -l or -L)
  -u	Use UDP
  -w	SECONDS timeout to establish connection
```

# How

Replaced `-z` with a closed stdin.

# Test Plan

System tests should work.

Deployed worker from this branch at https://github.com/expo/eas-cli/actions/runs/22666833774/job/65700594158.

Confirmed works at https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/workflows/019cb88e-f710-74d3-87ba-3c17bfe45c00.
2026-03-04 12:31:52 +01:00
Stanisław Chmiela cdf49377cd [build-tools] Replace ping with nc for Android network readiness check (#3453)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`ping` is blocked on Linux VMs. https://expo.dev/accounts/layerztec/projects/layerzwallet/workflows/019cb835-cf4a-7b9f-af32-db474797eb85

# How

Replaced `ping` check with `nc`.

# Test Plan

Tested manually on staging that it works. https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019cb862-4ea6-7966-9148-5cb8dbfb1724
2026-03-04 10:50:17 +00:00
Stanisław Chmiela 8fb84e86bb [build-tools] Harden Android emulator startup readiness and retries (#3450)
# Why

Android emulators started for workflow/Maestro runs can report boot completion while still lacking functional networking. This makes Maestro tests more flaky.

# How

- Extend `AndroidEmulatorUtils.waitForReadyAsync` to require both:
  - `sys.boot_completed == 1`
  - successful guest-side network probe (`ping 1.1.1.1`)
- Add reusable `AndroidEmulatorUtils.stopAsync` and compose `deleteAsync` from stop+delete logic.
- Add startup retries with escalating readiness timeouts (60s, 120s, 180s) in:
  - `eas/start_android_emulator` base emulator startup
  - `eas/start_android_emulator` clone startup path
  - Android startup path inside `eas/__maestro_test` (`withCleanDeviceAsync`)
- Keep retries at 3 total attempts (initial + 2 retries).

# Test Plan

Tested with integration tests.
2026-03-04 10:18:43 +01:00
Stanisław ChmielaandCopilot d660e3602d [worker] Preserve UserFacingError instead of UNKNOWN_ERROR (#3445)
## Why
Generic/custom workflow steps can throw `UserFacingError`, but worker-level error handling currently maps any non-`BuildError` to `UnknownBuildError` / `UnknownCustomBuildError`. That causes actionable step errors to be persisted and exposed as `UNKNOWN_ERROR`.

## How
- add a `toBuildError` conversion in worker service catch path
- preserve existing `BuildError` values
- convert `UserFacingError` into `BuildError` with the same code/message/docsUrl
- keep unknown fallbacks only for truly unstructured errors
- add integration coverage in `stateSync` to assert generic job `UserFacingError` is exposed in `externalBuildError` and `internalErrorCode`

We should make a bigger refactor to error handling, but it's more involved — https://github.com/expo/eas-cli/pull/3447.

## Test Plan

CI should pass.


```
 PASS  src/__integration__/stateSync.test.ts
  State sync mechanism
    query new worker
      ✓ should return 'new' state (210 ms)
    query worker after dispatch
      ✓ should return 'in-progress' state (170 ms)
    query worker after build
      ✓ should return 'success' state (2062 ms)
    query worker after error
      ✓ should return 'error' state with artifacts from error (88 ms)
      ✓ should expose user-facing error from generic job (60 ms)
```

Co-authored-by: Copilot <[email protected]>
2026-03-04 09:38:36 +01:00
Expo CI 5b9d47c02b update CHANGELOG.md 2026-03-04 08:36:00 +00:00
151 changed files with 5362 additions and 4264 deletions

No files matched your search

@@ -12,6 +12,8 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
+18 -2
View File
@@ -8,6 +8,24 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
@@ -15,8 +33,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
### 🧹 Chores
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
+3 -1
View File
@@ -296,9 +296,11 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated with TypeScript and the linter before committing:
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.1.0",
"version": "18.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -26,7 +26,7 @@
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^3.9.0",
"oclif": "^4.22.85",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.1.0",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -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.0.2",
"@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.0.2",
"@expo/steps": "18.2.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
@@ -19,3 +19,7 @@ export enum LoggerLevel {
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
@@ -26,8 +26,14 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.metadata).toEqual({
packageName: '@typescript-eslint/[email protected]',
});
});
it('detects log for invalid bundler and reports it to user', async () => {
@@ -45,7 +51,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.message).toBe(
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.metadata).toBeUndefined();
});
it('does not detect errors if they show up in different build phase', async () => {
@@ -63,7 +73,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects npm cache error if cache is enabled', async () => {
@@ -82,8 +95,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
});
it('does not detect npm cache error if cache is disabled', async () => {
@@ -103,7 +119,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects xcode line error', async () => {
@@ -126,7 +145,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/path/to/'
);
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects minimum deployment target error correctly', async () => {
@@ -144,7 +163,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -162,7 +181,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects generic "Run Fastlane" error for resign correctly', async () => {
@@ -178,7 +197,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects build error in "Run Fastlane" phase correctly', async () => {
@@ -194,7 +213,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -240,8 +259,8 @@ note`;
'/path/to'
);
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
expect(err.userFacingMessage)
expect(err.trackingCode).toBeUndefined();
expect(err.message)
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
- Some other error
@@ -263,9 +282,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
'/fake/path'
);
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -285,8 +304,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
expect(err.trackingCode).toBeUndefined();
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -6,7 +6,8 @@ import { ErrorContext, ErrorHandler } from './errors.types';
export class TrackedBuildError extends Error {
constructor(
public errorCode: string,
public message: string
public message: string,
public metadata?: Record<string, unknown>
) {
super(message);
}
@@ -154,7 +155,9 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
createError: (match: RegExpMatchArray) =>
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
packageName: match[1],
}),
})),
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
@@ -80,27 +80,17 @@ export async function resolveBuildPhaseErrorAsync(
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const isUnknownUserError =
!userFacingError ||
(
[
errors.ErrorCode.UNKNOWN_ERROR,
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
] as string[]
).includes(userFacingError.errorCode);
const message =
(isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message;
const errorCode =
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
userFacingError.errorCode;
const trackingCode =
buildError && buildError.errorCode !== userFacingError.errorCode
? buildError.errorCode
: undefined;
return new errors.BuildError(message, {
errorCode,
userFacingErrorCode: userFacingError.errorCode,
userFacingMessage: userFacingError.message,
return new errors.BuildError(userFacingError.message, {
errorCode: userFacingError.errorCode,
trackingCode,
docsUrl: userFacingError.docsUrl,
innerError: error,
cause: error,
buildPhase: phase,
metadata: buildError?.metadata,
});
}
@@ -24,7 +24,8 @@ export async function installDependenciesAsync({
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
break;
}
case PackageManager.PNPM: {
@@ -44,7 +45,12 @@ export async function installDependenciesAsync({
];
}
} else {
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
}
break;
}
+1 -1
View File
@@ -299,7 +299,7 @@ export class BuildContext<TJob extends Job = Job> {
// leaving message empty, website will display err.stack which already includes err.message
this.logger.error({ err }, '');
} else {
this.logger.error(`Error: ${buildError.userFacingMessage}`);
this.logger.error(`Error: ${buildError.message}`);
}
return buildError;
}
@@ -1,11 +1,14 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
@@ -13,11 +16,22 @@ jest.mock('@expo/steps', () => ({
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
@@ -28,8 +42,21 @@ describe(createInternalEasMaestroTestFunction, () => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
@@ -39,6 +66,18 @@ describe(createInternalEasMaestroTestFunction, () => {
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
@@ -95,4 +134,77 @@ describe(createInternalEasMaestroTestFunction, () => {
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -0,0 +1,177 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
getAvailableDevicesAsync: jest.fn(),
createAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
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' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
}
function createStartResult(serialId: string) {
return {
serialId: serialId as any,
emulatorPromise: Promise.resolve({}) as any,
} as any;
}
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await createStep().executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-1111',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-2222',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
})
);
});
it('retries clone startup independently and cleans up failed clone attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-base'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
mockedAndroidUtils.waitForReadyAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('clone network unavailable'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await createStep({ count: 2 }).executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
})
);
});
it('fails after exhausting all startup attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
});
@@ -1,9 +1,19 @@
import { isClosedVersionTrainError } from '../uploadToAsc';
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '90062' }])
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
@@ -17,3 +27,71 @@ describe(isClosedVersionTrainError, () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -24,6 +24,7 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
@@ -413,6 +414,9 @@ const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
@@ -434,7 +438,7 @@ async function withCleanDeviceAsync<TResult>({
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
@@ -458,28 +462,81 @@ async function withCleanDeviceAsync<TResult>({
break;
}
case 'android': {
logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(`Starting Android Emulator ${localDeviceName}...`);
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
localDeviceIdentifier = serialId;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
@@ -510,14 +567,14 @@ async function withCleanDeviceAsync<TResult>({
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
@@ -58,11 +58,6 @@ export function createRestoreCacheFunction(): BuildFunction {
const { logger } = stepsCtx;
try {
if (stepsCtx.global.staticContext.job.platform) {
logger.error('Caches are not supported in build jobs yet.');
return;
}
const paths = z
.array(z.string())
.parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/))
@@ -9,6 +9,9 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -72,25 +75,78 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}
);
logger.info('Creating emulator device');
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
logger.info('Starting emulator device');
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId,
});
logger.info(`${deviceName} is ready.`);
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -105,27 +161,70 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
for (let i = 0; i < count; i++) {
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let cloneSerialId = null;
try {
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info('Starting emulator device');
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
cloneSerialId = startResult.serialId;
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: cloneSerialId,
env,
timeoutMs,
logger,
});
logger.info(`${cloneIdentifier} is ready.`);
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
logger.warn(
{ err },
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (cloneSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: cloneSerialId,
deviceName: cloneIdentifier,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: cloneIdentifier,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
}
}
},
@@ -1,4 +1,5 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildStepInput,
@@ -14,6 +15,7 @@ import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -103,10 +105,13 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const client = new AscApiClient({ token, logger: stepsCtx.logger });
stepsCtx.logger.info('Reading App information...');
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
);
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
);
stepsCtx.logger.info('Creating Build Upload...');
@@ -261,11 +266,48 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
throw new UserFacingError(
'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` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`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).'
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} version train is closed. ` +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version and submit again.'
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
@@ -284,10 +326,34 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90062', '90186'].includes(message.code))
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -47,8 +47,8 @@ export namespace AscApiUtils {
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
: ''),
{
cause: error,
docsUrl: 'https://expo.fyi/asc-app-id',
cause: error,
}
);
}
@@ -100,8 +100,8 @@ export namespace AscApiUtils {
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.',
{
cause: error,
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: error,
}
);
}
@@ -1,4 +1,5 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
@@ -17,6 +18,8 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
export namespace AndroidEmulatorUtils {
const RETRY_INTERVAL_MS = 1_000;
export const defaultSystemImagePackage = `system-images;android-30;default;${
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
}`;
@@ -352,10 +355,15 @@ export namespace AndroidEmulatorUtils {
export async function waitForReadyAsync({
serialId,
env,
timeoutMs = 3 * 60 * 1_000,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
logger?: bunyan;
}): Promise<void> {
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
await retryAsync(
async () => {
const { stdout } = await spawn(
@@ -367,17 +375,48 @@ export namespace AndroidEmulatorUtils {
if (!stdout.startsWith('1')) {
throw new Error(`Emulator (${serialId}) boot has not completed.`);
}
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
if (!hasNetworkConnection) {
throw new Error(`Emulator (${serialId}) network is not ready.`);
}
},
{
// Retry every second for 3 minutes.
retryOptions: {
retries: 3 * 60,
retryIntervalMs: 1_000,
retries,
retryIntervalMs: RETRY_INTERVAL_MS,
},
logger,
}
);
}
async function hasNetworkConnectionAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
if (networkReadyCheckCommand) {
const customNetworkCheckResult = await asyncResult(
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
);
return customNetworkCheckResult.ok;
}
const netcatResult = await asyncResult(
spawn(
'adb',
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
)
);
return netcatResult.ok;
}
export async function collectLogsAsync({
serialId,
env,
@@ -419,18 +458,13 @@ export namespace AndroidEmulatorUtils {
return { outputPath };
}
export async function deleteAsync({
export async function stopAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
await retryAsync(
@@ -443,12 +477,40 @@ export namespace AndroidEmulatorUtils {
{
retryOptions: {
retries: 3 * 60,
retryIntervalMs: 1_000,
retryIntervalMs: RETRY_INTERVAL_MS,
},
}
);
}
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
export async function deleteAsync({
serialId,
deviceName,
env,
}: {
serialId?: AndroidDeviceSerialId;
deviceName?: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
let resolvedDeviceName = deviceName;
if (!resolvedDeviceName && serialId) {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
| AndroidVirtualDeviceName
| undefined;
}
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
const serialIdToStop =
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
if (serialIdToStop) {
await stopAsync({ serialId: serialIdToStop, env });
}
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
}
export async function startScreenRecordingAsync({
@@ -9,6 +9,7 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -104,7 +105,10 @@ describe('AndroidEmulatorUtils', () => {
});
expect(stdout).toContain('data');
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
await AndroidEmulatorUtils.stopAsync({
serialId,
env: process.env,
});
await asyncResult(emulatorPromise);
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
@@ -131,12 +135,133 @@ describe('AndroidEmulatorUtils', () => {
expect(stdoutClone).toContain('data');
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await asyncResult(emulatorPromiseClone);
}, 60_000);
it('should delete a running emulator by device name', async () => {
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: process.env,
});
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
await asyncResult(emulatorPromise);
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
expect(devices.map(device => device.serialId)).not.toContain(serialId);
await expect(fs.promises.access(avdPath)).rejects.toThrow();
}, 60_000);
it('should retry when first startup forces network check command failure', async () => {
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
let attemptCounter = 0;
let sawNetworkNotReadyError = false;
await retryAsync(
async attemptCount => {
attemptCounter += 1;
const shouldFailNetworkCheck = attemptCount === 0;
const envForAttempt: NodeJS.ProcessEnv = {
...process.env,
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
? 'echo "network not ready" && exit 1'
: 'echo "network ready"',
};
let serialId: AndroidDeviceSerialId | null = null;
let emulatorPromise: Promise<unknown> | null = null;
try {
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: envForAttempt,
timeoutMs: 30_000,
logger: createMockLogger({ logToConsole: true }),
});
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await emulatorPromise;
} catch (err: any) {
if (err?.message?.includes('network is not ready')) {
sawNetworkNotReadyError = true;
}
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
}
} catch (cleanupError) {
console.warn('Cleanup failed during retry test', cleanupError);
}
if (emulatorPromise) {
await emulatorPromise;
}
throw err;
}
},
{
logger: createMockLogger({ logToConsole: true }),
retryOptions: {
retries: 1,
retryIntervalMs: 1_000,
},
}
);
expect(sawNetworkNotReadyError).toBe(true);
expect(attemptCounter).toBe(2);
}, 360_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -0,0 +1,161 @@
import spawn from '@expo/turtle-spawn';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
timeoutMs: 60_000,
});
expect(mockedRetryAsync).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
retryOptions: expect.objectContaining({
retries: 59,
retryIntervalMs: 1_000,
}),
})
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
});
it('throws when network is unavailable despite completed boot', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc') {
throw new Error('network unreachable');
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await expect(
AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
})
).rejects.toThrow('network is not ready');
});
it('uses overridden network readiness command when provided', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'exit 0') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: {
...process.env,
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
},
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'exit 0'],
expect.objectContaining({
env: expect.objectContaining({
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
}),
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
expect.anything()
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[2] === 'emu' && args[3] === 'kill') {
return { stdout: '', stderr: '' } as any;
}
if (args[0] === 'devices') {
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.stopAsync({
serialId: 'emulator-5554' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
env: process.env,
});
});
});
describe(AndroidEmulatorUtils.deleteAsync, () => {
it('stops by serial id and then deletes the AVD', async () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await AndroidEmulatorUtils.deleteAsync({
serialId: 'emulator-5554' as any,
deviceName: 'eas-simulator-1' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
['delete', 'avd', '-n', 'eas-simulator-1'],
{
env: process.env,
}
);
});
});
});
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.0.3",
"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.0.2"
"@expo/steps": "18.2.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.0.2",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -1,4 +1,60 @@
import { UserFacingError } from '../errors';
import { BuildError, ExpoError, UserFacingError } from '../errors';
import { BuildPhase } from '../logs';
class TestExpoError extends ExpoError {
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
super(message, details);
}
}
describe(ExpoError, () => {
it('stores shared metadata and formats external payload', () => {
const cause = new Error('root cause');
const error = new TestExpoError('canonical message', {
errorCode: 'ERR_CODE',
trackingCode: 'TRACKING_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@typescript-eslint/typescript-estree' },
cause,
});
expect(error.errorCode).toBe('ERR_CODE');
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({
packageName: '@typescript-eslint/typescript-estree',
});
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
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',
buildPhase: BuildPhase.PREBUILD,
});
});
});
describe(UserFacingError, () => {
it('supports docsUrl in options', () => {
@@ -6,6 +62,7 @@ describe(UserFacingError, () => {
docsUrl: 'https://docs.example.dev',
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBeUndefined();
});
@@ -17,14 +74,16 @@ describe(UserFacingError, () => {
expect(error.cause).toBe(cause);
});
it('supports docsUrl and cause in options', () => {
it('supports docsUrl, metadata, and cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
metadata: { packageName: 'expo' },
cause,
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({ packageName: 'expo' });
expect(error.cause).toBe(cause);
});
});
+47 -24
View File
@@ -17,52 +17,79 @@ export interface ExternalBuildError {
buildPhase?: BuildPhase;
}
interface BuildErrorDetails {
export type ErrorMetadata = Record<string, unknown>;
interface ExpoErrorDetails<TMetadata extends ErrorMetadata = ErrorMetadata> {
errorCode: string;
userFacingMessage: string;
userFacingErrorCode: string;
trackingCode?: string;
docsUrl?: string;
innerError?: Error;
buildPhase?: BuildPhase;
/**
* Metadata object for the error. Used internally for Sentry/logging/debugging.
* It is not included in the external build error payload.
*/
metadata?: TMetadata;
/**
* Underlying error that caused this error to be created. Used internally to
* propagate blame stack traces to the response.
*/
cause?: unknown;
}
export class BuildError extends Error {
export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata> extends Error {
public errorCode: string;
public userFacingMessage: string;
public userFacingErrorCode: string;
// Internal-only classification used for Sentry, analytics, and worker internalErrorCode.
// The public error saved on builds and job runs is always `errorCode`.
public trackingCode?: string;
public docsUrl?: string;
public innerError?: Error;
public readonly metadata?: TMetadata;
public buildPhase?: BuildPhase;
constructor(message: string, details: BuildErrorDetails) {
super(message);
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, { cause: details.cause });
this.errorCode = details.errorCode;
this.userFacingErrorCode = details.userFacingErrorCode;
this.userFacingMessage = details.userFacingMessage;
this.trackingCode = details.trackingCode;
this.docsUrl = details.docsUrl;
this.innerError = details.innerError;
this.metadata = details.metadata;
this.buildPhase = details.buildPhase;
}
public format(): ExternalBuildError {
return {
errorCode: this.userFacingErrorCode,
message: this.userFacingMessage,
errorCode: this.errorCode,
message: this.message,
docsUrl: this.docsUrl,
buildPhase: this.buildPhase,
};
}
}
export class UserFacingError extends Error {
public docsUrl?: string;
export class BuildError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, details);
}
}
export class UserFacingError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
public errorCode: string,
public message: string,
options?: { docsUrl?: string; cause?: unknown }
options?: {
docsUrl?: string;
metadata?: TMetadata;
cause?: unknown;
}
) {
super(message, { cause: options?.cause });
this.docsUrl = options?.docsUrl;
super(message, {
errorCode,
docsUrl: options?.docsUrl,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
@@ -83,8 +110,6 @@ export class UnknownBuildError extends BuildError {
const message = 'Unknown error. See logs for more information.';
super(message, {
errorCode,
userFacingMessage: message,
userFacingErrorCode: errorCode,
});
}
}
@@ -95,8 +120,6 @@ export class UnknownCustomBuildError extends BuildError {
const message = 'Unknown custom build error. See logs for more information.';
super(message, {
errorCode,
userFacingMessage: message,
userFacingErrorCode: errorCode,
});
}
}
+3
View File
@@ -16,6 +16,7 @@ export enum BuildPhase {
EAS_BUILD_INTERNAL = 'EAS_BUILD_INTERNAL',
PREBUILD = 'PREBUILD',
PREPARE_CREDENTIALS = 'PREPARE_CREDENTIALS',
CONFIGURE_ANDROID_VERSION = 'CONFIGURE_ANDROID_VERSION',
CALCULATE_EXPO_UPDATES_RUNTIME_VERSION = 'CALCULATE_EXPO_UPDATES_RUNTIME_VERSION',
CONFIGURE_EXPO_UPDATES = 'CONFIGURE_EXPO_UPDATES',
EAGER_BUNDLE = 'EAGER_BUNDLE',
@@ -80,6 +81,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.EAS_BUILD_INTERNAL]: 'Resolve build configuration',
[BuildPhase.PREBUILD]: 'Prebuild',
[BuildPhase.PREPARE_CREDENTIALS]: 'Prepare credentials',
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'Configure Android version',
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'Calculate expo-updates runtime version',
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'Configure expo-updates',
[BuildPhase.EAGER_BUNDLE]: 'Bundle JavaScript',
@@ -143,6 +145,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.EAS_BUILD_INTERNAL]: 'resolve-build-configuration',
[BuildPhase.PREBUILD]: 'prebuild',
[BuildPhase.PREPARE_CREDENTIALS]: 'prepare-credentials',
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'configure-android-version',
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'calculate-expo-updates-runtime-version',
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'configure-expo-updates',
[BuildPhase.EAGER_BUNDLE]: 'eager-bundle',
+511 -496
View File
File diff suppressed because it is too large. Load diff
+6 -13
View File
@@ -1,17 +1,10 @@
#!/usr/bin/env node
const oclif = require('@oclif/core');
const path = require('path');
(async () => {
const oclif = require('@oclif/core');
const project = path.join(__dirname, '..', 'tsconfig.json');
// Keep dev entrypoint failures verbose for local debugging.
oclif.settings.debug = true;
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
await oclif.execute({ development: true, dir: __dirname });
})();
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
require('@oclif/core')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.1.0",
"version": "18.2.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.0.2",
"@expo/eas-json": "18.0.2",
"@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,10 +63,10 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.0.2",
"@expo/steps": "18.2.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
@@ -99,11 +99,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -126,11 +122,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=ios'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=ios'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -150,11 +142,7 @@ describe(BuildVersionGetView, () => {
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -175,11 +163,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
});
@@ -218,11 +202,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError(
`This project is not configured for using remote version source. Add ${chalk.bold(
'{"cli": { "appVersionSource": "remote" }}'
@@ -133,11 +133,23 @@ export function mockTestCommand<T extends Command>(
argv: string[],
ctx: any
): T {
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
const config = getMockOclifConfig();
const cmd = new commandConstructor(argv, config);
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
export function getMockOclifConfig(
options: Partial<ConstructorParameters<typeof Config>[0]> = {}
): Config {
const config = new Config({ root: __dirname, ...options });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
export class NoErrorThrownError extends Error {}
export const getErrorAsync = async <TError = any>(
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { ExitError } from '@oclif/core/lib/errors';
import { Errors } from '@oclif/core';
import path from 'path';
import { instance, mock, when } from 'ts-mockito';
@@ -8,6 +8,8 @@ import { validatePNGsForManagedProjectAsync } from '../validate';
const fixturesPath = path.join(__dirname, 'fixtures');
const { ExitError } = Errors;
describe(validatePNGsForManagedProjectAsync, () => {
it('does not validate PNGs for generic projects', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
@@ -42,9 +42,9 @@ beforeEach(() => {
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
});
const createTestEasCommand = (): typeof EasCommand => {
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
@@ -109,7 +109,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
runAsyncMock.mockImplementation(() => {
throw error;
@@ -126,7 +126,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const graphQLErrors = ['Unexpected GraphQL error message'];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
@@ -144,7 +144,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
@@ -176,7 +176,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
@@ -212,7 +212,7 @@ describe(EasCommand.name, () => {
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
runAsyncMock.mockImplementation(() => {
throw new Error('Error message');
});
@@ -226,7 +226,7 @@ describe(EasCommand.name, () => {
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 as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = ['Unexpected GraphQL error message'];
throw new CombinedError({ graphQLErrors });
@@ -241,7 +241,7 @@ describe(EasCommand.name, () => {
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 as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
@@ -0,0 +1,45 @@
import { resolveNonInteractiveAndJsonFlags } from '../flags';
describe(resolveNonInteractiveAndJsonFlags, () => {
test('returns both false when no flags are set', () => {
expect(resolveNonInteractiveAndJsonFlags({})).toEqual({
json: false,
nonInteractive: false,
});
});
test('--json implies --non-interactive', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: true })).toEqual({
json: true,
nonInteractive: true,
});
});
test('--non-interactive alone does not enable json', () => {
expect(resolveNonInteractiveAndJsonFlags({ 'non-interactive': true })).toEqual({
json: false,
nonInteractive: true,
});
});
test('both flags explicitly set', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: true, 'non-interactive': true })).toEqual({
json: true,
nonInteractive: true,
});
});
test('both flags explicitly false', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: false, 'non-interactive': false })).toEqual({
json: false,
nonInteractive: false,
});
});
test('--json false with --non-interactive true', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: false, 'non-interactive': true })).toEqual({
json: false,
nonInteractive: true,
});
});
});
+20 -11
View File
@@ -2,14 +2,23 @@ import { Flags } from '@oclif/core';
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
dependsOn: ['non-interactive'],
description:
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export function resolveNonInteractiveAndJsonFlags(flags: {
json?: boolean;
'non-interactive'?: boolean;
}): { json: boolean; nonInteractive: boolean } {
const json = flags.json ?? false;
const nonInteractive = flags['non-interactive'] || json;
return { json, nonInteractive };
}
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
};
@@ -28,28 +37,28 @@ export const EASMultiEnvironmentFlag = {
};
export const EASVariableFormatFlag = {
format: Flags.enum({
format: Flags.option({
description: 'Output format',
options: ['long', 'short'],
options: ['long', 'short'] as const,
default: 'short',
}),
})(),
};
export const EASVariableVisibilityFlag = {
visibility: Flags.enum<'plaintext' | 'sensitive' | 'secret'>({
visibility: Flags.option({
description: 'Visibility of the variable',
options: ['plaintext', 'sensitive', 'secret'],
}),
options: ['plaintext', 'sensitive', 'secret'] as const,
})(),
};
export type EASEnvironmentVariableScopeFlagValue = 'project' | 'account';
export const EASEnvironmentVariableScopeFlag = {
scope: Flags.enum<EASEnvironmentVariableScopeFlagValue>({
scope: Flags.option({
description: 'Scope for the variable',
options: ['project', 'account'],
options: ['project', 'account'] as const,
default: 'project',
}),
})(),
};
export const EASNonInteractiveFlag = {
@@ -1,18 +1,18 @@
import { Flags } from '@oclif/core';
import { OptionFlag } from '@oclif/core/lib/interfaces';
import { Flags, Interfaces } from '@oclif/core';
import { EasNonInteractiveAndJsonFlags } from './flags';
import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags } from './flags';
export const getPaginatedQueryOptions = (
flags: Partial<
Record<keyof typeof EasPaginatedQueryFlags | keyof typeof EasNonInteractiveAndJsonFlags, any>
>
): PaginatedQueryOptions => {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
...('limit' in flags && { limit: flags.limit }),
offset: flags.offset ?? 0,
nonInteractive: flags['non-interactive'] ?? false,
json: flags.json ?? false,
nonInteractive,
json,
};
};
@@ -38,7 +38,7 @@ export const getLimitFlagWithCustomValues = ({
}: {
defaultTo: number;
limit: number;
}): OptionFlag<number | undefined> =>
}): Interfaces.OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -201,13 +202,12 @@ function displayUsage(data: UsageDisplayData, usageData: AccountFullUsageData):
export default class AccountUsage extends EasCommand {
static override description = 'view account usage and billing for the current cycle';
static override args = [
{
name: 'ACCOUNT_NAME',
static override args = {
ACCOUNT_NAME: Args.string({
description:
'Account name to view usage for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
},
];
}),
};
static override flags = {
...EasJsonOnlyFlag,
+6 -1
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import { getAnalyticsEnabledAsync, setAnalyticsEnabledAsync } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import Log from '../log';
@@ -5,7 +6,11 @@ import Log from '../log';
export default class AnalyticsView extends EasCommand {
static override description = 'display or change analytics settings';
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
static override args = {
STATUS: Args.string({
options: ['on', 'off'] as const,
}),
};
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
+14 -7
View File
@@ -1,9 +1,13 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
import { getDefaultBranchNameAsync } from '../../branch/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
@@ -12,13 +16,12 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BranchCreate extends EasCommand {
static override description = 'create a branch';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the branch to create',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -33,8 +36,9 @@ export default class BranchCreate extends EasCommand {
async runAsync(): Promise<void> {
let {
args: { name },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(BranchCreate);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -61,6 +65,9 @@ export default class BranchCreate extends EasCommand {
initial: (await getDefaultBranchNameAsync(vcsClient)) ?? undefined,
validate: value => (value ? true : validationMessage),
}));
if (!name) {
throw new Error(validationMessage);
}
}
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, { appId: projectId, name });
+10 -7
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
@@ -5,7 +6,10 @@ import { scheduleBranchDeletionAsync } from '../../branch/delete';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { withErrorHandlingAsync } from '../../graphql/client';
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
@@ -54,13 +58,12 @@ export default class BranchDelete extends EasCommand {
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the branch to delete',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -71,7 +74,7 @@ export default class BranchDelete extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchDelete);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (jsonFlag) {
+6 -2
View File
@@ -1,6 +1,9 @@
import { listAndRenderBranchesOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
@@ -19,11 +22,12 @@ export default class BranchList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(BranchList);
const { nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchList, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
@@ -4,7 +4,10 @@ import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
EditUpdateBranchInput,
@@ -69,8 +72,9 @@ export default class BranchRename extends EasCommand {
async runAsync(): Promise<void> {
let {
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
flags: { from: currentName, to: newName, ...rawFlags },
} = await this.parse(BranchRename);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
projectId,
loggedIn: { graphqlClient },
+10 -7
View File
@@ -1,6 +1,10 @@
import { Args } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -12,13 +16,12 @@ import { enableJsonOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
static override description = 'view a branch';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the branch to view',
},
];
}),
};
static override flags = {
...EasPaginatedQueryFlags,
@@ -36,7 +39,7 @@ export default class BranchView extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchView);
const { 'non-interactive': nonInteractive } = flags;
const { nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -93,15 +93,17 @@ export async function selectBuildToCancelAsync(
export default class BuildCancel extends EasCommand {
static override description = 'cancel a build';
static override args = [{ name: 'BUILD_ID' }];
static override args = {
BUILD_ID: Args.string({}),
};
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.enum({
platform: Flags.option({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
}),
})(),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -140,7 +142,7 @@ export default class BuildCancel extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg;
let buildId: string | null = buildIdFromArg ?? null;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
@@ -19,11 +19,11 @@ export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
description: 'Platform to configure',
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
};
static override contextDefinition = {
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -79,14 +79,16 @@ export async function selectBuildToDeleteAsync(
export default class BuildDelete extends EasCommand {
static override description = 'delete a build';
static override args = [{ name: 'BUILD_ID' }];
static override args = {
BUILD_ID: Args.string({}),
};
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.enum({
platform: Flags.option({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
}),
})(),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -121,7 +123,7 @@ export default class BuildDelete extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg;
let buildId: string | null = buildIdFromArg ?? null;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
+3 -3
View File
@@ -34,10 +34,10 @@ export default class BuildDev extends EasCommand {
'run dev client simulator/emulator build with matching fingerprint or create a new one';
static override flags = {
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
profile: Flags.string({
char: 'e',
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
@@ -5,7 +5,10 @@ import { pathExists } from 'fs-extra';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
@@ -23,10 +26,10 @@ export default class Download extends EasCommand {
description: 'Fingerprint hash of the build to download',
required: true,
}),
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
@@ -42,13 +45,13 @@ export default class Download extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: {
json: jsonFlag,
platform,
fingerprint,
'dev-client': developmentClient,
'non-interactive': nonInteractive,
...rawFlags
},
} = await this.parse(Download);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
loggedIn: { graphqlClient },
+13 -13
View File
@@ -10,7 +10,10 @@ import path from 'path';
import { LocalBuildMode } from '../../build/local';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
@@ -45,10 +48,10 @@ export default class Build extends EasCommand {
static override description = 'start a build';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
'skip-credentials-check': Flags.boolean({
default: false,
hidden: true,
@@ -96,7 +99,7 @@ export default class Build extends EasCommand {
description:
'Specify the "What to Test" information for the build in TestFlight (iOS-only). To be used with the `auto-submit` flag',
}),
'resource-class': Flags.enum({
'resource-class': Flags.option({
options: Object.values(ResourceClass),
hidden: true,
deprecated: {
@@ -105,15 +108,15 @@ export default class Build extends EasCommand {
),
},
description: 'The instance type that will be used to run this build [experimental]',
}),
})(),
message: Flags.string({
char: 'm',
description: 'A short message describing the build',
}),
'build-logger-level': Flags.enum({
'build-logger-level': Flags.option({
description: 'The level of logs to output during the build process. Defaults to "info".',
options: Object.values(LoggerLevel),
}),
})(),
'freeze-credentials': Flags.boolean({
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
@@ -184,16 +187,13 @@ export default class Build extends EasCommand {
private sanitizeFlags(
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
const nonInteractive = flags['non-interactive'];
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
if (!flags.platform && nonInteractive) {
Errors.error('--platform is required when building in non-interactive mode', { exit: 1 });
}
if (flags.json && !nonInteractive) {
Errors.error('--json is allowed only when building in non-interactive mode', { exit: 1 });
}
const autoSubmit = flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined;
if (flags['what-to-test'] && !autoSubmit) {
Errors.error(
@@ -242,7 +242,7 @@ export default class Build extends EasCommand {
: {},
wait: flags['wait'],
clearCache: flags['clear-cache'],
json: flags['json'],
json,
autoSubmit,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
resourceClass: flags['resource-class'],
@@ -28,23 +28,23 @@ export default class BuildInspect extends EasCommand {
'inspect the state of the project at specific build stages, useful for troubleshooting';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: [RequestedPlatform.Android, RequestedPlatform.Ios],
options: [RequestedPlatform.Android, RequestedPlatform.Ios] as const,
required: true,
}),
})(),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
stage: Flags.enum({
stage: Flags.option({
char: 's',
description: STAGE_DESCRIPTION,
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD],
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD] as const,
required: true,
}),
})(),
output: Flags.string({
char: 'o',
description: 'Output directory.',
@@ -18,11 +18,11 @@ export default class BuildInternal extends EasCommand {
static override hidden = true;
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
options: ['android', 'ios'] as const,
required: true,
}),
})(),
profile: Flags.string({
char: 'e',
description:
+11 -9
View File
@@ -3,7 +3,10 @@ import { Flags } from '@oclif/core';
import { BUILDS_LIMIT, listAndRenderBuildsOnAppAsync } from '../../build/queries';
import { BuildDistributionType, BuildStatus } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -20,18 +23,18 @@ export default class BuildList extends EasCommand {
static override description = 'list all builds for your project';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
options: Object.values(RequestedPlatform),
char: 'p',
}),
status: Flags.enum({
})(),
status: Flags.option({
options: Object.values(BuildStatus),
description: 'Filter only builds with the specified status',
}),
distribution: Flags.enum({
})(),
distribution: Flags.option({
options: Object.values(BuildDistributionType),
description: 'Filter only builds with the specified distribution type',
}),
})(),
channel: Flags.string(),
'app-version': Flags.string({
aliases: ['appVersion'],
@@ -83,12 +86,11 @@ export default class BuildList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
json: jsonFlag,
platform: requestedPlatform,
status: buildStatus,
distribution: buildDistribution,
'non-interactive': nonInteractive,
} = flags;
if (buildDistribution === BuildDistributionType.SIMULATOR) {
Log.warn(
@@ -13,7 +13,10 @@ import { listAndSelectBuildOnAppAsync } from '../../build/queries';
import { printBuildResults, printLogsUrls } from '../../build/utils/printBuildInfo';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasPaginatedQueryFlags } from '../../commandUtils/pagination';
import { CredentialsContext } from '../../credentials/context';
import {
@@ -66,10 +69,10 @@ export default class BuildResign extends EasCommand {
static override description = 're-sign a build archive';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
'target-profile': Flags.string({
char: 'e',
description:
@@ -242,7 +245,7 @@ export default class BuildResign extends EasCommand {
}
sanitizeFlags(flags: RawBuildResignFlags): BuildResignFlags {
const nonInteractive = flags['non-interactive'];
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (nonInteractive && !flags.id) {
throw new Error(
`${chalk.bold('--id')} is required when running with ${chalk.bold(
@@ -251,7 +254,7 @@ export default class BuildResign extends EasCommand {
);
}
return {
json: flags.json,
json,
nonInteractive,
offset: flags.offset,
limit: flags.limit,
+3 -3
View File
@@ -62,10 +62,10 @@ export default class Run extends EasCommand {
description: 'ID of the simulator/emulator build to run',
exclusive: ['latest, path, url'],
}),
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
@@ -5,7 +5,10 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../../../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
@@ -22,10 +25,10 @@ export default class BuildVersionGetView extends EasCommand {
public static override description = 'get the latest version from EAS servers';
public static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
@@ -44,7 +47,8 @@ export default class BuildVersionGetView extends EasCommand {
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionGetView);
if (flags.json) {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
@@ -57,13 +61,13 @@ export default class BuildVersionGetView extends EasCommand {
withServerSideEnvironment: null,
});
if (!flags.platform && flags['non-interactive']) {
if (!flags.platform && nonInteractive) {
throw new Error('"--platform" flag is required in non-interactive mode.');
}
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const platforms = toPlatforms(requestedPlatform);
@@ -94,7 +98,7 @@ export default class BuildVersionGetView extends EasCommand {
buildProfile: profile,
platform,
vcsClient,
nonInteractive: flags['non-interactive'],
nonInteractive,
env,
});
const remoteVersions = await AppVersionQuery.latestVersionAsync(
@@ -107,7 +111,7 @@ export default class BuildVersionGetView extends EasCommand {
results[platform] = remoteVersions?.buildVersion;
}
}
if (flags.json) {
if (json) {
const jsonResults: { versionCode?: string; buildNumber?: string } = {};
if (results.android) {
jsonResults.versionCode = results.android;
@@ -26,10 +26,10 @@ export default class BuildVersionSetView extends EasCommand {
static override description = 'update version of an app';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
@@ -45,10 +45,10 @@ export default class BuildVersionSyncView extends EasCommand {
'update a version in native code with a value stored on EAS servers';
public static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
+4 -1
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
@@ -11,7 +12,9 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildView extends EasCommand {
static override description = 'view a build for your project';
static override args = [{ name: 'BUILD_ID' }];
static override args = {
BUILD_ID: Args.string({}),
};
static override flags = {
...EasJsonOnlyFlag,
@@ -1,8 +1,12 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createAndLinkChannelAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { enableJsonOutput } from '../../utils/json';
@@ -10,13 +14,12 @@ import { enableJsonOutput } from '../../utils/json';
export default class ChannelCreate extends EasCommand {
static override description = 'create a channel';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to create',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -30,8 +33,9 @@ export default class ChannelCreate extends EasCommand {
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(ChannelCreate);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -53,6 +57,9 @@ export default class ChannelCreate extends EasCommand {
message: 'Provide a channel name:',
validate: value => (value ? true : validationMessage),
}));
if (!channelName) {
throw new Error(validationMessage);
}
}
await createAndLinkChannelAsync(graphqlClient, {
@@ -1,7 +1,11 @@
import { Args } from '@oclif/core';
import { scheduleChannelDeletionAsync } from '../../channel/delete';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
@@ -11,13 +15,12 @@ import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJ
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to delete',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -30,8 +33,9 @@ export default class ChannelDelete extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { name: nameArg },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(ChannelDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+11 -8
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -12,7 +12,10 @@ import {
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
UpdateChannelBasicInfoFragment,
@@ -58,13 +61,12 @@ export async function updateChannelBranchMappingAsync(
export default class ChannelEdit extends EasCommand {
static override description = 'point a channel at a new branch';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to edit',
},
];
}),
};
static override flags = {
branch: Flags.string({
@@ -81,8 +83,9 @@ export default class ChannelEdit extends EasCommand {
async runAsync(): Promise<void> {
const {
args,
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
flags: { branch: branchFlag, ...rawFlags },
} = await this.parse(ChannelEdit);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,6 +1,9 @@
import { CHANNELS_LIMIT, listAndRenderChannelsOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -25,7 +28,7 @@ export default class ChannelList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+11 -11
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -6,7 +6,10 @@ import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
PauseUpdateChannelMutation,
@@ -50,13 +53,12 @@ export async function pauseUpdateChannelAsync(
export default class ChannelPause extends EasCommand {
static override description = 'pause a channel to stop it from sending updates';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to edit',
},
];
}),
};
static override flags = {
branch: Flags.string({
@@ -71,10 +73,8 @@ export default class ChannelPause extends EasCommand {
};
async runAsync(): Promise<void> {
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelPause);
const { args, flags } = await this.parse(ChannelPause);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+11 -11
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -6,7 +6,10 @@ import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
ResumeUpdateChannelMutation,
@@ -50,13 +53,12 @@ export async function resumeUpdateChannelAsync(
export default class ChannelResume extends EasCommand {
static override description = 'resume a channel to start sending updates';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to edit',
},
];
}),
};
static override flags = {
branch: Flags.string({
@@ -71,10 +73,8 @@ export default class ChannelResume extends EasCommand {
};
async runAsync(): Promise<void> {
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelResume);
const { args, flags } = await this.parse(ChannelResume);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,7 +1,10 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
@@ -49,15 +52,14 @@ type ChannelRolloutArgsAndFlags = {
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = [
{
name: 'channel',
static override args = {
channel: Args.string({
description: 'channel on which the rollout should be done',
},
];
}),
};
static override flags = {
action: Flags.enum({
action: Flags.option({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
@@ -97,17 +99,17 @@ export default class ChannelRollout extends EasCommand {
],
},
],
}),
})(),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: false,
}),
outcome: Flags.enum({
outcome: Flags.option({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
}),
})(),
branch: Flags.string({
description: 'Branch to roll out. Use with --action=create',
required: false,
@@ -183,8 +185,7 @@ export default class ChannelRollout extends EasCommand {
runtimeVersion: rawFlags['runtime-version'],
privateKeyPath: rawFlags['private-key-path'] ?? null,
action: action ? this.getAction(action) : undefined,
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
...resolveNonInteractiveAndJsonFlags(rawFlags),
};
}
}
+10 -7
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import assert from 'assert';
import {
@@ -5,20 +6,22 @@ import {
selectChannelOnAppAsync,
} from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
export default class ChannelView extends EasCommand {
static override description = 'view a channel';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to view',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -36,7 +39,7 @@ export default class ChannelView extends EasCommand {
flags,
} = await this.parse(ChannelView);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+10 -10
View File
@@ -6,7 +6,10 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { appPlatformEmojis } from '../platform';
@@ -17,7 +20,7 @@ export default class Config extends EasCommand {
static override description = 'display project configuration (app.json + eas.json)';
static override flags = {
platform: Flags.enum<Platform>({ char: 'p', options: [Platform.ANDROID, Platform.IOS] }),
platform: Flags.option({ char: 'p', options: [Platform.ANDROID, Platform.IOS] as const })(),
profile: Flags.string({
char: 'e',
description:
@@ -39,14 +42,11 @@ export default class Config extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(Config);
if (flags.json) {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
platform: maybePlatform,
profile: maybeProfile,
'non-interactive': nonInteractive,
} = flags;
const { platform: maybePlatform, profile: maybeProfile } = flags;
const { getDynamicPublicProjectConfigAsync, projectDir, getDynamicLoggedInAsync } =
await this.getContextAsync(Config, {
nonInteractive,
@@ -80,7 +80,7 @@ export default class Config extends EasCommand {
const profile = await EasJsonUtils.getBuildProfileAsync(accessor, platform, profileName);
if (flags['eas-json-only']) {
if (flags.json) {
if (json) {
printJsonOnlyOutput({ buildProfile: profile });
} else {
const appPlatform = toAppPlatform(platform);
@@ -99,7 +99,7 @@ export default class Config extends EasCommand {
opts: { env: profile.env },
});
if (flags.json) {
if (json) {
printJsonOnlyOutput({ buildProfile: profile, appConfig });
} else {
Log.addNewLineIfNone();
@@ -10,10 +10,10 @@ export default class InitializeBuildCredentials extends EasCommand {
static override description = 'Set up credentials for building your project.';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: [Platform.ANDROID, Platform.IOS],
}),
options: [Platform.ANDROID, Platform.IOS] as const,
})(),
profile: Flags.string({
char: 'e',
description: 'The name of the build profile in eas.json.',
@@ -7,7 +7,7 @@ export default class Credentials extends EasCommand {
static override description = 'manage credentials';
static override flags = {
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
platform: Flags.option({ char: 'p', options: ['android', 'ios'] as const })(),
};
static override contextDefinition = {
@@ -1,8 +1,12 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
@@ -22,7 +26,9 @@ export default class WorkerAliasDelete extends EasCommand {
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = [{ name: 'ALIAS_NAME' }];
static override args = {
ALIAS_NAME: Args.string({}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -105,9 +111,10 @@ export default class WorkerAliasDelete extends EasCommand {
}
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
nonInteractive,
json,
};
}
}
@@ -3,7 +3,10 @@ import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
@@ -163,9 +166,10 @@ export default class WorkerAlias extends EasCommand {
}
private sanitizeFlags(flags: RawDeployAliasFlags): DeployAliasFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
nonInteractive,
json,
aliasName: flags.alias?.trim().toLowerCase(),
deploymentIdentifier: flags.id?.trim().toLowerCase(),
isProduction: flags.prod,
+11 -4
View File
@@ -1,7 +1,11 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { toggleConfirmAsync } from '../../prompts';
@@ -23,7 +27,9 @@ export default class WorkerDelete extends EasCommand {
static override aliases = ['worker:delete'];
static override state = 'preview';
static override args = [{ name: 'DEPLOYMENT_ID' }];
static override args = {
DEPLOYMENT_ID: Args.string({}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -104,9 +110,10 @@ export default class WorkerDelete extends EasCommand {
}
private sanitizeFlags(flags: RawDeployDeleteFlags): DeployDeleteFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
nonInteractive,
json,
};
}
}
+19 -4
View File
@@ -7,7 +7,11 @@ import * as path from 'node:path';
import { getHostingDeploymentsUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EASEnvironmentFlag,
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
@@ -48,6 +52,7 @@ interface DeployFlags {
deploymentIdentifier?: string;
exportDir: string;
dryRun: boolean;
sourceMaps: boolean;
}
interface RawDeployFlags {
@@ -59,6 +64,7 @@ interface RawDeployFlags {
id?: string;
'export-dir': string;
'dry-run': boolean;
'source-maps': boolean;
}
interface UploadAssetBatchInstruction {
@@ -102,6 +108,11 @@ export default class WorkerDeploy extends EasCommand {
description: 'Outputs a tarball of the new deployment instead of uploading it.',
default: false,
}),
'source-maps': Flags.boolean({
description: 'Include source maps in the deployment.',
default: true,
allowNo: true,
}),
...EASEnvironmentFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -142,7 +153,9 @@ export default class WorkerDeploy extends EasCommand {
yield ['assets.json', JSON.stringify(params.assetMap)];
yield ['manifest.json', JSON.stringify(params.manifest)];
if (projectDist.type === 'server' && projectDist.serverPath) {
const workerFiles = WorkerAssets.listWorkerFilesAsync(projectDist.serverPath);
const workerFiles = WorkerAssets.listWorkerFilesAsync(projectDist.serverPath, {
skipSourceMaps: !flags.sourceMaps,
});
for await (const workerFile of workerFiles) {
yield [`server/${workerFile.normalizedPath}`, workerFile.data];
}
@@ -439,15 +452,17 @@ export default class WorkerDeploy extends EasCommand {
}
private sanitizeFlags(flags: RawDeployFlags): DeployFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
nonInteractive,
json,
isProduction: !!flags.prod,
aliasName: flags.alias?.trim().toLowerCase(),
deploymentIdentifier: flags.id?.trim(),
exportDir: flags['export-dir'],
environment: flags['environment'],
dryRun: flags['dry-run'],
sourceMaps: flags['source-maps'],
};
}
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import formatDevice from '../../devices/utils/formatDevice';
@@ -8,7 +9,9 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
export default class DeviceView extends EasCommand {
static override description = 'view a device for your project';
static override args = [{ name: 'UDID' }];
static override args = {
UDID: Args.string({}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
@@ -1,6 +1,4 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -28,7 +26,7 @@ jest.mock('../../../utils/prompts');
describe(EnvCreate, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const variableId = 'testId';
const testAccountId = 'test-account-id';
@@ -1,5 +1,4 @@
import { Config } from '@oclif/core';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
@@ -21,7 +20,7 @@ describe(EnvDelete, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
@@ -1,5 +1,4 @@
import { Config } from '@oclif/core';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -17,7 +16,7 @@ jest.mock('../../../log');
describe(EnvExec, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const mockEnvironmentVariables = [
{
+2 -3
View File
@@ -1,5 +1,4 @@
import { Config } from '@oclif/core';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -21,7 +20,7 @@ jest.mock('../../../utils/prompts');
describe(EnvGet, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const mockVariables: EnvironmentVariableFragment[] = [
{
id: 'var1',
@@ -1,6 +1,4 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -48,7 +46,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
describe(EnvList, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
@@ -1,7 +1,7 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import path from 'path';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -25,7 +25,7 @@ jest.mock('../../../log');
describe(EnvPull, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const testProjectDir = '/test/project';
const testTargetPath = '.env.local';
@@ -1,6 +1,6 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -23,7 +23,7 @@ jest.mock('../../../prompts');
describe(EnvPush, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const testProjectDir = '/test/project';
const testEnvPath = '.env.test';
@@ -1,8 +1,7 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
@@ -33,7 +32,7 @@ describe(EnvUpdate, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockConfig = getMockOclifConfig();
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
+8 -9
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
@@ -59,14 +59,13 @@ interface CreateFlags {
export default class EnvCreate extends EasCommand {
static override description = 'create an environment variable for the current project or account';
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Environment to create the variable in. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
static override flags = {
name: Flags.string({
@@ -79,10 +78,10 @@ export default class EnvCreate extends EasCommand {
description: 'Overwrite existing variable',
default: false,
}),
type: Flags.enum<'string' | 'file'>({
type: Flags.option({
description: 'The type of variable',
options: ['string', 'file'],
}),
options: ['string', 'file'] as const,
})(),
...EASVariableVisibilityFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
+5 -6
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
@@ -45,14 +45,13 @@ export default class EnvDelete extends EasCommand {
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Current environment of the variable to delete. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
+7 -8
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
@@ -37,19 +38,17 @@ export default class EnvExec extends EasCommand {
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
required: true,
description:
"Environment to execute the command in. Default environments are 'production', 'preview', and 'development'.",
},
{
name: 'bash_command',
}),
bash_command: Args.string({
required: true,
description: 'bash command to execute with the environment variables from the environment',
},
];
}),
};
private isNonInteractive: boolean = false;
+5 -6
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -44,14 +44,13 @@ export default class EnvGet extends EasCommand {
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Current environment of the variable. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
static override flags = {
'variable-name': Flags.string({
+5 -6
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -106,14 +106,13 @@ export default class EnvList extends EasCommand {
...EASEnvironmentVariableScopeFlag,
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Environment to list the variables from. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvList);
+5 -6
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
@@ -24,14 +24,13 @@ export default class EnvPull extends EasCommand {
...this.ContextOptions.ProjectDir,
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Environment to pull variables from. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
static override flags = {
...EASNonInteractiveFlag,
+6 -7
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import dotenv from 'dotenv';
import fs from 'fs-extra';
import path from 'path';
@@ -38,14 +38,13 @@ export default class EnvPush extends EasCommand {
}),
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Environment to push variables to. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvPush);
@@ -214,7 +213,7 @@ export default class EnvPush extends EasCommand {
environment: string[] | undefined;
force: boolean;
},
{ environment }: Record<string, string>
{ environment }: { environment?: string }
): { environment?: string[]; path: string; force: boolean } {
const environments = flags.environment ?? (environment ? [environment] : undefined);
+8 -9
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -79,24 +79,23 @@ export default class EnvUpdate extends EasCommand {
value: Flags.string({
description: 'New value or the variable',
}),
type: Flags.enum<'string' | 'file'>({
type: Flags.option({
description: 'The type of variable',
options: ['string', 'file'],
}),
options: ['string', 'file'] as const,
})(),
...EASVariableVisibilityFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
static override args = {
environment: Args.string({
description:
"Current environment of the variable to update. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import chalk from 'chalk';
@@ -11,6 +11,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import {
EasEnvironmentFlagParameters,
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { diffFingerprint } from '../../fingerprint/cli';
import { abridgedDiff } from '../../fingerprint/diff';
@@ -68,19 +69,17 @@ export default class FingerprintCompare extends EasCommand {
'$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> \t # Compare fingerprint from update against provided fingerprint',
];
static override args = [
{
name: 'hash1',
static override args = {
hash1: Args.string({
description:
"If provided alone, HASH1 is compared against the current project's fingerprint.",
required: false,
},
{
name: 'hash2',
}),
hash2: Args.string({
description: 'If two hashes are provided, HASH1 is compared against HASH2.',
required: false,
},
];
}),
};
static override flags = {
'build-id': Flags.string({
@@ -115,14 +114,8 @@ export default class FingerprintCompare extends EasCommand {
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(FingerprintCompare);
const { hash1, hash2 } = args;
const {
json,
'non-interactive': nonInteractive,
'build-id': buildIds,
'update-id': updateIds,
open,
environment,
} = flags;
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { 'build-id': buildIds, 'update-id': updateIds, open, environment } = flags;
const [buildId1, buildId2] = buildIds ?? [];
const [updateId1, updateId2] = updateIds ?? [];
@@ -155,8 +148,8 @@ export default class FingerprintCompare extends EasCommand {
);
const { fingerprint: firstFingerprint, origin: firstFingerprintOrigin } = firstFingerprintInfo;
const isFirstFingerprintSpecifiedByFlagOrArg = hash1 || buildId1 || updateId1;
const isSecondFingerprintSpecifiedByFlagOrArg = hash2 || buildId2 || updateId2;
const isFirstFingerprintSpecifiedByFlagOrArg = Boolean(hash1 || buildId1 || updateId1);
const isSecondFingerprintSpecifiedByFlagOrArg = Boolean(hash2 || buildId2 || updateId2);
const secondFingerprintInfo = await getFingerprintInfoAsync(
graphqlClient,
projectDir,
@@ -287,7 +280,7 @@ async function getFingerprintInfoAsync(
}: {
buildId?: string;
updateId?: string;
hash: string;
hash?: string;
useProjectFingerprint?: boolean;
environmentForProjectFingerprint?: string;
nonInteractive: boolean;
@@ -9,6 +9,7 @@ import {
EASEnvironmentFlag,
EasEnvironmentFlagParameters,
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
appPlatformToPlatform,
@@ -34,10 +35,10 @@ export default class FingerprintGenerate extends EasCommand {
];
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
...EASEnvironmentFlag,
environment: Flags.string({
...EasEnvironmentFlagParameters,
@@ -62,13 +63,8 @@ export default class FingerprintGenerate extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(FingerprintGenerate);
const {
json,
'non-interactive': nonInteractive,
platform: platformStringFlag,
environment,
'build-profile': buildProfileName,
} = flags;
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { platform: platformStringFlag, environment, 'build-profile': buildProfileName } = flags;
const {
projectId,
@@ -3,6 +3,7 @@ import chalk from 'chalk';
import { vol } from 'memfs';
import { instance, mock } from 'ts-mockito';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
import ProjectDirContextField from '../../../commandUtils/context/ProjectDirContextField';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -97,7 +98,7 @@ function mockTestProject(options: {
jest.mocked(isExpoInstalled).mockReturnValue(true);
}
const commandOptions = { root: '/test-project' } as any;
const commandOptions = getMockOclifConfig({ root: '/test-project' });
beforeEach(() => {
jest.resetAllMocks();
@@ -166,9 +167,10 @@ describe(ProjectInit.name, () => {
});
it('does not prompt to overwrite when different', async () => {
await new ProjectInit(['--id', '12345', '--force'], {
root: '/test-project',
} as any).run();
await new ProjectInit(
['--id', '12345', '--force'],
getMockOclifConfig({ root: '/test-project' })
).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
expect(confirmAsync).not.toHaveBeenCalled();
});
@@ -177,9 +179,10 @@ describe(ProjectInit.name, () => {
describe('non-interactive', () => {
it('aborts when different', async () => {
await expect(
new ProjectInit(['--id', '12345', '--non-interactive'], {
root: '/test-project',
} as any).run()
new ProjectInit(
['--id', '12345', '--non-interactive'],
getMockOclifConfig({ root: '/test-project' })
).run()
).rejects.toThrowError(
`Project is already linked to a different ID: ${chalk.bold(
'1234'
+8 -9
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
@@ -132,21 +132,20 @@ export default class New extends EasCommand {
static override description =
'Create a new project configured with Expo Application Services (EAS)';
static override args = [
{
name: 'path',
static override args = {
path: Args.string({
description: 'Path to create the project (defaults to current directory)',
required: false,
},
];
}),
};
static override flags = {
'package-manager': Flags.enum<PackageManager>({
'package-manager': Flags.option({
char: 'p',
description: 'Package manager to use for installing dependencies',
options: [...PACKAGE_MANAGERS],
options: [...PACKAGE_MANAGERS] as const,
default: 'npm',
}),
})(),
};
static override contextDefinition = {
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { AppVersionSource, EasJson } from '@expo/eas-json';
@@ -45,7 +46,9 @@ export default class Onboarding extends EasCommand {
static override flags = {};
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
static override args = {
TARGET_PROJECT_DIRECTORY: Args.string({}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
@@ -28,21 +28,21 @@ export default class EnvironmentSecretCreate extends EasCommand {
static override hidden = true;
static override flags = {
scope: Flags.enum({
scope: Flags.option({
description: 'Scope for the secret',
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT],
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT] as const,
default: EnvironmentSecretScope.PROJECT,
}),
})(),
name: Flags.string({
description: 'Name of the secret',
}),
value: Flags.string({
description: 'Text value or path to a file to store in the secret',
}),
type: Flags.enum({
type: Flags.option({
description: 'The type of secret',
options: [SecretType.STRING, SecretType.FILE],
}),
options: [SecretType.STRING, SecretType.FILE] as const,
})(),
force: Flags.boolean({
description: 'Delete and recreate existing secrets',
default: false,
@@ -171,6 +171,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
}
assert(secretValue);
const environmentSecretType = SecretTypeToEnvironmentSecretType[secretType as SecretType];
let secretFilePath: string | undefined;
if (secretType === SecretType.FILE) {
@@ -201,7 +202,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
const secret = await EnvironmentSecretMutation.createForAppAsync(
graphqlClient,
{ name, value: secretValue, type: SecretTypeToEnvironmentSecretType[secretType] },
{ name, value: secretValue, type: environmentSecretType },
projectId
);
if (!secret) {
@@ -244,7 +245,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
const secret = await EnvironmentSecretMutation.createForAccountAsync(
graphqlClient,
{ name, value: secretValue, type: SecretTypeToEnvironmentSecretType[secretType] },
{ name, value: secretValue, type: environmentSecretType },
ownerAccount.id
);
+3 -3
View File
@@ -27,11 +27,11 @@ export default class EnvironmentSecretPush extends EasCommand {
static override hidden = true;
static override flags = {
scope: Flags.enum({
scope: Flags.option({
description: 'Scope for the secrets',
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT],
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT] as const,
default: EnvironmentSecretScope.PROJECT,
}),
})(),
'env-file': Flags.string({
description: 'Env file with secrets',
}),
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more