Compare commits

..
1 Commits
Author SHA1 Message Date
Quinlan Jung d8d7ac68da [eas-cli] amend fingerprint compare url 2025-02-26 20:24:55 -08:00
220 changed files with 5372 additions and 19177 deletions

No files matched your search

+1 -11
View File
@@ -3,7 +3,7 @@ commentConfiguration:
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
rules:
- patterns: ['**/*']
mentions: ['douglowder']
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela', 'radoslawkrzemien']
- patterns: ['packages/eas-cli/src/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/update/**']
@@ -18,13 +18,3 @@ rules:
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/commands/metadata/**']
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/worker/**']
mentions: ['kitten', 'kadikraman']
- patterns: ['packages/eas-cli/src/commands/deploy/**']
mentions: ['kitten', 'kadikraman']
- patterns: ['packages/eas-cli/src/commands/build/**']
mentions: ['sjchmiela']
- patterns: ['packages/eas-cli/src/build/**']
mentions: ['sjchmiela']
- patterns: ['packages/eas-cli/src/commands/workflow/**']
mentions: ['sjchmiela']
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
uses: tj-actions/changed-files@v41
with:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
+6 -11
View File
@@ -18,7 +18,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
@@ -28,13 +27,9 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
node-version: 20
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn verify-graphql-code
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
@@ -73,7 +68,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
node-version: 20
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
@@ -116,7 +111,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
node-version: 20
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
@@ -158,7 +153,7 @@ jobs:
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: 22
node-version: 20
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
@@ -191,7 +186,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 22
node-version: 20
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
@@ -211,7 +206,7 @@ jobs:
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md"
git commit -m "update CHANGELOG.md"
git push
- name: Prepare changelog for Slack
run: |
-27
View File
@@ -1,27 +0,0 @@
name: Ensure GraphQL schema and generated code is up-to-date
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
concurrency:
group: test-graphql-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
-48
View File
@@ -1,48 +0,0 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
default: true
description: 'Run the release in dry run mode, without pushing any changes'
name: Manually trigger a release
jobs:
release:
name: Trigger a release
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
- run: |
# Lerna will not detect changes correctly without having all tags
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --frozen-lockfile --check-files
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release $INPUT_VERSION
fi
@@ -66,7 +66,7 @@ jobs:
destination_branch: 'main'
pr_title: 'Upgrade eas-build dependencies'
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
pr_reviewer: 'sjchmiela'
pr_reviewer: 'szdziedzic,radoslawkrzemien,sjchmiela,khamilowicz'
github_token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/github-script@v3
-372
View File
@@ -8,385 +8,13 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Improvements to workflow:validate. ([#3165](https://github.com/expo/eas-cli/pull/3165) by [@douglowder](https://github.com/douglowder))
- Add `eas:new` command behind `hidden` flag. ([#3196](https://github.com/expo/eas-cli/pull/3196) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
### 🧹 Chores
## [16.19.3](https://github.com/expo/eas-cli/releases/tag/v16.19.3) - 2025-09-17
### 🧹 Chores
- Temporarily remove warnings when no compatible build found, need to refine the message. ([#3182](https://github.com/expo/eas-cli/pull/3182) by [@brentvatne](https://github.com/brentvatne))
## [16.19.2](https://github.com/expo/eas-cli/releases/tag/v16.19.2) - 2025-09-15
### 🎉 New features
- Add artifact URLs to workflow:view. ([#3175](https://github.com/expo/eas-cli/pull/3175) by [@douglowder](https://github.com/douglowder))
## [16.19.1](https://github.com/expo/eas-cli/releases/tag/v16.19.1) - 2025-09-12
## [16.19.0](https://github.com/expo/eas-cli/releases/tag/v16.19.0) - 2025-09-12
### 🎉 New features
- Allow any string as Android release `track`, not just the `production`, `internal`, `alpha`, `beta`. ([#3163](https://github.com/expo/eas-cli/pull/3163) by [@sjchmiela](https://github.com/sjchmiela))
## [16.18.1](https://github.com/expo/eas-cli/releases/tag/v16.18.1) - 2025-09-10
### 🎉 New features
- CLI debug log should respect DEBUG=`eas:*`. ([#3162](https://github.com/expo/eas-cli/pull/3162) by [@douglowder](https://github.com/douglowder))
- Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder))
## [16.18.0](https://github.com/expo/eas-cli/releases/tag/v16.18.0) - 2025-08-27
### 🎉 New features
- Show fingerprints in build:view and build:list commands. ([#3137](https://github.com/expo/eas-cli/pull/3137) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- Make `EXPO_PUBLIC_` env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
## [16.17.4](https://github.com/expo/eas-cli/releases/tag/v16.17.4) - 2025-07-31
### 🧹 Chores
- Introduce new release process and workflow. ([#3119](https://github.com/expo/eas-cli/pull/3119) by [@douglowder](https://github.com/douglowder))
- SSO Login: print browser url ([#3113](https://github.com/expo/eas-cli/pull/3113) by [@BackSlasher](https://github.com/BackSlasher))
## [16.17.2](https://github.com/expo/eas-cli/releases/tag/v16.17.2) - 2025-07-23
## [16.17.1](https://github.com/expo/eas-cli/releases/tag/v16.17.1) - 2025-07-23
## [16.17.0](https://github.com/expo/eas-cli/releases/tag/v16.17.0) - 2025-07-18
### 🎉 New features
- Add `deploy:delete` for deleting worker deployments ([#3106](https://github.com/expo/eas-cli/pull/3106) by [@kadikraman](https://github.com/kadikraman))
- Add `deploy:alias:delete` for deleting worker deployment aliases ([#3107](https://github.com/expo/eas-cli/pull/3107) by [@kadikraman](https://github.com/kadikraman))
## [16.16.0](https://github.com/expo/eas-cli/releases/tag/v16.16.0) - 2025-07-16
### 🎉 New features
- Add workflow:view and workflow:logs commands. ([#3090](https://github.com/expo/eas-cli/pull/3090) by [@douglowder](https://github.com/douglowder))
- Add support for providing inputs to new workflow runs ([#3095](https://github.com/expo/eas-cli/pull/3095) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Refactor capabilities syncing to avoid provisioning profiles becoming invalid. ([#3088](https://github.com/expo/eas-cli/pull/3088) by [@vonovak](https://github.com/vonovak))
- Avoid corrupted EAS Hosting batch upload bodies and double-check length and checksums while uploading. ([#3103](https://github.com/expo/eas-cli/pull/3103) by [@kitten](https://github.com/kitten))
## [16.15.0](https://github.com/expo/eas-cli/releases/tag/v16.15.0) - 2025-07-10
### 🎉 New features
- Implement new EAS Hosting asset upload protocol, which improves performance by grouping files into batched upload API calls ([#3085](https://github.com/expo/eas-cli/pull/3085) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Update the EAS Hosting worker deployment progress indicator to use a progress bar for asset uploads instead ([#3099](https://github.com/expo/eas-cli/pull/3099) by [@kitten](https://github.com/kitten))
## [16.14.1](https://github.com/expo/eas-cli/releases/tag/v16.14.1) - 2025-07-08
### 🧹 Chores
- Update apple team info as needed. ([#3096](https://github.com/expo/eas-cli/pull/3096) by [@quinlanj](https://github.com/quinlanj))
## [16.14.0](https://github.com/expo/eas-cli/releases/tag/v16.14.0) - 2025-07-07
### 🎉 New features
- Add update:revert-update-rollout command. ([#3068](https://github.com/expo/eas-cli/pull/3068) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Remove unreleased `link` and `unlink` commands from `eas env`. ([#3092](https://github.com/expo/eas-cli/pull/3092) by [@kadikraman](https://github.com/kadikraman))
- Add new CI to verify GraphQL generation. ([#3093](https://github.com/expo/eas-cli/pull/3093) by [@douglowder](https://github.com/douglowder))
- Update GraphQL generation to look in all src directories. ([#3097](https://github.com/expo/eas-cli/pull/3097) by [@wschurman](https://github.com/wschurman))
## [16.13.4](https://github.com/expo/eas-cli/releases/tag/v16.13.4) - 2025-07-04
### 🧹 Chores
- Remove hidden flag from `eas upload`. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.13.3](https://github.com/expo/eas-cli/releases/tag/v16.13.3) - 2025-07-01
### 🐛 Bug fixes
- Fix suggested command to switch Xcode Developer Tools. ([#3079](https://github.com/expo/eas-cli/pull/3079) by [@rolfb](https://github.com/rolfb))
- Skip configuring build profiles when running `eas update:configure` if no build profiles exist. ([#3084](https://github.com/expo/eas-cli/pull/3084) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- Reword how paid plans are described ([#3082](https://github.com/expo/eas-cli/pull/3082) by [@ide](https://github.com/ide))
## [16.13.2](https://github.com/expo/eas-cli/releases/tag/v16.13.2) - 2025-06-27
### 🧹 Chores
- Amend update override fields. ([#3080](https://github.com/expo/eas-cli/pull/3080) by [@quinlanj](https://github.com/quinlanj))
## [16.13.1](https://github.com/expo/eas-cli/releases/tag/v16.13.1) - 2025-06-25
### 🐛 Bug fixes
- Fix bug in log during update:republish. ([#3067](https://github.com/expo/eas-cli/pull/3067) by [@wschurman](https://github.com/wschurman))
- Fix update group deletion. ([#3069](https://github.com/expo/eas-cli/pull/3069) by [@wschurman](https://github.com/wschurman))
## [16.13.0](https://github.com/expo/eas-cli/releases/tag/v16.13.0) - 2025-06-24
### 🎉 New features
- Add `--what-to-test` option to `build` command. ([#3065](https://github.com/expo/eas-cli/pull/3065) by [@vonovak](https://github.com/vonovak))
### 🐛 Bug fixes
- Hide `workflow:list`. ([#3060](https://github.com/expo/eas-cli/pull/3060) by [@douglowder](https://github.com/douglowder))
- Align text of messages from `build` and `workflow:run`. ([#3061](https://github.com/expo/eas-cli/pull/3061) by [@douglowder](https://github.com/douglowder))
## [16.12.0](https://github.com/expo/eas-cli/releases/tag/v16.12.0) - 2025-06-20
### 🎉 New features
- Add `--what-to-test` option to the `submit` command. ([#3023](https://github.com/expo/eas-cli/pull/3023) by [@vonovak](https://github.com/vonovak))
### 🐛 Bug fixes
- Fixed monorepo support in `workflow:run` if a project is not connected to a GitHub repository. ([#3058](https://github.com/expo/eas-cli/pull/3058) by [@sjchmiela](https://github.com/sjchmiela))
## [16.10.1](https://github.com/expo/eas-cli/releases/tag/v16.10.1) - 2025-06-13
### 🐛 Bug fixes
- Fixed `eas build:dev` command to correctly use passed build profile. ([#3053](https://github.com/expo/eas-cli/pull/3053) by [@sebryu](https://github.com/sebryu))
### 🧹 Chores
- Allow rolling out update on empty branch. ([#3050](https://github.com/expo/eas-cli/pull/3050) by [@wschurman](https://github.com/wschurman))
## [16.10.0](https://github.com/expo/eas-cli/releases/tag/v16.10.0) - 2025-06-12
### 🎉 New features
- [eas-cli] Add workflow:cancel. ([#3048](https://github.com/expo/eas-cli/pull/3048) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Removed repack support. ([#3044](https://github.com/expo/eas-cli/pull/3044) by [@kudo](https://github.com/kudo))
## [16.9.0](https://github.com/expo/eas-cli/releases/tag/v16.9.0) - 2025-06-06
### 🎉 New features
- Add `groups` option to the `submit` command. ([#2891](https://github.com/expo/eas-cli/pull/2891) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
- [eas-cli] Ensure workflow:runs returns most recent runs. ([#3045](https://github.com/expo/eas-cli/pull/3045) by [@douglowder](https://github.com/douglowder))
## [16.8.0](https://github.com/expo/eas-cli/releases/tag/v16.8.0) - 2025-06-04
### 🎉 New features
- [eas-cli] Add workflow:list and workflow:runs. ([#3030](https://github.com/expo/eas-cli/pull/3030) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Change location of EAS Update host override config. ([#3042](https://github.com/expo/eas-cli/pull/3042) by [@wschurman](https://github.com/wschurman))
## [16.7.2](https://github.com/expo/eas-cli/releases/tag/v16.7.2) - 2025-06-03
### 🐛 Bug fixes
- Add support for array `flavorDimensions` (`flavorDimensions = ['version']`). ([#3043](https://github.com/expo/eas-cli/pull/3043) by [@sjchmiela](https://github.com/sjchmiela))
## [16.7.1](https://github.com/expo/eas-cli/releases/tag/v16.7.1) - 2025-06-03
### 🐛 Bug fixes
- Fix some unit tests by updating mocks. ([#3035](https://github.com/expo/eas-cli/pull/3035) by [@douglowder](https://github.com/douglowder))
- Fix update republishing not including environment or git info. ([#3036](https://github.com/expo/eas-cli/pull/3036) by [@wschurman](https://github.com/wschurman))
- Do not require `eas.json` or `package.json` files when creating new workflow runs. ([#3028](https://github.com/expo/eas-cli/pull/3028) by [@sjchmiela](https://github.com/sjchmiela))
- Add `EAS_SKIP_CLI_VERSION_CHECK` allowing us to skip `eas-cli` version check (against `eas.json#version`). ([#3041](https://github.com/expo/eas-cli/pull/3041) by [@sjchmiela](https://github.com/sjchmiela))
## [16.7.0](https://github.com/expo/eas-cli/releases/tag/v16.7.0) - 2025-05-29
### 🎉 New features
- Add support for manifest host and asset host overriding for EAS Update. ([#3021](https://github.com/expo/eas-cli/pull/3021) by [@wschurman](https://github.com/wschurman))
- Add rollout flag to update:republish. ([#3029](https://github.com/expo/eas-cli/pull/3029) by [@quinlanj](https://github.com/quinlanj))
## [16.6.2](https://github.com/expo/eas-cli/releases/tag/v16.6.2) - 2025-05-23
### 🧹 Chores
- Further no longer require owner field for SDK >= 53 or canary. ([#3017](https://github.com/expo/eas-cli/pull/3017) by [@wschurman](https://github.com/wschurman))
## [16.6.1](https://github.com/expo/eas-cli/releases/tag/v16.6.1) - 2025-05-14
### 🐛 Bug fixes
- Onboarding: add cwd to spawned git process. ([#3015](https://github.com/expo/eas-cli/pull/3015) by [@quinlanj](https://github.com/quinlanj))
## [16.6.0](https://github.com/expo/eas-cli/releases/tag/v16.6.0) - 2025-05-14
### 🎉 New features
- Add support for uploading iOS internal distribution local builds. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.5.0](https://github.com/expo/eas-cli/releases/tag/v16.5.0) - 2025-05-13
### 🎉 New features
- Add `--wait`/`--no-wait` flag to `eas workflow:run`. ([#3012](https://github.com/expo/eas-cli/pull/3012) by [@sjchmiela](https://github.com/sjchmiela))
- Add `--json` flag to `eas workflow:run`. ([#3013](https://github.com/expo/eas-cli/pull/3013) by [@sjchmiela](https://github.com/sjchmiela))
## [16.4.2](https://github.com/expo/eas-cli/releases/tag/v16.4.2) - 2025-05-10
### 🧹 Chores
- Update `eas-build-cache-provider` function names to use `buildCacheProvider` key. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.4.1](https://github.com/expo/eas-cli/releases/tag/v16.4.1) - 2025-05-07
### 🧹 Chores
- Allow non-standard build types in custom gradle command. ([#3003](https://github.com/expo/eas-cli/pull/3003) by [@khamilowicz](https://github.com/khamilowicz))
## [16.4.0](https://github.com/expo/eas-cli/releases/tag/v16.4.0) - 2025-05-02
### 🎉 New features
- Add `--verbose-logs` flag for `build` command ([#3000](https://github.com/expo/eas-cli/pull/3000) by [@khamilowicz](https://github.com/khamilowicz))
- Add `eas-build-cache-provider` package. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### 🧹 Chores
- Read remote build cache provider types from `@expo/config`. ([#3005](https://github.com/expo/eas-cli/pull/3005) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.3.3](https://github.com/expo/eas-cli/releases/tag/v16.3.3) - 2025-04-24
### 🐛 Bug fixes
- Add `sdk-52` alias to the list or supported android images. ([#2989](https://github.com/expo/eas-cli/pull/2989) by [@kadikraman](https://github.com/kadikraman))
- Detect when expo metro config is used. ([#2996](https://github.com/expo/eas-cli/pull/2996) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- upload assetmap.json when publishing update. ([#2994](https://github.com/expo/eas-cli/pull/2994) by [@quinlanj](https://github.com/quinlanj))
- Add error messages for CDN-level upload errors. ([#2998](https://github.com/expo/eas-cli/pull/2998) by [@kitten](https://github.com/kitten))
## [16.3.2](https://github.com/expo/eas-cli/releases/tag/v16.3.2) - 2025-04-17
### 🧹 Chores
- Update upload command to display build info. ([#2990](https://github.com/expo/eas-cli/pull/2990) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- change launch asset file extension. ([#2991](https://github.com/expo/eas-cli/pull/2991) by [@quinlanj](https://github.com/quinlanj))
## [16.3.1](https://github.com/expo/eas-cli/releases/tag/v16.3.1) - 2025-04-11
### 🧹 Chores
- Add `--no-dev-client` flag for `build:download` command. ([#2985](https://github.com/expo/eas-cli/pull/2985) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.3.0](https://github.com/expo/eas-cli/releases/tag/v16.3.0) - 2025-04-09
### 🎉 New features
- Add `eas upload` command. ([#2932](https://github.com/expo/eas-cli/pull/2932), [#2981](https://github.com/expo/eas-cli/pull/2981), [#2983](https://github.com/expo/eas-cli/pull/2983) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- Add `eas build:download` command. ([#2982](https://github.com/expo/eas-cli/pull/2982) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.2.2](https://github.com/expo/eas-cli/releases/tag/v16.2.2) - 2025-04-08
## [16.2.1](https://github.com/expo/eas-cli/releases/tag/v16.2.1) - 2025-04-04
### 🧹 Chores
- Bump `@expo/apple-utils` to use async JWT API. ([#2973](https://github.com/expo/eas-cli/pull/2973) by [@EvanBacon](https://github.com/EvanBacon))
## [16.2.0](https://github.com/expo/eas-cli/releases/tag/v16.2.0) - 2025-04-03
### 🎉 New features
- Add environment flag to `eas fingerprint:compare`. ([#2954](https://github.com/expo/eas-cli/pull/2954) by [@quinlanj](https://github.com/quinlanj))
- Add build-profile flag to `eas fingerprint:generate`. ([#2966](https://github.com/expo/eas-cli/pull/2966) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Remove hidden flag from `eas fingerprint:generate`. ([#2965](https://github.com/expo/eas-cli/pull/2965) by [@quinlanj](https://github.com/quinlanj))
- Refactor `eas update` command to improve code readability. ([#2976](https://github.com/expo/eas-cli/pull/2976) by [@quinlanj](https://github.com/quinlanj))
- `eas update`: add warning if no build exists with fingerprint. ([#2977](https://github.com/expo/eas-cli/pull/2977) by [@quinlanj](https://github.com/quinlanj))
## [16.1.0](https://github.com/expo/eas-cli/releases/tag/v16.1.0) - 2025-03-26
### 🎉 New features
- Add environment flag to `eas fingerprint:generate`. ([#2951](https://github.com/expo/eas-cli/pull/2951) by [@quinlanj](https://github.com/quinlanj))
- Add `corepack` field to `eas.json`. ([#2964](https://github.com/expo/eas-cli/pull/2964) by [@szdziedzic](https://github.com/szdziedzic))
## [16.0.1](https://github.com/expo/eas-cli/releases/tag/v16.0.1) - 2025-03-20
### 🐛 Bug fixes
- Make `eas update:configure` re-apply configuration from app.json /app.config.js when run multiple times. ([#2957](https://github.com/expo/eas-cli/pull/2957) by [@brentvatne](https://github.com/brentvatne))
## [16.0.0](https://github.com/expo/eas-cli/releases/tag/v16.0.0) - 2025-03-19
### 🛠 Breaking changes
- Add support for `.easignore` when `requireCommit` is set to `true`. ([#2942](https://github.com/expo/eas-cli/pull/2942) by [@sjchmiela](https://github.com/sjchmiela))
- Up to 15.0.0, if `requireCommit` was `true`, `.easignore` was silently ignored.
- Versions 15.0.0-15.0.13 started using `.easignore` to skip files from being bundled into a tarball when `requireCommit` was `true`. This was an unexpected change in behavior.
- To clear this up, versions 15.0.13-15.0.15 were erroring if `.easignore` was present when `requireCommit` was `true`.
- `[email protected]` formalizes the 15.0.0-15.0.13 behavior by adhering to `.easignore` even when `requireCommit` is set to `true`.
- If you know what you're doing and you want to suppress a warning printed, you can do so by setting `EAS_SUPPRESS_REQUIRE_COMMIT_EASIGNORE_WARNING` environment variable to `true`.
### 🎉 New features
- Add requestId to ApiV2Error. ([#2941](https://github.com/expo/eas-cli/pull/2941) by [@wschurman](https://github.com/wschurman))
- Release `eas fingerprint:generate` in hidden mode ([#2937](https://github.com/expo/eas-cli/pull/2937) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Use correct logic to determine whether artifacts have expired in `eas build:run` command. ([#2931](https://github.com/expo/eas-cli/pull/2931) by [@szdziedzic](https://github.com/szdziedzic))
## [15.0.15](https://github.com/expo/eas-cli/releases/tag/v15.0.15) - 2025-03-12
### 🐛 Bug fixes
- Pass through the updates version to `setUpdatesConfigAsync`, which expects it in order to determine which field values to use. ([#2934](https://github.com/expo/eas-cli/pull/2934) by [@brentvatne](https://github.com/brentvatne)).
## [15.0.14](https://github.com/expo/eas-cli/releases/tag/v15.0.14) - 2025-03-06
### 🐛 Bug fixes
- In `EAS_NO_VCS=1`, use Git for repository root when `EAS_PROJECT_ROOT` is not set. ([#2901](https://github.com/expo/eas-cli/pull/2901) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Suggest using `eas build:dev` for matching configurations. ([#2929](https://github.com/expo/eas-cli/pull/2929) by [@szdziedzic](https://github.com/szdziedzic))
## [15.0.13](https://github.com/expo/eas-cli/releases/tag/v15.0.13) - 2025-03-04
### 🐛 Bug fixes
- Fixed `.git` being always unexpectedly removed if you had `requireCommit: true` and `.easignore` present. ([#2925](https://github.com/expo/eas-cli/pull/2925) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Fix `eas fingerprint:compare` URL generation and pretty prints. ([#2909](https://github.com/expo/eas-cli/pull/2909) by [@quinlanj](https://github.com/quinlanj))
- fix formatFields to handle empty array. ([#2914](https://github.com/expo/eas-cli/pull/2914) by [@quinlanj](https://github.com/quinlanj))
- fix git diff header in `fingerprint:compare`. ([#2915](https://github.com/expo/eas-cli/pull/2915) by [@quinlanj](https://github.com/quinlanj))
- amend fingerprint compare url. ([#2923](https://github.com/expo/eas-cli/pull/2923) by [@quinlanj](https://github.com/quinlanj))
- improve error message for `applicationIdSuffix`. ([#2924](https://github.com/expo/eas-cli/pull/2924) by [@kadikraman](https://github.com/kadikraman))
## [15.0.12](https://github.com/expo/eas-cli/releases/tag/v15.0.12) - 2025-02-22
+5 -14
View File
@@ -1,21 +1,12 @@
# Development
## Getting Started
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
If this is your first time running `eas-cli` locally, make sure you install and build the packages. In the project root, run:
```sh
yarn install
yarn build
```
From there, set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
> The only exception is if there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
Open your `.zshrc` or other config file and add:
```sh
```
alias easd="/PATH/TO/eas-cli/bin/run"
```
@@ -23,7 +14,7 @@ Then use it with `easd` like `easd build`.
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
```sh
```
yarn start
```
@@ -42,7 +33,7 @@ If it is easier for you to work with the code locally with compiler options set
you can consider using:
```sh
```
yarn start-allow-unused
```
+1 -1
View File
@@ -1,6 +1,6 @@
# Releasing EAS CLI
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
## Choosing the next version
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.20.0",
"version": "15.0.12",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -28,7 +28,7 @@
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "8.2.3",
"lerna": "7.4.2",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"ts-jest": "29.1.1",
@@ -1,22 +0,0 @@
# eas-build-cache-provider
A build cache provider plugin for the Expo CLI
To use the EAS remote build provider plugin, install the `eas-build-cache-provider` package as a developer dependency:
```bash
npm install --save-dev eas-build-cache-provider
```
Then, update your **app.json** to include the `buildCacheProvider` property and its provider under `experiments`:
```json
{
"expo": {
...
"experiments": {
"buildCacheProvider": "eas"
}
}
}
```
@@ -1,58 +0,0 @@
{
"name": "eas-build-cache-provider",
"description": "A build cache provider plugin for the Expo CLI",
"version": "16.4.2",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/config": "11.0.10",
"@expo/spawn-async": "^1.7.2",
"chalk": "4.1.2",
"figures": "3.2.0",
"fs-extra": "11.2.0",
"getenv": "1.0.0",
"log-symbols": "4.1.0",
"semver": "7.5.2",
"terminal-link": "2.1.1",
"tslib": "2.4.1"
},
"devDependencies": {
"@tsconfig/node18": "18.2.4",
"@types/babel__code-frame": "7.0.3",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.3.3"
},
"engines": {
"node": ">=18.0.0"
},
"homepage": "https://github.com/expo/eas-cli",
"license": "MIT",
"main": "build/index.js",
"types": "build/index.d.ts",
"repository": "expo/eas-cli",
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build && yarn build",
"test": "jest",
"clean": "rimraf build node_modules yarn-error.log"
},
"files": [
"/build"
],
"publishConfig": {
"access": "public"
},
"volta": {
"node": "20.11.0",
"yarn": "1.22.21"
}
}
@@ -1,19 +0,0 @@
module.exports = {
extends: ['universe/shared/typescript-analysis'],
overrides: [
{
files: ['*.ts', '*.d.ts'],
parserOptions: {
project: './packages/eas-build-cache-provider/tsconfig.json',
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
},
},
],
};
@@ -1,38 +0,0 @@
import { RunOptions, getPackageJson } from '@expo/config';
import { SpawnResult } from '@expo/spawn-async';
export function isSpawnResultError(obj: any): obj is Error & SpawnResult {
return (
obj &&
'message' in obj &&
obj.status !== undefined &&
obj.stdout !== undefined &&
obj.stderr !== undefined
);
}
export function isDevClientBuild({
runOptions,
projectRoot,
}: {
runOptions: RunOptions;
projectRoot: string;
}): boolean {
if (!hasDirectDevClientDependency(projectRoot)) {
return false;
}
if ('variant' in runOptions && runOptions.variant !== undefined) {
return runOptions.variant === 'debug';
}
if ('configuration' in runOptions && runOptions.configuration !== undefined) {
return runOptions.configuration === 'Debug';
}
return true;
}
export function hasDirectDevClientDependency(projectRoot: string): boolean {
const { dependencies = {}, devDependencies = {} } = getPackageJson(projectRoot);
return !!dependencies['expo-dev-client'] || !!devDependencies['expo-dev-client'];
}
@@ -1,136 +0,0 @@
import {
BuildCacheProviderPlugin,
CalculateFingerprintHashProps,
ResolveBuildCacheProps,
UploadBuildCacheProps,
} from '@expo/config';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { isDevClientBuild, isSpawnResultError } from './helpers';
import Log from './log';
async function resolveBuildCacheAsync({
projectRoot,
platform,
fingerprintHash,
runOptions,
}: ResolveBuildCacheProps): Promise<string | null> {
const easJsonPath = path.join(projectRoot, 'eas.json');
if (!(await fs.exists(easJsonPath))) {
Log.debug('eas.json not found, skip checking for remote builds');
return null;
}
Log.log(
chalk`{whiteBright \u203A} {bold Searching builds with matching fingerprint on EAS servers}`
);
try {
const results = await spawnAsync(
'npx',
[
'eas-cli',
'build:download',
`--platform=${platform}`,
`--fingerprint=${fingerprintHash}`,
'--non-interactive',
isDevClientBuild({ runOptions, projectRoot }) ? '--dev-client' : '--no-dev-client',
'--json',
],
{
cwd: projectRoot,
}
);
Log.log(chalk`{whiteBright \u203A} {bold Successfully downloaded cached build}`);
// {
// "path": "/var/folders/03/lppcpcnn61q3mz5ckzmzd8w80000gn/T/eas-cli-nodejs/eas-build-run-cache/c0f9ba9c-0cf1-4c5c-8566-b28b7971050f_22f1bbfa-1c09-4b67-9e4a-721906546b58.app"
// }
const json = JSON.parse(results.stdout.trim());
return json?.path;
} catch (error) {
Log.debug('eas-cli error:', error);
// @TODO(2025-04-11): remove this in a future release
if (isSpawnResultError(error) && error.stderr.includes('command build:download not found')) {
Log.warn(
`To take advantage of EAS build cache provider, upgrade your eas-cli installation to latest.`
);
}
return null;
}
}
async function uploadBuildCacheAsync({
projectRoot,
platform,
fingerprintHash,
buildPath,
}: UploadBuildCacheProps): Promise<string | null> {
const easJsonPath = path.join(projectRoot, 'eas.json');
if (!(await fs.exists(easJsonPath))) {
Log.debug('eas.json not found, skip uploading builds');
return null;
}
try {
Log.log(chalk`{whiteBright \u203A} {bold Uploading build to EAS}`);
const results = await spawnAsync(
'npx',
[
'eas-cli',
'upload',
`--platform=${platform}`,
`--fingerprint=${fingerprintHash}`,
buildPath ? `--build-path=${buildPath}` : '',
'--non-interactive',
'--json',
],
{
cwd: projectRoot,
}
);
// {
// "url": "/var/folders/03/lppcpcnn61q3mz5ckzmzd8w80000gn/T/eas-cli-nodejs/eas-build-run-cache/c0f9ba9c-0cf1-4c5c-8566-b28b7971050f_22f1bbfa-1c09-4b67-9e4a-721906546b58.app"
// }
const json = JSON.parse(results.stdout.trim());
Log.log(chalk`{whiteBright \u203A} {bold Build successfully uploaded: ${json?.url}}`);
return json?.url;
} catch (error) {
Log.debug('eas-cli error:', error);
}
return null;
}
async function calculateEASFingerprintHashAsync({
projectRoot,
platform,
}: CalculateFingerprintHashProps): Promise<string | null> {
// prefer using `eas fingerprint:generate` because it automatically upload sources
try {
const results = await spawnAsync(
'npx',
['eas-cli', 'fingerprint:generate', `--platform=${platform}`, '--json', '--non-interactive'],
{
cwd: projectRoot,
}
);
// {
// "hash": "203f960b965e154b77dc31c6c42e5582e8d77196"
// }
const json = JSON.parse(results.stdout.trim());
return json?.hash;
} catch (error) {
Log.debug('eas-cli error:', error);
}
return null;
}
const EASBuildCacheProvider: BuildCacheProviderPlugin = {
resolveBuildCache: resolveBuildCacheAsync,
uploadBuildCache: uploadBuildCacheAsync,
calculateFingerprintHash: calculateEASFingerprintHashAsync,
};
export default EASBuildCacheProvider;
@@ -1,121 +0,0 @@
import chalk from 'chalk';
import figures from 'figures';
import { boolish } from 'getenv';
import logSymbols from 'log-symbols';
import terminalLink from 'terminal-link';
type Color = (...text: string[]) => string;
export default class Log {
public static readonly isDebug = boolish('EXPO_DEBUG', false);
public static log(...args: any[]): void {
Log.consoleLog(...args);
}
public static newLine(): void {
Log.consoleLog();
}
public static addNewLineIfNone(): void {
if (!Log.isLastLineNewLine) {
Log.newLine();
}
}
public static error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
public static debug(...args: any[]): void {
if (Log.isDebug) {
Log.consoleLog(...args);
}
}
public static gray(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.gray));
}
public static warnDeprecatedFlag(flag: string, message: string): void {
Log.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
}
public static fail(message: string): void {
Log.log(`${chalk.red(logSymbols.error)} ${message}`);
}
public static succeed(message: string): void {
Log.log(`${chalk.green(logSymbols.success)} ${message}`);
}
public static withTick(...args: any[]): void {
Log.consoleLog(chalk.green(figures.tick), ...args);
}
public static withInfo(...args: any[]): void {
Log.consoleLog(chalk.green(figures.info), ...args);
}
private static consoleLog(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.log(...args);
}
private static withTextColor(args: any[], chalkColor: Color): string[] {
return args.map(arg => chalkColor(arg));
}
private static isLastLineNewLine = false;
private static updateIsLastLineNewLine(args: any[]): void {
if (args.length === 0) {
Log.isLastLineNewLine = true;
} else {
const lastArg = args[args.length - 1];
if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) {
Log.isLastLineNewLine = true;
} else {
Log.isLastLineNewLine = false;
}
}
}
}
/**
* Prints a link for given URL, using text if provided, otherwise text is just the URL.
* Format links as dim (unless disabled) and with an underline.
*
* @example https://expo.dev
*/
export function link(
url: string,
{ text = url, fallback, dim = true }: { text?: string; dim?: boolean; fallback?: string } = {}
): string {
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
const output = terminalLink(text, url, {
fallback: () =>
fallback ?? (text === url ? chalk.underline(url) : `${text}: ${chalk.underline(url)}`),
});
return dim ? chalk.dim(output) : output;
}
/**
* Provide a consistent "Learn more" link experience.
* Format links as dim (unless disabled) with an underline.
*
* @example Learn more: https://expo.dev
*/
export function learnMore(
url: string,
{
learnMoreMessage: maybeLearnMoreMessage,
dim = true,
}: { learnMoreMessage?: string; dim?: boolean } = {}
): string {
return link(url, { text: maybeLearnMoreMessage ?? 'Learn more', dim });
}
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig",
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}
@@ -1,18 +0,0 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"declaration": true,
"importHelpers": true,
"module": "commonjs",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "build",
"rootDir": "src",
"typeRoots": ["../../node_modules/@types", "../../ts-declarations", "./node_modules/@types"]
},
"include": ["src/**/*"]
}
+101 -437
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -1,7 +1,13 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/**/!(*.d).ts'
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
- 'src/branch/**/*.ts'
- 'src/channel/**/*.ts'
- 'src/worker/**/*.ts'
generates:
src/graphql/generated.ts:
plugins:
+883 -4585
View File
File diff suppressed because it is too large. Load diff
+11 -15
View File
@@ -1,22 +1,22 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.20.0",
"version": "15.0.12",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "2.1.12",
"@expo/apple-utils": "2.1.11",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.221",
"@expo/eas-json": "16.19.1",
"@expo/eas-build-job": "1.0.165",
"@expo/eas-json": "15.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.221",
"@expo/logger": "1.0.117",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.7.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "1.0.221",
"@expo/steps": "1.0.166",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
@@ -38,7 +38,6 @@
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"bplist-parser": "^0.3.0",
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
@@ -49,7 +48,7 @@
"fast-deep-equal": "3.1.3",
"fast-glob": "3.3.2",
"figures": "3.2.0",
"form-data": "^4.0.4",
"form-data": "4.0.0",
"fs-extra": "11.2.0",
"getenv": "1.0.0",
"gradle-to-js": "2.0.1",
@@ -69,7 +68,6 @@
"nanoid": "3.3.8",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
@@ -80,19 +78,17 @@
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
"tar": "6.2.1",
"tar-stream": "3.1.7",
"terminal-link": "2.1.1",
"ts-deepmerge": "6.2.0",
"tslib": "2.6.2",
"turndown": "7.1.2",
"untildify": "4.0.0",
"uuid": "9.0.1",
"wrap-ansi": "7.0.0",
"yaml": "2.6.0",
"zod": "^4.1.3"
"zod": "^3.23.8"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.0",
@@ -109,7 +105,7 @@
"@types/getenv": "^1.0.0",
"@types/jsonwebtoken": "8.5.1",
"@types/mime": "3.0.1",
"@types/node-fetch": "2.6.12",
"@types/node-fetch": "2.6.2",
"@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.4",
"@types/promise-retry": "1.1.3",
@@ -120,13 +116,14 @@
"@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.7",
"@types/wrap-ansi": "3.0.0",
"axios": "1.11.0",
"axios": "0.28.1",
"eslint-plugin-graphql": "4.0.0",
"express": "4.20.0",
"memfs": "3.4.13",
"mockdate": "3.0.5",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-deepmerge": "6.2.0",
"ts-mockito": "2.6.1",
"ts-node": "10.9.2",
"typescript": "5.3.3"
@@ -234,7 +231,6 @@
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log"
},
"volta": {
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
function cleanup()
{
echo 'Cleaning up...'
rm -f graphql.schema.json.bak src/graphql/generated.ts.bak
}
# Fail if anything errors
set -eox pipefail
# If this script exits, trap it first and clean up
trap cleanup EXIT
cp graphql.schema.json graphql.schema.json.bak
cp src/graphql/generated.ts src/graphql/generated.ts.bak
yarn generate-graphql-code
if cmp -s graphql.schema.json graphql.schema.json.bak; then
echo "GraphQL schema is up-to-date"
if cmp -s src/graphql/generated.ts src/graphql/generated.ts.bak; then
echo "GraphQL generated code is up-to-date"
exit 0
else
echo "GraphQL code has changed but has not been regenerated. Run `yarn generate-graphql-code` and commit the changes."
exit 1
fi
else
echo "GraphQL schema has changed on the server."
echo "Building eas-json"
cd ../eas-json
yarn build
cd ../eas-cli
echo "Running `yarn tsc` to check for breaking changes..."
yarn tsc
# If there are no errors, then the schema is backwards compatible
exit 0
fi
-3
View File
@@ -6,7 +6,6 @@ export class ApiV2Error extends Error {
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
readonly expoApiV2ErrorRequestId?: string;
constructor(response: {
message: string;
@@ -14,13 +13,11 @@ export class ApiV2Error extends Error {
stack?: string;
details?: JSONValue;
metadata?: object;
requestId?: string;
}) {
super(response.message);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
this.expoApiV2ErrorMetadata = response.metadata;
this.expoApiV2ErrorRequestId = response.requestId;
}
}
@@ -16,7 +16,6 @@ describe(ApiV2Client, () => {
stack: 'line 1: hello',
details: { who: 'world' },
metadata: { an: 'object' },
requestId: 'request-id',
},
],
});
@@ -38,7 +37,6 @@ describe(ApiV2Client, () => {
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
expect(error.expoApiV2ErrorRequestId).toEqual('request-id');
});
it('does not convert non-APIv2 error to ApiV2Error', async () => {
@@ -7,16 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import {
AppFragment,
Role,
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { AppFragment, Role } from '../../graphql/generated';
export function getMockEasJson(): EasJson {
return {
@@ -90,9 +81,7 @@ export function mockCommandContext<
if (contextKey === 'loggedIn') {
result.loggedIn = {};
}
if (contextKey === 'projectId') {
result.projectId = overrides.projectId ?? mockProjectId;
}
if (contextKey === 'projectDir') {
result.projectDir = projectDir;
}
@@ -179,130 +168,3 @@ export function withLocalVersionSource(easJson: EasJson): EasJson {
},
};
}
export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
return getMockWorkflowRunsFragment();
}
export function getMockWorkflowRunsFragment(
params?:
| undefined
| {
successes?: number;
failures?: number;
pending?: number;
withJobs?: number;
}
): WorkflowRunFragment[] {
const { successes = 0, failures = 0, pending = 0, withJobs = 0 } = params ?? {};
const runs: any[] = [];
for (let i = 0; i < successes; i++) {
runs.push({
id: `success-${i}`,
status: WorkflowRunStatus.Success,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
});
}
for (let i = 0; i < failures; i++) {
runs.push({
id: `failure-${i}`,
errors: ['Failed'],
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
});
}
for (let i = 0; i < pending; i++) {
runs.push({
id: `pending-${i}`,
errors: [],
status: WorkflowRunStatus.InProgress,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
});
}
for (let i = 0; i < withJobs; i++) {
runs.push(getMockWorkflowRunWithJobsFragment());
}
return runs;
}
export function getMockWorkflowJobFragment(
runId?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
return {
id: 'job1',
key: 'job1',
name: 'job1',
status: WorkflowJobStatus.Success,
type: WorkflowJobType.Build,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
outputs: {},
errors: [],
workflowRun: {
id: runId ?? 'build1',
},
turtleJobRun: {
id: 'job1',
logFileUrls: ['https://example.com/log1'],
errors: [],
artifacts: [],
},
};
}
export function getMockWorkflowRunWithJobsFragment(
runID?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
const id = runID ?? 'build1';
return {
id,
status: WorkflowRunStatus.Failure,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
app: {
id: mockProjectId,
__typename: 'App',
name: 'App',
ownerAccount: {
id: 'account-id',
name: 'account-name',
__typename: 'Account',
},
},
},
jobs: [getMockWorkflowJobFragment(id)],
};
}
@@ -1,188 +0,0 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowJobFragment,
getMockWorkflowRunWithJobsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/queries/WorkflowJobQuery');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowJobFragment());
jest
.mocked(WorkflowRunQuery.withJobsByIdAsync)
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
});
afterEach(() => {
jest.clearAllMocks();
});
test('view logs, passing in no parameters, no runs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(Log.error).toHaveBeenCalledWith('No workflow runs found');
});
test('view logs, passing in no parameters, runs found, no jobs', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ successes: 1 }));
jest.mocked(promptAsync).mockResolvedValue({ selectedRun: 'build1' });
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(Log.error).toHaveBeenCalledWith('No job found');
});
test('view logs, passing in no parameters, runs found, jobs found, step selected, no logs', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
}
});
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(promptAsync).toHaveBeenCalledTimes(2);
expect(Log.error).toHaveBeenCalledWith('No logs found');
});
test('view logs, passing in no parameters, runs found, jobs found, step selected, logs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
}
});
jest
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, ['job1', '--all-steps'], ctx);
await cmd.run();
expect(WorkflowJobQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'job1', {
useCache: false,
});
});
test('view logs for a workflow job, passing in a run ID', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--all-steps'], ctx);
await cmd.run();
expect(WorkflowRunQuery.withJobsByIdAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
'build1',
{
useCache: false,
}
);
});
test('view logs for a workflow job, passing in a run ID, non-interactive', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--non-interactive'], ctx);
try {
await cmd.run();
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect((e as Error).message).toEqual('No workflow job found that matched the provided ID');
}
});
});
@@ -1,184 +0,0 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import WorkflowRunList from '../../commands/workflow/runs';
import { WorkflowRunStatus, WorkflowRunTriggerEventType } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
describe(WorkflowRunList, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('list workflow runs with default params', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
const cmd = mockTestCommand(WorkflowRunList, [], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
10
);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('list workflow runs with custom limit', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
const cmd = mockTestCommand(WorkflowRunList, ['--limit', '100'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
100
);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('list workflow runs with specific filename', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
{
id: 'build',
status: WorkflowRunStatus.Success,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
errors: [],
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
},
},
]);
const cmd = mockTestCommand(WorkflowRunList, ['--workflow', 'build.yml'], ctx);
await cmd.run();
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'build.yml',
undefined,
10
);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('list only workflow runs with status FAILURE, get json output', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ failures: 1 }));
const cmd = mockTestCommand(WorkflowRunList, ['--status', 'FAILURE', '--json'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.Failure,
10
);
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
{
id: 'failure-0',
status: 'FAILURE',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
trigger: '',
triggerType: 'Other',
startedAt: '2022-01-01T00:00:00.000Z',
finishedAt: '2022-01-01T00:00:00.000Z',
workflowId: 'build',
workflowName: 'build',
workflowFileName: 'build.yml',
},
]);
});
test('list only workflow runs with specific filename and status FAILURE, get json output', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
{
id: 'build1',
status: WorkflowRunStatus.Failure,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
},
},
]);
const cmd = mockTestCommand(
WorkflowRunList,
['--workflow', 'build.yml', '--status', 'FAILURE', '--json'],
ctx
);
await cmd.run();
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'build.yml',
WorkflowRunStatus.Failure,
10
);
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
{
id: 'build1',
status: 'FAILURE',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
trigger: '',
triggerType: 'Manual',
startedAt: '2022-01-01T00:00:00.000Z',
finishedAt: '2022-01-01T00:00:00.000Z',
workflowId: 'build',
workflowName: 'build',
workflowFileName: 'build.yml',
},
]);
});
});
+1 -9
View File
@@ -95,11 +95,7 @@ export function getExpoWebsiteBaseUrl(): string {
}
}
export function getEASUpdateURL(projectId: string, manifestHostOverride: string | null): string {
if (manifestHostOverride) {
return new URL(projectId, `https://${manifestHostOverride}`).href;
}
export function getEASUpdateURL(projectId: string): string {
if (process.env.EXPO_STAGING) {
return new URL(projectId, `https://staging-u.expo.dev`).href;
} else if (process.env.EXPO_LOCAL) {
@@ -108,7 +104,3 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string
return new URL(projectId, `https://u.expo.dev`).href;
}
}
export function getExpoApiWorkflowSchemaURL(): string {
return getExpoApiBaseUrl() + '/v2/workflows/schema';
}
@@ -1,26 +0,0 @@
import Log from '../../../log';
import { maybeWarnAboutNonStandardBuildType } from '../build';
const warn = jest.spyOn(Log, 'warn');
describe('maybeWarnAboutNonStandardBuildType', () => {
it('should not warn about non-standard build type', () => {
const buildProfile = {
gradleCommand: ':app:buildRelease',
};
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'release' });
expect(warn).not.toHaveBeenCalled();
});
it('should warn about non-standard build type', () => {
const buildProfile = {
gradleCommand: ':app:buildStage',
};
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'stage' });
expect(warn).toHaveBeenCalled();
});
});
+1 -41
View File
@@ -1,7 +1,6 @@
import { Android, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import chalk from 'chalk';
import getenv from 'getenv';
import nullthrows from 'nullthrows';
import { transformJob } from './graphql';
@@ -13,7 +12,7 @@ import AndroidCredentialsProvider, {
} from '../../credentials/android/AndroidCredentialsProvider';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log, { learnMore } from '../../log';
import Log from '../../log';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
getApplicationIdAsync,
@@ -66,10 +65,6 @@ This means that it will most likely produce an AAB and you will not be able to i
ctx.vcsClient
);
if (gradleContext?.buildType) {
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: gradleContext.buildType });
}
if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
}
@@ -173,38 +168,3 @@ async function ensureAndroidCredentialsAsync(
source: credentialsSource,
};
}
export function maybeWarnAboutNonStandardBuildType({
buildProfile,
buildType,
}: {
buildProfile: Pick<CommonContext<Platform.ANDROID>['buildProfile'], 'gradleCommand'>;
buildType: string;
}): void {
const shouldSuppressWarning = getenv.boolish('EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING', false);
if (shouldSuppressWarning) {
return;
}
const knownValidBuildTypes = ['debug', 'release'];
if (!knownValidBuildTypes.includes(buildType)) {
Log.warn(
`Custom gradle command "${chalk.bold(
buildProfile.gradleCommand
)}" has non-standard build type: "${chalk.bold(
buildType
)}". Expected the command to end with "${chalk.bold('...Debug')}" or "${chalk.bold(
'...Release'
)}"`
);
Log.warn(
`Ensure the command is spelled correctly and ${chalk.bold(
'build.gradle'
)} is configured correctly. To suppress this warning, set ${chalk.bold(
'EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING=true'
)}.`
);
Log.warn(learnMore('https://developer.android.com/build/build-variants#build-types'));
}
}
@@ -12,7 +12,7 @@ import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { getUsername } from '../../project/projectUtils';
import { BuildContext } from '../context';
interface JobData {
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Android.Job> {
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
@@ -57,7 +57,9 @@ export async function prepareJobAsync(
: undefined;
let buildMode;
if (buildProfile.config) {
if (ctx.repack) {
buildMode = BuildMode.REPACK;
} else if (buildProfile.config) {
buildMode = BuildMode.CUSTOM;
} else {
buildMode = BuildMode.BUILD;
@@ -71,15 +73,11 @@ export async function prepareJobAsync(
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
corepack: buildProfile.corepack,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn,
ndk: buildProfile.ndk,
env: {
...buildProfile.env,
...(ctx.isVerboseLoggingEnabled ? { EAS_VERBOSE: '1' } : {}),
},
env: buildProfile.env,
},
cache: {
...cacheDefaults,
@@ -112,6 +110,11 @@ export async function prepareJobAsync(
path: maybeCustomBuildConfigPath,
},
}),
...(ctx.repack && {
customBuildConfig: {
path: '__eas/repack.yml',
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
+6 -2
View File
@@ -46,7 +46,6 @@ import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
BuildFragment,
@@ -69,6 +68,7 @@ import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAs
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createFingerprintAsync } from '../utils/fingerprintCli';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -500,7 +500,11 @@ async function handleSingleBuildProgressAsync(
if (build.priority !== BuildPriority.High) {
Log.newLine();
Log.log('Start builds sooner in the priority queue.');
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
Log.log(
`Sign up for EAS Production or Enterprise at ${link(
formatAccountBillingUrl(accountName)
)}`
);
}
Log.newLine();
+1 -2
View File
@@ -62,7 +62,6 @@ export interface BuildContext<T extends Platform> {
requiredPackageManager: NodePackageManager['name'] | null;
vcsClient: Client;
loggerLevel?: LoggerLevel;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
repack: boolean;
env: Record<string, string>;
}
+3 -6
View File
@@ -42,8 +42,7 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
isVerboseLoggingEnabled,
whatToTest,
repack,
env,
}: {
buildProfileName: string;
@@ -65,8 +64,7 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
repack: boolean;
env: Record<string, string>;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
@@ -152,9 +150,8 @@ export async function createBuildContextAsync<T extends Platform>({
developmentClient,
requiredPackageManager,
loggerLevel: buildLoggerLevel,
isVerboseLoggingEnabled,
repack,
env,
whatToTest,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
+11 -8
View File
@@ -14,7 +14,7 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { IosJobSecretsInput } from '../../graphql/generated';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { getUsername } from '../../project/projectUtils';
import { BuildContext } from '../context';
interface JobData {
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Ios.Job> {
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
@@ -49,7 +49,9 @@ export async function prepareJobAsync(
: undefined;
let buildMode;
if (buildProfile.config) {
if (ctx.repack) {
buildMode = BuildMode.REPACK;
} else if (buildProfile.config) {
buildMode = BuildMode.CUSTOM;
} else {
buildMode = BuildMode.BUILD;
@@ -63,17 +65,13 @@ export async function prepareJobAsync(
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
corepack: buildProfile.corepack,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn,
bundler: buildProfile.bundler,
cocoapods: buildProfile.cocoapods,
fastlane: buildProfile.fastlane,
env: {
...buildProfile.env,
...(ctx.isVerboseLoggingEnabled ? { EAS_VERBOSE: '1' } : {}),
},
env: buildProfile.env,
},
cache: {
...cacheDefaults,
@@ -107,6 +105,11 @@ export async function prepareJobAsync(
path: maybeCustomBuildConfigPath,
},
}),
...(ctx.repack && {
customBuildConfig: {
path: '__eas/repack.yml',
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
+1 -1
View File
@@ -7,7 +7,7 @@ import Log from '../log';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.171';
const PLUGIN_PACKAGE_VERSION = '1.0.168';
export enum LocalBuildMode {
/**
+2 -5
View File
@@ -9,10 +9,7 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import {
getUsernameForBuildMetadataAndBuildJob,
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
@@ -51,7 +48,7 @@ export async function collectMetadataAsync<T extends Platform>(
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
? false
: await ctx.vcsClient.hasUncommittedChangesAsync(),
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
username: getUsername(ctx.exp, ctx.user),
message: ctx.message,
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
@@ -97,8 +97,7 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
repack: boolean;
}
export async function runBuildAndSubmitAsync({
@@ -123,7 +122,6 @@ export async function runBuildAndSubmitAsync({
envOverride?: Env;
}): Promise<{
buildIds: string[];
buildProfiles?: ProfileData<'build'>[];
}> {
await vcsClient.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
@@ -230,7 +228,6 @@ export async function runBuildAndSubmitAsync({
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
env,
easJsonAccessor,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -273,7 +270,7 @@ export async function runBuildAndSubmitAsync({
({ platform }) => toAppPlatform(platform) === startedBuild.build.platform
)
).profile;
const submission = await prepareAndStartAutoSubmissionAsync({
const submission = await prepareAndStartSubmissionAsync({
build: startedBuild.build,
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
moreBuilds: startedBuilds.length > 1,
@@ -351,7 +348,6 @@ export async function runBuildAndSubmitAsync({
}
return {
buildIds: startedBuilds.map(({ build }) => build.id),
buildProfiles,
};
}
@@ -368,7 +364,6 @@ async function prepareAndStartBuildAsync({
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
env,
easJsonAccessor,
}: {
projectDir: string;
flags: BuildFlags;
@@ -381,7 +376,6 @@ async function prepareAndStartBuildAsync({
vcsClient: Client;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
easJsonAccessor: EasJsonAccessor;
env: Env;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
@@ -404,8 +398,7 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
repack: flags.repack,
env,
});
@@ -429,18 +422,8 @@ async function prepareAndStartBuildAsync({
nonInteractive: flags.nonInteractive,
buildProfile,
env: buildProfile.profile.env,
easJsonAccessor,
});
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
if (
isUsingEASUpdate(
buildCtx.exp,
buildCtx.projectId,
easJsonCliConfig.updateManifestHostOverride ?? null
)
) {
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
appId: buildCtx.projectId,
channelName: buildProfile.profile.channel,
@@ -505,7 +488,7 @@ async function startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragme
return await sendBuildRequestAsync();
}
async function prepareAndStartAutoSubmissionAsync({
async function prepareAndStartSubmissionAsync({
build,
buildCtx,
moreBuilds,
@@ -540,8 +523,6 @@ async function prepareAndStartAutoSubmissionAsync({
vcsClient: buildCtx.vcsClient,
isVerboseFastlaneEnabled: false,
specifiedProfile: selectedSubmitProfileName,
groups: undefined, // use groups from submit profile
whatToTest: buildCtx.whatToTest,
});
if (moreBuilds) {
@@ -621,7 +602,6 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
nonInteractive,
sdkVersion,
env,
easJsonAccessor,
}: {
exp: ExpoConfig;
projectId: string;
@@ -631,7 +611,6 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
nonInteractive: boolean;
sdkVersion?: string;
env: Env | undefined;
easJsonAccessor: EasJsonAccessor;
}): Promise<void> {
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
return;
@@ -653,9 +632,6 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
});
if (installExpoUpdates) {
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp,
projectId,
@@ -663,7 +639,6 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
platform: RequestedPlatform.All,
vcsClient,
env,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
Log.withTick('Installed expo-updates and configured EAS Update.');
throw new Error('Command must be re-run to pick up new updates configuration.');
+1
View File
@@ -35,4 +35,5 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
}
@@ -6,44 +6,8 @@ import {
BuildFragment,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { link } from '../../log';
import { appPlatformDisplayNames } from '../../platform';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
export function formatGraphQLBuildArtifacts(build: BuildFragment): FormatFieldsItem[] {
const fields = [
{
label: 'Application Archive URL',
get value() {
switch (build.status) {
case GraphQLBuildStatus.InProgress:
return '<in progress>';
default: {
const url = build.artifacts?.buildUrl;
return url ? link(url) : 'null';
}
}
},
},
{
label: 'Build Artifacts URL',
get value() {
switch (build.status) {
case GraphQLBuildStatus.InProgress:
return '<in progress>';
default: {
const url = build.artifacts?.buildArtifactsUrl;
return url ? link(url) : 'null';
}
}
},
},
];
return fields.filter(({ value }) => value !== undefined && value !== null) as {
label: string;
value: string;
}[];
}
import formatFields from '../../utils/formatFields';
export function formatGraphQLBuild(build: BuildFragment): string {
const actor = getActorName(build);
@@ -117,11 +81,10 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Logs',
value: link(getBuildLogsUrl(build)),
value: getBuildLogsUrl(build),
},
...formatGraphQLBuildArtifacts(build),
{
label: 'Fingerprint',
label: 'Artifact',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
@@ -133,8 +96,8 @@ export function formatGraphQLBuild(build: BuildFragment): string {
case GraphQLBuildStatus.Errored:
return null;
case GraphQLBuildStatus.Finished: {
const hash = build.fingerprint?.hash;
return hash ? chalk.green(hash) : chalk.red('not found');
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
default:
return null;
@@ -43,14 +43,14 @@ const errorCodeToErrorMessageOverride: Record<string, (build: BuildFragment) =>
export function printLogsUrls(builds: BuildFragment[]): void {
if (builds.length === 1) {
Log.log(`See logs: ${link(getBuildLogsUrl(builds[0]))}`);
Log.log(`Build details: ${link(getBuildLogsUrl(builds[0]))}`);
} else {
builds.forEach(build => {
const logsUrl = getBuildLogsUrl(build);
Log.log(
`See ${appPlatformEmojis[build.platform]} ${
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} logs: ${link(logsUrl)}`
} build details: ${link(logsUrl)}`
);
});
}
-9
View File
@@ -72,12 +72,3 @@ export function getProjectGitHubSettingsUrl(accountName: string, projectName: st
getExpoWebsiteBaseUrl()
).toString();
}
export function getHostingDeploymentsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/hosting/deployments`,
getExpoWebsiteBaseUrl()
).toString();
}
@@ -18,7 +18,6 @@ export const testUpdateBranch1: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -44,7 +43,6 @@ export const testUpdateBranch1: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -80,7 +78,6 @@ export const testUpdateBranch2: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -106,7 +103,6 @@ export const testUpdateBranch2: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -27,7 +27,7 @@ import {
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import Log, { link } from '../log';
import Log from '../log';
import SessionManager from '../user/SessionManager';
import { Client } from '../vcs/vcs';
@@ -264,16 +264,6 @@ export default abstract class EasCommand extends Command {
const defaultMsg = `${messageLine}${requestIdLine}`;
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
if (defaultMsg.includes('ScopedAccountActorViewerContext') && process.env.EAS_BUILD) {
// We're in EAS, authenticated with a scoped account actor access token.
// We may have not added the scoped actor privacy rule to the right place yet.
return `${chalk.bold(
`You don't have the required permissions to perform this operation.`
)}\n\nWe are in the process of migrating EAS to a more granular permissioning system. If you believe what you're doing is a legitimate operation you should be able to perform, report this to us at ${link(
'https://expo.dev/contact'
)}\n\nOriginal error message: ${defaultMsg}`;
}
return `${chalk.bold(
`You don't have the required permissions to perform this operation.`
)}\n\nThis can sometimes happen if you are logged in as incorrect user.\nRun ${chalk.bold(
@@ -43,7 +43,6 @@ export async function fetchBuildsAsync({
platform?: RequestedPlatform;
profile?: string;
hasFingerprint?: boolean;
fingerprintHash?: string;
};
}): Promise<BuildFragment[]> {
let builds: BuildFragment[];
@@ -57,9 +56,6 @@ export async function fetchBuildsAsync({
if (filters?.hasFingerprint) {
queryFilters['hasFingerprint'] = filters.hasFingerprint;
}
if (filters?.fingerprintHash) {
queryFilters['fingerprintHash'] = filters.fingerprintHash;
}
if (!filters?.statuses) {
builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
@@ -5,7 +5,7 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPublicExpoConfigAsync } from '../../project/expoConfig';
export type GetServerSideEnvironmentVariablesFn = (
type GetServerSideEnvironmentVariablesFn = (
maybeEnv?: Record<string, string>
) => Promise<Record<string, string>>;
@@ -1,10 +1,6 @@
import { vol } from 'memfs';
import { easCliVersion } from '../../../../utils/easCli';
import {
findProjectDirAndVerifyProjectSetupAsync,
findProjectRootAsync,
} from '../findProjectDirAndVerifyProjectSetupAsync';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
jest.mock('@expo/config');
jest.mock('fs');
@@ -64,54 +60,3 @@ describe(findProjectRootAsync, () => {
expect(projectRoot).toBe('/app');
});
});
describe(findProjectDirAndVerifyProjectSetupAsync, () => {
it('throws if the CLI version is not satisfied', async () => {
vol.fromJSON(
{
'./eas.json': JSON.stringify({ cli: { version: '1.0.0' } }),
'./package.json': JSON.stringify({}),
},
'/app'
);
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
new RegExp(
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
's'
)
);
process.env.EAS_SKIP_CLI_VERSION_CHECK = 'true';
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
delete process.env.EAS_SKIP_CLI_VERSION_CHECK;
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
new RegExp(
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
's'
)
);
});
it('does not throw if the CLI version is satisfied', async () => {
vol.fromJSON(
{
'./eas.json': JSON.stringify({ cli: { version: '>= 1.0.0' } }),
'./package.json': JSON.stringify({}),
},
'/app'
);
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
it('does not throw if there is no version', async () => {
vol.fromJSON(
{
'./eas.json': JSON.stringify({}),
'./package.json': JSON.stringify({}),
},
'/app'
);
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
});
@@ -2,7 +2,6 @@ import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs-extra';
import getenv from 'getenv';
import path from 'path';
import pkgDir from 'pkg-dir';
import semver from 'semver';
@@ -14,14 +13,7 @@ import { resolveVcsClient } from '../../../vcs';
async function applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
const shouldSkipVersionCheck = getenv.boolish('EAS_SKIP_CLI_VERSION_CHECK', false);
if (
!shouldSkipVersionCheck &&
config?.version &&
!semver.satisfies(easCliVersion, config.version)
) {
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
throw new Error(
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json (${
config.version
@@ -120,12 +112,8 @@ let ranEnsureEasCliIsNotInDependencies = false;
*
* @deprecated Should not be used outside of context functions.
*/
export async function findProjectDirAndVerifyProjectSetupAsync({
cwd,
}: {
cwd?: string;
} = {}): Promise<string> {
const projectDir = await findProjectRootAsync({ cwd });
export async function findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
const projectDir = await findProjectRootAsync();
await applyCliConfigAsync(projectDir);
if (!ranEnsureEasCliIsNotInDependencies) {
ranEnsureEasCliIsNotInDependencies = true;
@@ -180,7 +180,7 @@ export async function validateOrSetProjectIdAsync({
Log.warn('EAS project not configured.');
const getDefaultAccountNameForEASProject = (exp: ExpoConfig, user: Actor): string => {
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
if (exp.owner) {
return exp.owner;
}
@@ -191,7 +191,7 @@ export async function validateOrSetProjectIdAsync({
return user.username;
case 'Robot':
throw new Error(
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
);
}
};
@@ -199,7 +199,7 @@ export async function validateOrSetProjectIdAsync({
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
graphqlClient,
{
accountName: getDefaultAccountNameForEASProject(exp, actor),
accountName: getAccountNameForEASProjectSync(exp, actor),
projectName: exp.slug,
},
{
@@ -1,15 +0,0 @@
import { WorkflowJobResult } from './types';
// This function is in a separate module for testing purposes
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
}
@@ -1,324 +0,0 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processLogsFromJobAsync,
processWorkflowRuns,
} from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
/*
* State machine types and functions for moving between different workflow command states
*/
export enum WorkflowCommandSelectionStateValue {
WORKFLOW_RUN_SELECTION = 'WORKFLOW_RUN_SELECTION',
WORKFLOW_JOB_SELECTION = 'WORKFLOW_JOB_SELECTION',
WORKFLOW_STEP_SELECTION = 'WORKFLOW_STEP_SELECTION',
FINISH = 'FINISH',
ERROR = 'ERROR',
}
const workflowCommandSelectionAllowedStateTransitions: {
[key in WorkflowCommandSelectionStateValue]: WorkflowCommandSelectionStateValue[];
} = {
WORKFLOW_JOB_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
WorkflowCommandSelectionStateValue.FINISH,
],
WORKFLOW_RUN_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
],
WORKFLOW_STEP_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
WorkflowCommandSelectionStateValue.FINISH,
],
ERROR: [WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION],
FINISH: [],
};
export type WorkflowCommandSelectionState = {
graphqlClient: ExpoGraphqlClient;
projectId: string;
nonInteractive: boolean;
allSteps: boolean;
state: WorkflowCommandSelectionStateValue;
runId?: string;
jobId?: string;
step?: string;
job?: WorkflowJobResult;
logs?: WorkflowLogs | null;
message?: string;
};
export type WorkflowCommandSelectionStateParameters = Omit<
WorkflowCommandSelectionState,
'graphqlClient' | 'projectId' | 'state' | 'nonInteractive' | 'allSteps'
>;
export type WorkflowCommandSelectionAction = (
prevState: WorkflowCommandSelectionState
) => Promise<WorkflowCommandSelectionState>;
export function moveToNewWorkflowCommandSelectionState(
previousState: WorkflowCommandSelectionState,
newStateValue: WorkflowCommandSelectionStateValue,
parameters: WorkflowCommandSelectionStateParameters
): WorkflowCommandSelectionState {
if (
!workflowCommandSelectionAllowedStateTransitions[previousState.state].includes(newStateValue)
) {
const errorMessage = `Invalid state transition from ${
previousState.state
} to ${newStateValue}. Allowed transitions from ${
previousState.state
}: ${workflowCommandSelectionAllowedStateTransitions[previousState.state].join(', ')}`;
throw new Error(errorMessage);
}
return {
...previousState,
state: newStateValue,
...parameters,
};
}
export function moveToWorkflowRunSelectionState(
previousState: WorkflowCommandSelectionState,
params?: {
runId?: string | undefined;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
{
runId: params?.runId,
jobId: undefined,
}
);
}
export function moveToWorkflowJobSelectionState(
previousState: WorkflowCommandSelectionState,
params: {
jobId?: string | undefined;
runId?: string | undefined;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
params
);
}
export function moveToWorkflowStepSelectionState(
previousState: WorkflowCommandSelectionState,
params: {
job: WorkflowJobResult;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
params
);
}
export function moveToWorkflowSelectionFinishedState(
previousState: WorkflowCommandSelectionState,
params: {
step: string;
logs: WorkflowLogs;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.FINISH,
params
);
}
export function moveToWorkflowSelectionErrorState(
previousState: WorkflowCommandSelectionState,
message: string
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.ERROR,
{
message,
}
);
}
// eslint-disable-next-line async-protect/async-suffix
export const workflowRunSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { graphqlClient, projectId, runId, jobId, allSteps } = prevState;
Log.debug(
`workflowRunSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
);
if (runId) {
return moveToWorkflowJobSelectionState(prevState, { runId });
}
const runs = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
undefined,
20
);
if (runs.length === 0) {
return moveToWorkflowSelectionErrorState(prevState, 'No workflow runs found');
}
const processedRuns = processWorkflowRuns(runs);
const choices = processedRuns.map(run => choiceFromWorkflowRun(run));
const selectedId = (
await promptAsync({
type: 'select',
name: 'selectedRun',
message: 'Select a workflow run:',
choices,
})
).selectedRun;
return moveToWorkflowJobSelectionState(prevState, { runId: selectedId });
};
// eslint-disable-next-line async-protect/async-suffix
export const workflowJobSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { graphqlClient, runId, jobId, nonInteractive, allSteps } = prevState;
Log.debug(
`workflowJobSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
);
if (jobId) {
let workflowJobResult = undefined;
try {
workflowJobResult = await WorkflowJobQuery.byIdAsync(graphqlClient, jobId, {
useCache: false,
});
return moveToWorkflowStepSelectionState(prevState, { job: workflowJobResult });
} catch {}
if (nonInteractive && !workflowJobResult) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow job found that matched the provided ID'
);
} else {
// The passed in ID may be a run ID, pass it back to run selection action
return moveToWorkflowRunSelectionState(prevState, { runId: jobId });
}
} else {
// No job ID was passed in
if (nonInteractive) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow job ID provided in non-interactive mode'
);
} else if (!runId) {
// If no jobId or runId, we should go back to run selection
return moveToWorkflowRunSelectionState(prevState);
}
const workflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, runId, {
useCache: false,
});
if (!workflowRunResult) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow run found that matched the provided ID'
);
}
const choices: Choice[] = [
...workflowRunResult.jobs.map((job, i) => choiceFromWorkflowJob(job, i)),
{
title: 'Go back and select a different workflow run',
value: -1,
},
];
const selectedJobIndex = (
await promptAsync({
type: 'select',
name: 'selectedJob',
message: 'Select a job:',
choices,
})
).selectedJob;
if (selectedJobIndex === -1) {
return moveToWorkflowRunSelectionState(prevState);
}
const selectedJob = workflowRunResult.jobs[selectedJobIndex] as WorkflowJobResult;
return moveToWorkflowStepSelectionState(prevState, { job: selectedJob });
}
};
// eslint-disable-next-line async-protect/async-suffix
export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { job, allSteps } = prevState;
Log.debug(`workflowStepSelectionAction: job = ${job?.id}, allSteps = ${allSteps}`);
if (!job) {
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
}
const logs = await processLogsFromJobAsync(job);
if (!logs) {
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
}
if (allSteps) {
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
}
const choices: Choice[] = [
...choicesFromWorkflowLogs(logs),
{
title: 'Go back and select a different workflow job',
value: 'go-back',
},
];
const selectedStep: string =
(
await promptAsync({
type: 'select',
name: 'selectedStep',
message: 'Select a step:',
choices,
})
).selectedStep ?? '';
if (selectedStep === 'go-back') {
return moveToWorkflowJobSelectionState(prevState, {
runId: job.workflowRun.id,
jobId: undefined,
});
}
return moveToWorkflowSelectionFinishedState(prevState, { step: selectedStep, logs });
};
export const executeWorkflowSelectionActionsAsync: WorkflowCommandSelectionAction =
async prevState => {
let currentState = prevState;
while (
currentState.state !== WorkflowCommandSelectionStateValue.FINISH &&
currentState.state !== WorkflowCommandSelectionStateValue.ERROR
) {
Log.debug(`${currentState.state}`);
switch (currentState.state) {
case WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION:
currentState = await workflowJobSelectionAction(currentState);
break;
case WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION:
currentState = await workflowRunSelectionAction(currentState);
break;
case WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION:
currentState = await workflowStepSelectionAction(currentState);
break;
}
}
return currentState;
};
@@ -1,46 +0,0 @@
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
/*
* Utility types for workflow commands
*/
export enum WorkflowTriggerType {
MANUAL = 'Manual',
GITHUB = 'GitHub',
SCHEDULED = 'Scheduled',
OTHER = 'Other',
}
export type WorkflowRunResult = {
id: string;
status: string;
gitCommitMessage: string | null;
gitCommitHash: string | null;
triggerType: WorkflowTriggerType;
trigger: string | null;
startedAt: string;
finishedAt: string;
workflowId: string;
workflowName: string | null;
workflowFileName: string;
};
export type WorkflowResult = {
id: string;
name?: string | null | undefined;
fileName: string;
createdAt: string;
updatedAt: string;
};
export type WorkflowJobResult =
| WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number]
| WorkflowJobByIdQuery['workflowJobs']['byId'];
export type WorkflowRunWithJobsResult = WorkflowRunResult & {
jobs: WorkflowJobResult[];
logs?: string;
};
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
@@ -1,128 +0,0 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
WorkflowLogs,
WorkflowRunResult,
WorkflowTriggerType,
} from './types';
import {
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { Choice } from '../../prompts';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
trigger: string | null;
} {
let triggerType = WorkflowTriggerType.OTHER;
let trigger = '';
if (run.actor?.__typename === 'Robot') {
if (run.actor.firstName?.startsWith('GitHub App · ')) {
trigger = `${run.requestedGitRef ?? ''}@${run.gitCommitHash?.substring(0, 12) ?? ''}`;
}
} else if (run.actor?.__typename === 'User') {
trigger = run.actor.username;
}
switch (run.triggerEventType) {
case WorkflowRunTriggerEventType.Manual:
triggerType = WorkflowTriggerType.MANUAL;
break;
case WorkflowRunTriggerEventType.GithubPullRequestLabeled:
case WorkflowRunTriggerEventType.GithubPullRequestOpened:
case WorkflowRunTriggerEventType.GithubPullRequestReopened:
case WorkflowRunTriggerEventType.GithubPullRequestSynchronize:
case WorkflowRunTriggerEventType.GithubPush:
triggerType = WorkflowTriggerType.GITHUB;
break;
case WorkflowRunTriggerEventType.Schedule:
triggerType = WorkflowTriggerType.SCHEDULED;
trigger = run.triggeringSchedule ?? '';
break;
}
return { triggerType, trigger };
}
export function choiceFromWorkflowRun(run: WorkflowRunResult): Choice {
const titleArray = [
run.workflowFileName,
run.status,
run.startedAt,
run.triggerType,
run.trigger,
];
return {
title: titleArray.join(' - '),
value: run.id,
description: `ID: ${run.id}, Message: ${run.gitCommitMessage?.split('\n')[0] ?? ''}`,
};
}
export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Choice {
return {
title: `${job.name} - ${job.status}`,
value: index,
description: `ID: ${job.id}`,
};
}
export function choicesFromWorkflowLogs(logs: WorkflowLogs): Choice[] {
return Array.from(logs.keys()).map(step => {
const logLines = logs.get(step);
const stepStatus =
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
return {
title: `${step} - ${stepStatus}`,
value: step,
};
});
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
return {
id: run.id,
status: run.status,
gitCommitMessage: run.gitCommitMessage?.split('\n')[0] ?? null,
gitCommitHash: run.gitCommitHash ?? null,
startedAt: run.createdAt,
finishedAt,
triggerType,
trigger,
workflowId: run.workflow.id,
workflowName: run.workflow.name ?? null,
workflowFileName: run.workflow.fileName,
};
});
}
export async function processLogsFromJobAsync(
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const rawLogs = await fetchRawLogsForJobAsync(job);
if (!rawLogs) {
return null;
}
const logs: WorkflowLogs = new Map();
const logKeys = new Set<string>();
rawLogs.split('\n').forEach(line => {
try {
const parsedLine = JSON.parse(line);
const { result, marker } = parsedLine;
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
const stepId = buildStepDisplayName ?? buildStepInternalId;
if (stepId) {
if (!logKeys.has(stepId)) {
logKeys.add(stepId);
logs.set(stepId, []);
}
logs.get(stepId)?.push({ time, msg, result, marker });
}
} catch {}
});
return logs;
}
@@ -1,209 +0,0 @@
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/errors';
import { CombinedError } from '@urql/core';
import { promises as fs } from 'fs';
import path from 'path';
import * as YAML from 'yaml';
import { getExpoApiWorkflowSchemaURL } from '../../api';
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
import Log from '../../log';
import { createValidator, getReadableErrors } from '../../metadata/utils/ajv';
import { WorkflowFile } from '../../utils/workflowFile';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
const buildProfileIsInterpolated = (profileName: string): boolean => {
return profileName.includes('${{') && profileName.includes('}}');
};
export async function validateWorkflowFileAsync(
workflowFileContents: { yamlConfig: string; filePath: string },
projectDir: string,
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<void> {
const parsedYaml = parsedYamlFromWorkflowContents(workflowFileContents);
Log.debug(`Parsed workflow: ${JSON.stringify(parsedYaml, null, 2)}`);
// Check if the parsed result is empty or null
Log.debug(`Validating workflow is not empty...`);
validateWorkflowIsNotEmpty(parsedYaml);
const workflowSchema = await fetchWorkflowSchemaAsync();
// Check that all job types are valid
Log.debug(`Validating workflow job types...`);
validateWorkflowJobTypes(parsedYaml, workflowSchema);
// Check for build jobs that do not match any EAS build profiles
Log.debug(`Validating workflow build jobs...`);
await validateWorkflowBuildJobsAsync(parsedYaml, projectDir);
// Check that result passes validation against workflow schema
Log.debug(`Validating workflow structure...`);
validateWorkflowStructure(parsedYaml, workflowSchema);
// Check for other errors using the server-side validation
Log.debug(`Validating workflow on server...`);
await validateWorkflowOnServerAsync(graphqlClient, projectId, workflowFileContents);
}
export function logWorkflowValidationErrors(error: unknown): void {
if (error instanceof MissingEasJsonError) {
throw new Error(
'Workflows require a valid eas.json. Please run "eas build:configure" to create it.'
);
} else if (error instanceof InvalidEasJsonError) {
throw new Error(
'Workflows require a valid eas.json. Please fix the errors in your eas.json and try again.\n\n' +
error.message
);
} else if (error instanceof YAML.YAMLParseError) {
Log.error(`YAML syntax error: ${error.message}`);
} else if (error instanceof CombinedError) {
WorkflowFile.maybePrintWorkflowFileValidationErrors({
error,
});
throw error;
} else if (error instanceof Error) {
Log.error(`Error: ${error.message}`);
} else {
Log.error(`Unexpected error: ${String(error)}`);
}
}
function validateWorkflowIsNotEmpty(parsedYaml: any): void {
if (
parsedYaml === null ||
parsedYaml === undefined ||
(typeof parsedYaml === 'object' && Object.keys(parsedYaml).length === 0)
) {
throw new Error('YAML file is empty or contains only comments.');
}
}
async function validateWorkflowOnServerAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
workflowFileContents: { yamlConfig: string }
): Promise<void> {
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
appId: projectId,
yamlConfig: workflowFileContents.yamlConfig,
});
}
async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: string): Promise<void> {
const jobs = jobsFromWorkflow(parsedYaml);
const buildJobs = jobs.filter(job => jobTypesWithBuildProfile.has(job.value.type));
if (buildJobs.length === 0) {
return;
}
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfileNames = new Set(
easJsonAccessor && (await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor))
);
const invalidBuildJobs = buildJobs.filter(
job =>
!buildProfileNames.has(job.value.params.profile) &&
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
!buildProfileIsInterpolated(job.value.params.profile)
);
if (invalidBuildJobs.length > 0) {
const invalidBuildProfiles = new Set(invalidBuildJobs.map(job => job.value.params.profile));
throw new Error(
`The build jobs in this workflow refer to the following build profiles that are not present in your eas.json file: ${[
...invalidBuildProfiles,
].join(', ')}`
);
}
}
function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): void {
const jobs = jobsFromWorkflow(parsedYaml);
const jobTypes = jobTypesFromWorkflowSchema(workflowJsonSchema);
const invalidJobs = jobs.filter(job => job.value.type && !jobTypes.includes(job.value.type));
if (invalidJobs.length > 0) {
throw new Error(
`The following jobs have invalid types: ${invalidJobs
.map(job => job.key)
.join(', ')}. Valid types are: ${jobTypes.join(', ')}`
);
}
}
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
delete workflowJsonSchema['$schema'];
const ajv = createValidator();
const validate = ajv.compile(workflowJsonSchema);
const result = validate(parsedYaml);
if (!result) {
Log.debug(
JSON.stringify(
{
errors: validate.errors,
},
null,
2
)
);
const readableErrors = getReadableErrors(validate.errors ?? []);
const processedErrors = new Set<string>();
for (const err of readableErrors) {
if (err.message) {
processedErrors.add(err.message);
}
}
throw new Error([...processedErrors].join('\n'));
}
}
function parsedYamlFromWorkflowContents(workflowFileContents: {
yamlConfig: string;
}): Promise<any> {
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
return parsedYaml;
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async function fetchWorkflowSchemaAsync(): Promise<any> {
// EXPO_TESTING_WORKFLOW_SCHEMA_PATH is used only for testing against a different schema
if (process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH) {
const schemaPath = path.resolve(process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH);
Log.debug(`Loading workflow schema from ${schemaPath}`);
const jsonString = await fs.readFile(schemaPath, 'utf-8');
const jsonFromFile = JSON.parse(jsonString);
return jsonFromFile.data;
}
// Otherwise, we fetch from <ApiBaseUrl>/v2/workflows/schema
const schemaUrl = getExpoApiWorkflowSchemaURL();
Log.debug(`Fetching workflow schema from ${schemaUrl}`);
const response = await fetch(schemaUrl);
if (!response.ok) {
throw new Error(`Unable to fetch EAS Workflow schema, received status: ${response.status}`);
}
const jsonResponse: any = (await response.json()) as any;
return jsonResponse.data;
}
function jobsFromWorkflow(parsedYaml: any): any[] {
return Object.entries(parsedYaml?.jobs).flatMap(([key, value]: [string, any]) => {
return {
key,
value,
};
});
}
function jobTypesFromWorkflowSchema(workflowJsonSchema: any): string[] {
return workflowJsonSchema?.properties?.jobs?.additionalProperties?.anyOf.map(
(props: any) => props.properties.type.const
);
}
@@ -1,5 +1,4 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -10,10 +9,10 @@ import Log, { learnMore } from '../../log';
import { RequestedPlatform } from '../../platform';
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
import { ensureEASUpdateIsConfiguredInEasJsonAsync } from '../../update/configure';
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
import { promptPlatformAsync } from '../../utils/prompts';
export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
@@ -67,13 +66,8 @@ export default class BuildConfigure extends EasCommand {
nonInteractive: false,
vcsClient,
});
if (didCreateEasJson) {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
if (isUsingEASUpdate(exp, projectId, easJsonCliConfig.updateManifestHostOverride ?? null)) {
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
}
if (didCreateEasJson && isUsingEASUpdate(exp, projectId)) {
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
}
// configure expo-updates
@@ -113,7 +107,24 @@ export default class BuildConfigure extends EasCommand {
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
Log.addNewLineIfNone();
return await promptPlatformAsync({
const { platform } = await promptAsync({
type: 'select',
message: 'Which platforms would you like to configure for EAS Build?',
name: 'platform',
choices: [
{
title: 'All',
value: RequestedPlatform.All,
},
{
title: 'iOS',
value: RequestedPlatform.Ios,
},
{
title: 'Android',
value: RequestedPlatform.Android,
},
],
});
return platform;
}
+2 -6
View File
@@ -13,7 +13,6 @@ import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuil
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { createFingerprintAsync } from '../../fingerprint/cli';
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
@@ -22,6 +21,7 @@ import { RequestedPlatform } from '../../platform';
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync, promptAsync } from '../../prompts';
import { expoCommandAsync } from '../../utils/expoCli';
import { createFingerprintAsync } from '../../utils/fingerprintCli';
import { ProfileData, getProfilesAsync } from '../../utils/profiles';
import { Client } from '../../vcs/vcs';
@@ -115,7 +115,6 @@ export default class BuildDev extends EasCommand {
projectId,
platform,
fingerprint,
profile: buildProfile.profileName,
});
if (builds.length !== 0) {
const build = builds[0];
@@ -138,7 +137,6 @@ export default class BuildDev extends EasCommand {
graphqlClient,
projectId,
platform,
profile: buildProfile.profileName,
});
if (
previousBuildsForSelectedProfile.length > 0 &&
@@ -166,6 +164,7 @@ export default class BuildDev extends EasCommand {
json: false,
autoSubmit: false,
localBuildOptions: {},
repack: false,
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
},
actor,
@@ -309,13 +308,11 @@ export default class BuildDev extends EasCommand {
projectId,
platform,
fingerprint,
profile,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
platform: Platform;
fingerprint?: { hash: string };
profile?: string;
}): Promise<BuildFragment[]> {
return await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
@@ -326,7 +323,6 @@ export default class BuildDev extends EasCommand {
simulator: platform === Platform.IOS ? true : undefined,
distribution: platform === Platform.ANDROID ? DistributionType.Internal : undefined,
developmentClient: true,
buildProfile: profile,
},
offset: 0,
limit: 1,
@@ -1,166 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import { pathExists } from 'fs-extra';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getEasBuildRunCachedAppPath } from '../../run/run';
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class Download extends EasCommand {
static override description = 'download simulator/emulator builds for a given fingerprint hash';
static override flags = {
fingerprint: Flags.string({
description: 'Fingerprint hash of the build to download',
required: true,
}),
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectId,
};
async runAsync(): Promise<void> {
const {
flags: {
json: jsonFlag,
platform,
fingerprint,
'dev-client': developmentClient,
'non-interactive': nonInteractive,
},
} = await this.parse(Download);
const {
loggedIn: { graphqlClient },
projectId,
} = await this.getContextAsync(Download, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
const build = await this.getBuildAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint,
developmentClient,
});
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
if (jsonFlag) {
const jsonResults = { path: buildArtifactPath };
printJsonOnlyOutput(jsonResults);
} else {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
}
}
private async getBuildAsync({
graphqlClient,
projectId,
platform,
fingerprintHash,
developmentClient,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
platform: AppPlatform;
fingerprintHash: string;
developmentClient: boolean;
}): Promise<BuildFragment> {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
filter: {
platform,
fingerprintHash,
status: BuildStatus.Finished,
simulator: platform === AppPlatform.Ios ? true : undefined,
distribution: platform === AppPlatform.Android ? DistributionType.Internal : undefined,
developmentClient,
},
offset: 0,
limit: 1,
});
if (builds.length === 0) {
throw new Errors.CLIError(
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
);
}
Log.succeed(`🎯 Found successful build with matching fingerprint on EAS servers.`);
return builds[0];
}
async getPathToBuildArtifactAsync(build: BuildFragment, platform: AppPlatform): Promise<string> {
const cachedBuildArtifactPath = getEasBuildRunCachedAppPath(
build.project.id,
build.id,
platform
);
if (await pathExists(cachedBuildArtifactPath)) {
Log.newLine();
Log.log(`Using cached build...`);
return cachedBuildArtifactPath;
}
if (!build.artifacts?.applicationArchiveUrl) {
throw new Error('Build does not have an application archive url');
}
return await downloadAndMaybeExtractAppAsync(
build.artifacts.applicationArchiveUrl,
platform,
cachedBuildArtifactPath
);
}
}
async function resolvePlatformAsync({
nonInteractive,
platform,
}: {
nonInteractive: boolean;
platform?: Platform;
}): Promise<AppPlatform> {
if (nonInteractive && !platform) {
throw new Error('Platform must be provided in non-interactive mode');
}
if (platform) {
return toAppPlatform(platform);
}
const { selectedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'selectedPlatform',
choices: [
{ title: 'Android', value: AppPlatform.Android },
{ title: 'iOS', value: AppPlatform.Ios },
],
});
return selectedPlatform;
}
+8 -71
View File
@@ -1,5 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
import { EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
import { LoggerLevel } from '@expo/logger';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -17,7 +17,6 @@ import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform'
import { selectAsync } from '../../prompts';
import uniq from '../../utils/expodash/uniq';
import { enableJsonOutput } from '../../utils/json';
import { ProfileData } from '../../utils/profiles';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
interface RawBuildFlags {
@@ -37,8 +36,7 @@ interface RawBuildFlags {
message?: string;
'build-logger-level'?: LoggerLevel;
'freeze-credentials': boolean;
'verbose-logs'?: boolean;
'what-to-test'?: string;
repack: boolean;
}
export default class Build extends EasCommand {
@@ -92,10 +90,6 @@ export default class Build extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'what-to-test': Flags.string({
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({
options: Object.values(ResourceClass),
hidden: true,
@@ -118,9 +112,10 @@ export default class Build extends EasCommand {
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
}),
'verbose-logs': Flags.boolean({
repack: Flags.boolean({
default: false,
description: 'Use verbose logs for the build process',
hidden: true,
description: 'Use the golden dev client build repack flow as it works for onboarding',
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -165,7 +160,7 @@ export default class Build extends EasCommand {
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
const { buildProfiles } = await runBuildAndSubmitAsync({
await runBuildAndSubmitAsync({
graphqlClient,
analytics,
vcsClient,
@@ -174,11 +169,6 @@ export default class Build extends EasCommand {
actor,
getDynamicPrivateProjectConfigAsync,
});
this.maybeSuggestUsingEasBuildDev({
buildProfiles,
nonInteractive: flags.nonInteractive,
});
}
private sanitizeFlags(
@@ -194,13 +184,6 @@ export default class Build extends EasCommand {
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(
'The --what-to-test flag can only be used with the --auto-submit or --auto-submit-with-profile flag.',
{ exit: 1 }
);
}
const requestedPlatform =
flags.platform &&
@@ -243,14 +226,13 @@ export default class Build extends EasCommand {
wait: flags['wait'],
clearCache: flags['clear-cache'],
json: flags['json'],
autoSubmit,
autoSubmit: flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
resourceClass: flags['resource-class'],
message,
buildLoggerLevel: flags['build-logger-level'],
freezeCredentials: flags['freeze-credentials'],
isVerboseLoggingEnabled: flags['verbose-logs'],
whatToTest: flags['what-to-test'],
repack: flags.repack,
};
}
@@ -286,51 +268,6 @@ export default class Build extends EasCommand {
requestedPlatform,
};
}
private maybeSuggestUsingEasBuildDev({
buildProfiles,
nonInteractive,
}: {
buildProfiles: ProfileData<'build'>[] | undefined;
nonInteractive: boolean;
}): void {
// suggest using eas build:dev if the build configuration results in simulator/emulator dev client build
if (
!nonInteractive &&
!process.env.CI &&
buildProfiles?.some(({ profile, platform }) => {
if (profile.developmentClient !== true) {
return false;
}
if (profile.distribution !== 'internal') {
return false;
}
if (platform === Platform.IOS) {
const iosProfile = profile as BuildProfile<Platform.IOS>;
if (iosProfile.simulator !== true && iosProfile.withoutCredentials !== true) {
return false;
}
} else {
const androidProfile = profile as BuildProfile<Platform.ANDROID>;
if (
androidProfile.distribution !== 'internal' &&
androidProfile.withoutCredentials !== true
) {
return false;
}
}
return true;
})
) {
Log.newLine();
Log.log(
`🔎 TIP: You are using a build configuration that could benefit from using ${chalk.bold(
'eas build:dev'
)} command. Run it to install and run cached development build, or create a new one if a compatible build doesn't exist yet.`
);
}
}
}
export async function handleDeprecatedEasJsonAsync(
@@ -121,6 +121,7 @@ export default class BuildInspect extends EasCommand {
workingdir: tmpWorkingdir,
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
},
repack: false,
},
actor,
getDynamicPrivateProjectConfigAsync,
@@ -93,6 +93,7 @@ export default class BuildInternal extends EasCommand {
localBuildMode: LocalBuildMode.INTERNAL,
},
submitProfile: flags['auto-submit-with-profile'] ?? flags.profile,
repack: false,
},
actor,
getDynamicPrivateProjectConfigAsync,
@@ -16,6 +16,7 @@ import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelDelete extends EasCommand {
static override hidden = true;
static override description = 'Delete a channel';
static override args = [
@@ -1,23 +1,20 @@
import { Flags } 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 { WorkerDeploymentAliasFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../graphql/generated';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import {
assignWorkerDeploymentAliasAsync,
assignWorkerDeploymentProductionAsync,
selectWorkerDeploymentOnAppAsync,
} from '../../../worker/deployment';
import {
formatWorkerDeploymentJson,
formatWorkerDeploymentTable,
} from '../../../worker/utils/logs';
} from '../../worker/deployment';
import { formatWorkerDeploymentJson, formatWorkerDeploymentTable } from '../../worker/utils/logs';
interface DeployAliasFlags {
nonInteractive: boolean;
@@ -1,142 +0,0 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
deleteWorkerDeploymentAliasAsync,
selectWorkerDeploymentAliasOnAppAsync,
} from '../../../worker/deployment';
interface DeployAliasDeleteFlags {
nonInteractive: boolean;
json: boolean;
}
export default class WorkerAliasDelete extends EasCommand {
static override description = 'Delete deployment aliases.';
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = [{ name: 'ALIAS_NAME' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
override async runAsync(): Promise<void> {
const {
args: { ALIAS_NAME: aliasNameFromArg },
flags: rawFlags,
} = await this.parse(WorkerAliasDelete);
const flags = this.sanitizeFlags(rawFlags);
if (flags.json) {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerAliasDelete, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
const aliasName = await resolveDeploymentAliasAsync(
flags,
graphqlClient,
projectId,
aliasNameFromArg
);
const isProduction = !aliasName;
if (!flags.nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
isProduction
? `You are about to delete your production alias`
: `You are about to delete alias: "${aliasName}"`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.log('Aborted.');
return;
}
}
let progress: null | Ora = null;
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAliasAsync>> = null;
try {
progress = ora(chalk`Deleting alias {bold ${aliasName ?? 'production'}}`).start();
deleteResult = await deleteWorkerDeploymentAliasAsync({
graphqlClient,
appId: projectId,
aliasName,
});
progress.text = chalk`Deleted alias {bold ${aliasName ?? 'production'}}`;
} catch (error: any) {
progress?.fail(chalk`Failed to delete alias {bold ${aliasName ?? 'production'}}`);
throw error;
}
progress?.succeed(chalk`Deleted alias {bold ${aliasName ?? 'production'}}`);
if (flags.json) {
printJsonOnlyOutput({
aliasName: deleteResult.aliasName,
id: deleteResult.id,
});
}
}
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
async function resolveDeploymentAliasAsync(
flags: DeployAliasDeleteFlags,
graphqlClient: ExpoGraphqlClient,
projectId: string,
aliasNameFromArg?: string
): Promise<string> {
if (aliasNameFromArg?.trim()) {
return aliasNameFromArg.trim().toLowerCase();
}
if (flags.nonInteractive) {
throw new Error('Alias name must be provided in non-interactive mode');
}
const alias = await selectWorkerDeploymentAliasOnAppAsync({
graphqlClient,
appId: projectId,
selectTitle: 'alias to delete',
});
if (!alias) {
throw new Error('No aliases found for this project, create aliases with "eas deploy:alias"');
}
return alias.aliasName;
}
@@ -1,114 +0,0 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { deleteWorkerDeploymentAsync } from '../../worker/deployment';
interface DeployDeleteFlags {
nonInteractive: boolean;
json: boolean;
}
interface RawDeployDeleteFlags {
'non-interactive': boolean;
json: boolean;
}
export default class WorkerDelete extends EasCommand {
static override description = 'Delete a deployment.';
static override aliases = ['worker:delete'];
static override state = 'preview';
static override args = [{ name: 'DEPLOYMENT_ID' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
override async runAsync(): Promise<void> {
const {
args: { DEPLOYMENT_ID: deploymentIdFromArg },
flags: rawFlags,
} = await this.parse(WorkerDelete);
const flags = this.sanitizeFlags(rawFlags);
if (flags.json) {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerDelete, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
if (!deploymentIdFromArg) {
if (flags.nonInteractive) {
throw new Error('Deployment ID must be provided in non-interactive mode');
}
throw new Error('Deployment ID is required');
}
if (!flags.nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete deployment with ID: "${deploymentIdFromArg}"` +
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.log('Aborted.');
return;
}
}
let progress: Ora | null = null;
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAsync>> = null;
try {
progress = ora(chalk`Deleting deployment {bold ${deploymentIdFromArg}}`).start();
deleteResult = await deleteWorkerDeploymentAsync({
graphqlClient,
appId: projectId,
deploymentIdentifier: deploymentIdFromArg,
});
progress.text = chalk`Deleted deployment {bold ${deploymentIdFromArg}}`;
} catch (error: any) {
progress?.fail(chalk`Failed to delete deployment {bold ${deploymentIdFromArg}}`);
throw error;
}
progress?.succeed(chalk`Deleted deployment {bold ${deploymentIdFromArg}}`);
if (flags.json) {
printJsonOnlyOutput({
deploymentId: deleteResult.deploymentIdentifier,
id: deleteResult.id,
});
}
}
private sanitizeFlags(flags: RawDeployDeleteFlags): DeployDeleteFlags {
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
+59 -105
View File
@@ -1,18 +1,16 @@
import { format as formatTimeAgo } from '@expo/timeago.js';
import { Flags } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import fs from 'node:fs';
import * as path from 'node:path';
import { getHostingDeploymentsUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import Log, { link } from '../../log';
import Log from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { createProgressTracker } from '../../utils/progress';
import * as WorkerAssets from '../../worker/assets';
import {
assignWorkerDeploymentAliasAsync,
@@ -20,10 +18,9 @@ import {
getSignedDeploymentUrlAsync,
} from '../../worker/deployment';
import {
UploadPayload,
UploadParams,
batchUploadAsync,
callUploadApiAsync,
createProgressBar,
uploadAsync,
} from '../../worker/upload';
import {
@@ -32,8 +29,6 @@ import {
getDeploymentUrlFromFullName,
} from '../../worker/utils/logs';
const MAX_UPLOAD_SIZE = 5e8; // 500MB
const isDirectory = (directoryPath: string): Promise<boolean> =>
fs.promises
.stat(directoryPath)
@@ -62,20 +57,15 @@ interface RawDeployFlags {
'dry-run': boolean;
}
interface UploadAssetBatchInstruction {
sha512: string[];
}
interface DeployInProgressParams {
id: string;
fullName: string;
baseURL: string;
token: string;
upload?: UploadAssetBatchInstruction[];
}
export default class WorkerDeploy extends EasCommand {
static override description = 'deploy your Expo Router web build and API Routes';
static override description = 'Deploy your Expo Router web build and API Routes.';
static override aliases = ['worker:deploy'];
static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`];
static override state = 'preview';
@@ -130,10 +120,7 @@ export default class WorkerDeploy extends EasCommand {
} = await this.getContextAsync(WorkerDeploy, { ...flags, withServerSideEnvironment: null });
const projectDist = await resolveExportedProjectAsync(flags, projectDir);
const { projectId, exp } = await getDynamicPrivateProjectConfigAsync();
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const { projectId } = await getDynamicPrivateProjectConfigAsync();
logExportedProjectInfo(projectDist);
@@ -169,14 +156,14 @@ export default class WorkerDeploy extends EasCommand {
tarPath: string,
uploadUrl: string
): Promise<DeployInProgressParams> {
const payload = { filePath: tarPath };
const { response } = await uploadAsync(
{
baseURL: uploadUrl,
method: 'POST',
const { response } = await uploadAsync({
url: uploadUrl,
filePath: tarPath,
compress: false,
headers: {
accept: 'application/json',
},
payload
);
});
if (response.status === 413) {
throw new Error(
'Upload failed! (Payload too large)\n' +
@@ -192,79 +179,73 @@ export default class WorkerDeploy extends EasCommand {
if (!json.success || !json.result || typeof json.result !== 'object') {
throw new Error(json.message ? `Upload failed: ${json.message}` : 'Upload failed!');
}
const { id, fullName, token, upload } = json.result;
const { id, fullName, token } = json.result;
if (typeof token !== 'string') {
throw new Error('Upload failed: API failed to return a deployment token');
} else if (typeof id !== 'string') {
throw new Error('Upload failed: API failed to return a deployment identifier');
} else if (typeof fullName !== 'string') {
throw new Error('Upload failed: API failed to return a script name');
} else if (!Array.isArray(upload) && upload !== undefined) {
throw new Error('Upload failed: API returned invalid asset upload instructions');
}
const baseURL = new URL('/', uploadUrl).toString();
return { id, fullName, baseURL, token, upload };
return { id, fullName, baseURL, token };
}
}
async function uploadAssetsAsync(
assetFiles: WorkerAssets.AssetFileEntry[],
assetMap: WorkerAssets.AssetMap,
deployParams: DeployInProgressParams
): Promise<void> {
const baseURL = new URL('/asset/', deployParams.baseURL);
const uploadInit = { baseURL, method: 'POST' };
uploadInit.baseURL.searchParams.set('token', deployParams.token);
const uploadPayloads: UploadPayload[] = [];
if (deployParams.upload) {
const assetsBySHA512 = assetFiles.reduce((map, asset) => {
map.set(asset.sha512, asset);
return map;
}, new Map<string, WorkerAssets.AssetFileEntry>());
const payloads = deployParams.upload
.map(instruction =>
instruction.sha512.map(sha512 => {
const asset = assetsBySHA512.get(sha512);
if (!asset) {
// NOTE(@kitten): This should never happen
throw new Error(
`Uploading assets failed: API instructed us to upload an asset that does not exist`
);
}
return asset;
})
)
.filter(assets => assets && assets.length > 0)
.map(assets => (assets.length > 1 ? { multipart: assets } : { asset: assets[0] }));
uploadPayloads.push(...payloads);
} else {
// NOTE(@kitten): Legacy format which uploads assets one-by-one
uploadPayloads.push(...assetFiles.map(asset => ({ asset })));
const uploadParams: UploadParams[] = [];
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
if (!assetPath) {
return;
}
const progressTotal = uploadPayloads.reduce(
(acc, payload) => acc + ('multipart' in payload ? payload.multipart.length : 1),
0
);
const progressTracker = createProgressBar(`Uploading ${progressTotal} assets`);
for await (const asset of WorkerAssets.listAssetMapFilesAsync(assetPath, assetMap)) {
const uploadURL = new URL(`/asset/${asset.sha512}`, deployParams.baseURL);
uploadURL.searchParams.set('token', deployParams.token);
uploadParams.push({ url: uploadURL.toString(), filePath: asset.path });
}
const progress = {
total: uploadParams.length,
pending: 0,
percent: 0,
transferred: 0,
};
const updateProgress = createProgressTracker({
total: progress.total,
message(ratio) {
const percent = `${Math.floor(ratio * 100)}`;
const details = chalk.dim(
`(${progress.pending} Pending, ${progress.transferred} Completed, ${progress.total} Total)`
);
return `Uploading assets: ${percent.padStart(3)}% ${details}`;
},
completedMessage: 'Uploaded assets',
});
try {
for await (const signal of batchUploadAsync(
uploadInit,
uploadPayloads,
progressTracker.update
)) {
progressTracker.update(signal.progress);
for await (const signal of batchUploadAsync(uploadParams)) {
if ('response' in signal) {
progress.pending--;
progress.percent = ++progress.transferred / progress.total;
} else {
progress.pending++;
}
updateProgress({ progress });
}
} catch (error: any) {
progressTracker.stop();
updateProgress({ isComplete: true, error });
throw error;
} finally {
progressTracker.stop();
}
updateProgress({ isComplete: true });
}
let assetMap: WorkerAssets.AssetMap;
let tarPath: string;
let assetFiles: WorkerAssets.AssetFileEntry[];
let deployResult: DeployInProgressParams;
let progress = ora('Preparing project').start();
@@ -284,13 +265,12 @@ export default class WorkerDeploy extends EasCommand {
manifestResult.conflictingVariableNames.join(' ')
);
}
assetFiles = await WorkerAssets.collectAssetsAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path,
{ maxFileSize: MAX_UPLOAD_SIZE }
assetMap = await WorkerAssets.createAssetMapAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path
);
tarPath = await WorkerAssets.packFilesIterableAsync(
emitWorkerTarballAsync({
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
assetMap,
manifest: manifestResult.manifest,
})
);
@@ -319,36 +299,10 @@ export default class WorkerDeploy extends EasCommand {
progress.succeed('Created deployment');
} catch (error: any) {
progress.fail('Failed to create deployment');
if (
flags.isProduction &&
error instanceof CombinedError &&
error.graphQLErrors.some(err => {
return (
err.extensions?.errorCode === 'UNAUTHORIZED_ERROR' &&
err.message.includes('AppDevDomainNameEntity') &&
err.message.includes('CREATE')
);
})
) {
throw new Error(
`You have specified the new deployment should be a production deployment, but a production domain has not been set up yet for this app.\n\nRun ${chalk.bold(
'eas deploy --prod'
)} as an app admin on your machine or promote an existing deployment to production on the ${link(
getHostingDeploymentsUrl(accountName, projectName),
{
dim: false,
text: 'Hosting Dashboard',
fallback: `Hosting Dashboard (${getHostingDeploymentsUrl(accountName, projectName)})`,
}
)} to set up a production domain and then try again.`
);
}
throw error;
}
await uploadAssetsAsync(assetFiles, deployResult);
await uploadAssetsAsync(assetMap, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
@@ -47,7 +47,6 @@ describe(EnvCreate, () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.String,
}));
jest
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
@@ -57,7 +56,6 @@ describe(EnvCreate, () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Shared,
type: EnvironmentSecretType.String,
}));
jest
.mocked(EnvironmentVariableMutation.updateAsync)
@@ -201,6 +199,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
isGlobal: true,
environments: [EnvironmentVariableEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
@@ -290,7 +289,7 @@ describe(EnvCreate, () => {
});
});
it('creates an account-wide variable', async () => {
it('creates an account-wide variable and links it', async () => {
const command = new EnvCreate(
[
'--name',
@@ -303,6 +302,7 @@ describe(EnvCreate, () => {
'development',
'--scope',
'account',
'--link',
],
mockConfig
);
@@ -325,10 +325,23 @@ describe(EnvCreate, () => {
EnvironmentVariableEnvironment.Development,
],
visibility: EnvironmentVariableVisibility.Public,
isGlobal: true,
type: EnvironmentSecretType.String,
},
testAccountId
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Development
);
});
it('prompts for missing arguments', async () => {
@@ -0,0 +1,271 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvLink from '../link';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvLink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('links an account-wide variable to the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('links an account-wide variable to the current project to a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('uses --variable-environment to select the variable with ambigous name', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockImplementation(
(_client, { environment }) => {
return mockVariables.filter(v => v.environments.includes(environment));
}
);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'other-id',
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
@@ -0,0 +1,226 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvUnlink from '../unlink';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvUnlink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Unlinked variable ${chalk.bold('TEST_VARIABLE')} from project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('unlinks an account-wide variable from the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('unlinks an account-wide variable from the current project in a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Production],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
+62 -1
View File
@@ -33,11 +33,12 @@ import {
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
interface RawCreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
@@ -49,6 +50,7 @@ interface RawCreateFlags {
interface CreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
@@ -76,6 +78,10 @@ export default class EnvCreate extends EasCommand {
value: Flags.string({
description: 'Text value or the variable',
}),
link: Flags.boolean({
description: 'Link account-wide variable to the current project',
hidden: true, // every account-wide variable is global for now so it's not user facing
}),
force: Flags.boolean({
description: 'Overwrite existing variable',
default: false,
@@ -108,6 +114,7 @@ export default class EnvCreate extends EasCommand {
'non-interactive': nonInteractive,
environment: environments,
visibility,
link,
force,
type,
fileName,
@@ -147,6 +154,29 @@ export default class EnvCreate extends EasCommand {
});
overwrite = true;
}
if (existingVariable.scope === EnvironmentVariableScope.Shared) {
await this.promptForOverwriteAsync({
nonInteractive,
force,
message: `Account-wide variable with ${name} name already exists on this account.`,
suggestion: 'Do you want to unlink it first?',
});
Log.withTick(
`Unlinking account-wide variable ${chalk.bold(name)} on project ${chalk.bold(
projectDisplayName
)}.`
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
existingVariable.id,
projectId,
environment
);
});
}
}
const variable =
@@ -220,6 +250,7 @@ export default class EnvCreate extends EasCommand {
value,
visibility,
environments,
isGlobal: true, // TODO: every account-wide variable is global for now so it's not user facing
type: type ?? EnvironmentSecretType.String,
},
ownerAccount.id
@@ -234,6 +265,29 @@ export default class EnvCreate extends EasCommand {
Log.withTick(
`Created a new variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
if (link) {
Log.withTick(
`Linking account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
variable.id,
projectId,
environment
);
});
Log.withTick(
`Linked account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
}
}
}
@@ -348,6 +402,7 @@ export default class EnvCreate extends EasCommand {
value,
environment: newEnvironments,
visibility: newVisibility,
link: rest.link ?? false,
force: rest.force ?? false,
'non-interactive': nonInteractive,
type: newType,
@@ -357,6 +412,12 @@ export default class EnvCreate extends EasCommand {
}
private sanitizeFlags(flags: RawCreateFlags): CreateFlags {
if (flags.scope !== 'account' && flags.link) {
throw new Error(
`Unexpected argument: --link can only be used when creating account-wide variables`
);
}
return {
...flags,
scope:
+204
View File
@@ -0,0 +1,204 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type LinkFlags = {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvLink extends EasCommand {
static override description = 'link an account-wide environment variable to the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to link',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
description:
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvLink);
let {
'variable-name': name,
'variable-environment': currentEnvironment,
'non-interactive': nonInteractive,
environment: environments,
} = this.validateInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvLink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment: currentEnvironment,
filterNames: name ? [name] : undefined,
});
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error(
'Multiple variables found, run command with --variable-name and --variable-environment arguments.'
);
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
if (!selectedVariable) {
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
let explicitSelect = false;
if (!nonInteractive && !environments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
}
if (!environments) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
try {
if (
selectedVariable.linkedEnvironments?.includes(environment) ===
environments.includes(environment)
) {
if (!explicitSelect && environments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already linked to ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (environments.includes(environment)) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
} catch (err: any) {
Log.warn(err.message);
}
}
}
private validateInputs(flags: LinkFlags, { environment }: { environment?: string }): LinkFlags {
environment = environment?.toUpperCase();
if (environment && !isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments = flags.environment
? flags.environment
: environment
? [environment as EnvironmentVariableEnvironment]
: undefined;
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
'--variable-name VARIABLE_NAME'
)} flag to fix the issue`
);
}
}
return {
...flags,
environment: environments,
};
}
}
+11 -13
View File
@@ -6,13 +6,15 @@ import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
import {
CreateEnvironmentVariableInput,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import {
EnvironmentVariableMutation,
EnvironmentVariablePushInput,
} from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
@@ -64,7 +66,8 @@ export default class EnvPush extends EasCommand {
});
}
const updateVariables = await this.parseEnvFileAsync(envPath, environments);
const updateVariables: Record<string, EnvironmentVariablePushInput> =
await this.parseEnvFileAsync(envPath, environments);
const variableNames = Object.keys(updateVariables);
@@ -216,16 +219,11 @@ export default class EnvPush extends EasCommand {
private async parseEnvFileAsync(
envPath: string,
environments: EnvironmentVariableEnvironment[]
): Promise<
Record<
string,
CreateEnvironmentVariableInput & { environments: EnvironmentVariableEnvironment[] }
>
> {
): Promise<Record<string, EnvironmentVariablePushInput>> {
if (!(await fs.exists(envPath))) {
throw new Error(`File ${envPath} does not exist.`);
}
const pushInput: Awaited<ReturnType<typeof this.parseEnvFileAsync>> = {};
const pushInput: Record<string, EnvironmentVariablePushInput> = {};
const variables: Record<string, string> = dotenv.parse(await fs.readFile(envPath, 'utf8'));
@@ -248,9 +246,9 @@ export default class EnvPush extends EasCommand {
name,
value,
environments,
visibility: name.startsWith('EXPO_PUBLIC_')
? EnvironmentVariableVisibility.Public
: EnvironmentVariableVisibility.Sensitive,
visibility: name.startsWith('EXPO_SENSITIVE')
? EnvironmentVariableVisibility.Sensitive
: EnvironmentVariableVisibility.Public,
};
}
+192
View File
@@ -0,0 +1,192 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type UnlinkFlags = {
'variable-name'?: string;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvUnlink extends EasCommand {
static override description =
'unlink an account-wide environment variable from the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'environment',
description:
"Environment to unlink the variable from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvUnlink);
let {
'variable-name': name,
'non-interactive': nonInteractive,
environment: unlinkEnvironments,
} = this.validateInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvUnlink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
filterNames: name ? [name] : undefined,
});
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error("Multiple variables found, please select one using '--variable-name'");
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
if (!selectedVariable) {
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
let explicitSelect = false;
if (!nonInteractive && !unlinkEnvironments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
const environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
unlinkEnvironments = Object.values(EnvironmentVariableEnvironment).filter(
env => !environments.includes(env)
);
}
if (!unlinkEnvironments) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
if (
selectedVariable.linkedEnvironments?.includes(environment) !==
unlinkEnvironments.includes(environment)
) {
if (!explicitSelect && unlinkEnvironments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already unlinked from ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (unlinkEnvironments.includes(environment)) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
}
}
private validateInputs(
flags: UnlinkFlags,
{ environment }: { environment?: string }
): UnlinkFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
);
}
}
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
environment: [environment],
...flags,
};
}
return flags;
}
}
@@ -1,3 +1,4 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import chalk from 'chalk';
@@ -6,27 +7,16 @@ import { getExpoWebsiteBaseUrl } from '../../api';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds';
import { GetServerSideEnvironmentVariablesFn } from '../../commandUtils/context/ServerSideEnvironmentVariablesContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasEnvironmentFlagParameters,
EasNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { diffFingerprint } from '../../fingerprint/cli';
import { abridgedDiff } from '../../fingerprint/diff';
import { Fingerprint, FingerprintDiffItem } from '../../fingerprint/types';
import {
getFingerprintInfoFromLocalProjectForPlatformsAsync,
stringToAppPlatform,
} from '../../fingerprint/utils';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
AppPlatform,
BuildFragment,
BuildStatus,
EnvironmentVariableEnvironment,
FingerprintFragment,
UpdateFragment,
} from '../../graphql/generated';
import { FingerprintMutation } from '../../graphql/mutations/FingerprintMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { FingerprintQuery } from '../../graphql/queries/FingerprintQuery';
@@ -34,9 +24,14 @@ import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { learnMore } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprintAsync';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
import { promptAsync, selectAsync } from '../../prompts';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { Fingerprint, FingerprintDiffItem } from '../../utils/fingerprint';
import { createFingerprintAsync, diffFingerprint } from '../../utils/fingerprintCli';
import { abridgedDiff } from '../../utils/fingerprintDiff';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { Client } from '../../vcs/vcs';
@@ -63,7 +58,6 @@ export default class FingerprintCompare extends EasCommand {
'$ eas fingerprint:compare <FINGERPRINT-HASH> \t # Compare fingerprint against local directory',
'$ eas fingerprint:compare <FINGERPRINT-HASH-1> <FINGERPRINT-HASH-2> \t # Compare provided fingerprints',
'$ eas fingerprint:compare --build-id <BUILD-ID> \t # Compare fingerprint from build against local directory',
'$ eas fingerprint:compare --build-id <BUILD-ID> --environment production \t # Compare fingerprint from build against local directory with the "production" environment',
'$ eas fingerprint:compare --build-id <BUILD-ID-1> --build-id <BUILD-ID-2>\t # Compare fingerprint from a build against another build',
'$ eas fingerprint:compare --build-id <BUILD-ID> --update-id <UPDATE-ID>\t # Compare fingerprint from build against fingerprint from update',
'$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> \t # Compare fingerprint from update against provided fingerprint',
@@ -97,11 +91,6 @@ export default class FingerprintCompare extends EasCommand {
open: Flags.boolean({
description: 'Open the fingerprint comparison in the browser',
}),
environment: Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description:
'If generating a fingerprint from the local directory, use the specified environment.',
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -110,7 +99,6 @@ export default class FingerprintCompare extends EasCommand {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
...this.ContextOptions.ServerSideEnvironmentVariables,
};
async runAsync(): Promise<void> {
@@ -122,7 +110,6 @@ export default class FingerprintCompare extends EasCommand {
'build-id': buildIds,
'update-id': updateIds,
open,
environment,
} = flags;
const [buildId1, buildId2] = buildIds ?? [];
const [updateId1, updateId2] = updateIds ?? [];
@@ -132,10 +119,9 @@ export default class FingerprintCompare extends EasCommand {
privateProjectConfig: { projectDir },
loggedIn: { graphqlClient },
vcsClient,
getServerSideEnvironmentVariablesAsync,
} = await this.getContextAsync(FingerprintCompare, {
nonInteractive,
withServerSideEnvironment: environment ?? null,
withServerSideEnvironment: null,
});
if (json) {
enableJsonOutput();
@@ -146,7 +132,6 @@ export default class FingerprintCompare extends EasCommand {
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
{
nonInteractive,
buildId: buildId1,
@@ -163,7 +148,6 @@ export default class FingerprintCompare extends EasCommand {
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
{
nonInteractive,
buildId: buildId2,
@@ -171,7 +155,6 @@ export default class FingerprintCompare extends EasCommand {
hash: hash2,
useProjectFingerprint:
isFirstFingerprintSpecifiedByFlagOrArg && !isSecondFingerprintSpecifiedByFlagOrArg,
environmentForProjectFingerprint: environment,
},
firstFingerprintInfo
);
@@ -277,20 +260,17 @@ async function getFingerprintInfoAsync(
projectDir: string,
projectId: string,
vcsClient: Client,
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn,
{
buildId,
updateId,
hash,
useProjectFingerprint,
environmentForProjectFingerprint,
nonInteractive,
}: {
buildId?: string;
updateId?: string;
hash: string;
useProjectFingerprint?: boolean;
environmentForProjectFingerprint?: string;
nonInteractive: boolean;
},
firstFingerprintInfo?: {
@@ -316,15 +296,13 @@ async function getFingerprintInfoAsync(
'First fingerprint must be provided in order to compare against the project.'
);
}
return await getFingerprintInfoFromLocalProjectAsync({
return await getFingerprintInfoFromLocalProjectAsync(
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environment: environmentForProjectFingerprint,
});
firstFingerprintInfo
);
}
if (nonInteractive) {
@@ -332,38 +310,26 @@ async function getFingerprintInfoAsync(
'Insufficent arguments provided for fingerprint comparison in non-interactive mode'
);
}
return await getFingerprintInfoInteractiveAsync({
return await getFingerprintInfoInteractiveAsync(
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environmentForProjectFingerprint,
});
firstFingerprintInfo
);
}
async function getFingerprintInfoInteractiveAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environmentForProjectFingerprint,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
async function getFingerprintInfoInteractiveAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
vcsClient: Client,
firstFingerprintInfo?: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
};
environmentForProjectFingerprint?: string;
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
}
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const prompt = firstFingerprintInfo
? 'Select the second fingerprint to compare against'
: 'Select a reference fingerprint for comparison';
@@ -381,15 +347,13 @@ async function getFingerprintInfoInteractiveAsync({
'First fingerprint must be provided in order to compare against the project.'
);
}
return await getFingerprintInfoFromLocalProjectAsync({
return await getFingerprintInfoFromLocalProjectAsync(
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environment: environmentForProjectFingerprint,
});
firstFingerprintInfo
);
} else if (originType === FingerprintOriginType.Build) {
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, {
@@ -443,49 +407,54 @@ async function getFingerprintInfoInteractiveAsync({
}
}
async function getFingerprintInfoFromLocalProjectAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environment,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
async function getFingerprintInfoFromLocalProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
vcsClient: Client,
firstFingerprintInfo: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
};
environment?: string;
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
}
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
if (!firstFingerprintPlatforms || firstFingerprintPlatforms.length === 0) {
if (!firstFingerprintPlatforms) {
throw new Error(
`Cannot compare the local directory against the provided fingerprint hash "${firstFingerprintInfo.fingerprint.hash}" because the associated platform could not be determined. Ensure the fingerprint is linked to a build or update to identify the platform.`
);
}
if (environment) {
Log.log(`🔧 Using environment: ${environment}`);
}
const env = environment
? { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' }
: undefined;
const fingerprint = await getFingerprintInfoFromLocalProjectForPlatformsAsync(
graphqlClient,
projectDir,
projectId,
vcsClient,
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(
firstFingerprintPlatforms,
{ env }
workflows
);
return { fingerprint, origin: { type: FingerprintOriginType.Project } };
const projectFingerprint = await createFingerprintAsync(projectDir, {
...optionsFromWorkflow,
platforms: firstFingerprintPlatforms.map(appPlatformToString),
debug: true,
env: undefined,
});
if (!projectFingerprint) {
throw new Error('Project fingerprints can only be computed for projects with SDK 52 or higher');
}
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: projectFingerprint.hash,
fingerprint: {
fingerprintSources: projectFingerprint.sources,
isDebugFingerprintSource: Log.isDebug,
},
graphqlClient,
});
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, {
hash: uploadedFingerprint.hash,
source: uploadedFingerprint.fingerprintSource,
});
return { fingerprint: projectFingerprint, origin: { type: FingerprintOriginType.Project } };
}
async function getFingerprintFromUpdateFragmentAsync(
@@ -632,6 +601,37 @@ async function getFingerprintFromFingerprintFragmentAsync(
return (await fingerprintResponse.json()) as Fingerprint;
}
function getFingerprintOptionsFromWorkflow(
platforms: AppPlatform[],
workflowsByPlatform: Record<Platform, Workflow>
): { workflow?: Workflow; ignorePaths?: string[] } {
if (platforms.length === 0) {
throw new Error('Could not determine platform from fingerprint sources');
}
// Single platform case
if (platforms.length === 1) {
const platform = platforms[0];
return { workflow: workflowsByPlatform[appPlatformToPlatform(platform)] };
}
// Multiple platforms case
const workflows = platforms.map(platform => workflowsByPlatform[appPlatformToPlatform(platform)]);
// If all workflows are the same, return the common workflow
const [firstWorkflow, ...restWorkflows] = workflows;
if (restWorkflows.every(workflow => workflow === firstWorkflow)) {
return { workflow: firstWorkflow };
}
// Generate ignorePaths for mixed workflows
const ignorePaths = platforms
.filter(platform => workflowsByPlatform[appPlatformToPlatform(platform)] === Workflow.MANAGED)
.map(platform => `${appPlatformToString(platform)}/**/*`);
return { ignorePaths };
}
function printContentDiff(diff: FingerprintDiffItem): void {
if (diff.op === 'added') {
const sourceType = diff.addedSource.type;
@@ -810,7 +810,40 @@ function isJSON(str: string): boolean {
}
}
async function selectBuildToCompareAsync(
function appPlatformToPlatform(platform: AppPlatform): Platform {
switch (platform) {
case AppPlatform.Android:
return Platform.ANDROID;
case AppPlatform.Ios:
return Platform.IOS;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
function appPlatformToString(platform: AppPlatform): string {
switch (platform) {
case AppPlatform.Android:
return 'android';
case AppPlatform.Ios:
return 'ios';
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
function stringToAppPlatform(platform: string): AppPlatform {
switch (platform) {
case 'android':
return AppPlatform.Android;
case 'ios':
return AppPlatform.Ios;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
export async function selectBuildToCompareAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
projectDisplayName: string,
@@ -1,164 +0,0 @@
import { Env } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import { getExpoWebsiteBaseUrl } from '../../api';
import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
EasEnvironmentFlagParameters,
EasNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
appPlatformToPlatform,
getFingerprintInfoFromLocalProjectForPlatformsAsync,
stringToAppPlatform,
} from '../../fingerprint/utils';
import { AppPlatform, EnvironmentVariableEnvironment } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { link } from '../../log';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getProfilesAsync } from '../../utils/profiles';
export default class FingerprintGenerate extends EasCommand {
static override description = 'generate fingerprints from the current project';
static override strict = false;
static override examples = [
'$ eas fingerprint:generate \t # Generate fingerprint in interactive mode',
'$ eas fingerprint:generate --build-profile preview \t # Generate a fingerprint using the "preview" build profile',
'$ eas fingerprint:generate --environment preview \t # Generate a fingerprint using the "preview" environment',
'$ eas fingerprint:generate --json --non-interactive --platform android \t # Output fingerprint json to stdout',
];
static override flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'],
}),
...EASEnvironmentFlag,
environment: Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
exclusive: ['build-profile'],
}),
'build-profile': Flags.string({
char: 'e',
description: 'Name of the build profile from eas.json.',
exclusive: ['environment'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
...this.ContextOptions.ServerSideEnvironmentVariables,
...this.ContextOptions.DynamicProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(FingerprintGenerate);
const {
json,
'non-interactive': nonInteractive,
platform: platformStringFlag,
environment,
'build-profile': buildProfileName,
} = flags;
const {
projectId,
privateProjectConfig: { projectDir },
loggedIn: { graphqlClient },
vcsClient,
getServerSideEnvironmentVariablesAsync,
getDynamicPrivateProjectConfigAsync,
} = await this.getContextAsync(FingerprintGenerate, {
nonInteractive,
withServerSideEnvironment: environment ?? null,
});
if (json) {
enableJsonOutput();
}
let platform: AppPlatform;
if (platformStringFlag) {
platform = stringToAppPlatform(platformStringFlag);
} else {
if (nonInteractive) {
throw new Error('Platform must be specified in non-interactive mode with the --p flag');
}
platform = await selectRequestedPlatformAsync();
}
let env: Env | undefined = undefined;
if (environment) {
Log.log(`🔧 Using environment: ${environment}`);
env = { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' };
} else if (buildProfileName) {
Log.log(`🔧 Using build profile: ${buildProfileName}`);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfile = (
await getProfilesAsync({
type: 'build',
easJsonAccessor,
platforms: [appPlatformToPlatform(platform)],
profileName: buildProfileName ?? undefined,
projectDir,
})
)[0];
if (!buildProfile) {
throw new Error(`Build profile ${buildProfile} not found for platform: ${platform}`);
}
const configResult = await evaluateConfigWithEnvVarsAsync({
buildProfile: buildProfile.profile,
buildProfileName: buildProfile.profileName,
graphqlClient,
getProjectConfig: getDynamicPrivateProjectConfigAsync,
opts: { env: buildProfile.profile.env },
});
env = configResult.env;
}
const fingerprint = await getFingerprintInfoFromLocalProjectForPlatformsAsync(
graphqlClient,
projectDir,
projectId,
vcsClient,
[platform],
{ env }
);
if (json) {
printJsonOnlyOutput(fingerprint);
return;
}
Log.log(`✅ Fingerprint generated: ${fingerprint.hash}`);
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
const fingerprintUrl = new URL(
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprint.hash}`,
getExpoWebsiteBaseUrl()
);
Log.log(`🔍 View the fingerprint at ${link(fingerprintUrl.toString())}`);
Log.log(`💡 If you want to see the entire fingerprint output, pass in the --json flag.`);
}
}
async function selectRequestedPlatformAsync(): Promise<AppPlatform> {
const { requestedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'requestedPlatform',
choices: [
{ title: 'Android', value: AppPlatform.Android },
{ title: 'iOS', value: AppPlatform.Ios },
],
});
return requestedPlatform;
}
@@ -86,7 +86,7 @@ export default class MetadataPull extends EasCommand {
- Update the ${chalk.bold(relativePath)} file to prepare the app information.
- Run ${chalk.bold('eas submit')} or manually upload a new app version to the app stores.
- Once the app is uploaded, run ${chalk.bold('eas metadata:push')} to sync the store config.
- ${learnMore('https://docs.expo.dev/eas/metadata/')}`);
- ${learnMore('https://docs.expo.dev/eas-metadata/introduction/')}`);
} catch (error: any) {
handleMetadataError(error);
}
@@ -1,714 +0,0 @@
import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../../api';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester, jester2 } from '../../../credentials/__tests__/fixtures-constants';
import { AppFragment, Role } from '../../../graphql/generated';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import {
installDependenciesAsync,
promptForPackageManagerAsync,
} from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { Ora, ora } from '../../../ora';
import {
createOrModifyExpoConfigAsync,
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { promptAsync } from '../../../prompts';
import { Actor, getActorUsername } from '../../../user/User';
import New, {
cloneTemplateAsync,
copyProjectTemplatesAsync,
createProjectAsync,
formatScriptCommand,
generateAppConfigAsync,
generateEasConfigAsync,
getAccountChoices,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
mergeReadmeAsync,
promptForTargetDirectoryAsync,
updatePackageJsonAsync,
} from '../new';
jest.mock('../../../prompts');
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../project/expoConfig');
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../user/User', () => ({
getActorUsername: jest.fn(),
}));
jest.mock('../../../ora');
jest.mock('fs-extra');
jest.mock('../../../onboarding/installDependencies');
jest.mock('../../../api');
jest.mock('../../../build/utils/url');
jest.mock('../../../utils/easCli', () => ({
easCliVersion: '5.0.0',
}));
// Remove Log module mocking entirely - use the actual module
describe(New.name, () => {
let consoleLogSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
// Capture console.log calls
consoleLogSpy = jest.spyOn(console, 'log');
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
jest.mocked(fs.readJson).mockImplementation(() => Promise.resolve({}));
jest.mocked(fs.writeJson).mockImplementation(() => Promise.resolve());
jest.mocked(fs.copy).mockImplementation(() => Promise.resolve());
jest.mocked(fs.remove).mockImplementation(() => Promise.resolve());
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
// Helper function to get all console output as strings
const getConsoleOutput = (): string[] => {
return consoleLogSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
};
// Helper function to check if a specific message was logged
const expectConsoleToContain = (message: string): void => {
const output = getConsoleOutput();
// strip out ANSI codes and special characters like the tick
const outputWithoutAnsi = output.map(line =>
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
);
// throw new Error(message + '\n' + outputWithoutAnsi.join('\n'));
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
};
// Helper function to verify prompt was called with expected message
const expectPromptToHaveMessage = (expectedMessage: string): void => {
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expectedMessage,
})
);
};
// Helper function to simulate user input via prompt
const mockUserInput = (inputValue: any): void => {
jest.mocked(promptAsync).mockResolvedValue(inputValue);
};
describe('helper functions', () => {
describe('promptForTargetDirectoryAsync', () => {
it('should prompt for target directory', async () => {
const promptedDirectory = '/test/prompted-project';
mockUserInput({ targetProjectDir: promptedDirectory });
const result = await promptForTargetDirectoryAsync();
expectConsoleToContain(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
expectPromptToHaveMessage('Where would you like to create your new project directory?');
expect(result).toBe(promptedDirectory);
});
it('should handle the target directory from args', async () => {
const providedDirectory = '/test/provided-project';
const result = await promptForTargetDirectoryAsync(providedDirectory);
expectConsoleToContain(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
expect(promptAsync).not.toHaveBeenCalled();
expect(result).toBe(providedDirectory);
});
});
describe('cloneTemplateAsync', () => {
it('should clone the template project with ssh', async () => {
const targetProjectDir = '/test/target-project';
const finalTargetProjectDir = '/test/final-target-project';
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir: finalTargetProjectDir,
});
const result = await cloneTemplateAsync(targetProjectDir);
expectConsoleToContain(`📂 Cloning the project to ${targetProjectDir}`);
expectConsoleToContain('We detected that ssh is your preferred git clone method');
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'ssh',
});
expect(result).toBe(finalTargetProjectDir);
});
it('should clone the template project with https', async () => {
const targetProjectDir = '/test/target-project';
const finalTargetProjectDir = '/test/final-target-project';
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir: finalTargetProjectDir,
});
const result = await cloneTemplateAsync(targetProjectDir);
expectConsoleToContain(`📂 Cloning the project to ${targetProjectDir}`);
expectConsoleToContain('We detected that https is your preferred git clone method');
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'https',
});
expect(result).toBe(finalTargetProjectDir);
});
});
describe('installProjectDependenciesAsync', () => {
it('should install the project dependencies', async () => {
const projectDir = '/test/project-directory';
jest.mocked(promptForPackageManagerAsync).mockResolvedValue('npm');
jest.mocked(installDependenciesAsync).mockResolvedValue();
jest.mocked(runCommandAsync).mockResolvedValue();
const packageManager = await installProjectDependenciesAsync(projectDir);
expect(packageManager).toBe('npm');
expect(promptForPackageManagerAsync).toHaveBeenCalled();
expect(installDependenciesAsync).toHaveBeenCalledWith({
projectDir,
packageManager: 'npm',
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
});
});
});
describe('getAccountChoices', () => {
it('should return mapped account choices', () => {
// Use jester fixture which has multiple accounts with different permissions
const namesWithSufficientPermissions = new Set(['jester']); // Only personal account has permissions
const result = getAccountChoices(jester, namesWithSufficientPermissions);
expect(result).toEqual([
{
title: 'jester (personal account)',
value: { name: 'jester' },
},
{
title: 'other',
value: { name: 'other' },
disabled: true,
description:
'You do not have the required permissions to create projects on this account.',
},
]);
});
});
describe('createProjectAsync', () => {
const mockGraphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project-dir';
const mockProjectId = 'test-project-id';
beforeEach(() => {
// use single-account jester for most of the tests
jest.mocked(getActorUsername).mockReturnValue('jester2');
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
name: 'test-app',
slug: 'test-app',
extra: {},
} as ExpoConfig);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
type: 'success' as const,
message: 'Config updated',
config: {
name: 'test-app',
slug: 'test-app',
extra: { eas: { projectId: 'test-project-id' } },
} as ExpoConfig,
});
const mockSpinner: Partial<Ora> = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(mockSpinner as Ora);
});
it('should create a project', async () => {
const mockActor = jester2 as Actor;
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue(mockProjectId);
const result = await createProjectAsync(mockGraphqlClient, mockActor, projectDir);
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester2',
'jester2-app'
);
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
accountId: 'jester2-account-id',
projectName: 'jester2-app',
});
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
projectDir,
{
extra: { eas: { projectId: mockProjectId } },
},
{ skipSDKVersionRequirement: true }
);
expect(result).toBe(mockProjectId);
});
it('should create a project when there are multiple accounts', async () => {
// Use jester fixture which has multiple accounts
const mockActor = jester;
jest.mocked(getActorUsername).mockReturnValue('jester');
jest.mocked(promptAsync).mockResolvedValue({
account: { name: 'jester' },
});
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue(mockProjectId);
const result = await createProjectAsync(mockGraphqlClient, mockActor, projectDir);
expect(promptAsync).toHaveBeenCalledWith({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices: expect.any(Array),
});
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester',
'jester-app'
);
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
accountId: 'jester-account-id',
projectName: 'jester-app',
});
expect(result).toBe(mockProjectId);
});
it('should throw when the project already exists', async () => {
const mockActor = jester2 as Actor;
const existingProjectId = 'existing-project-id';
jest
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
.mockResolvedValue(existingProjectId);
await expect(createProjectAsync(mockGraphqlClient, mockActor, projectDir)).rejects.toThrow(
`Existing project found: @jester2/jester2-app (ID: ${existingProjectId}). Project ID configuration canceled. Re-run the command to select a different account/project.`
);
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
it('should throw when the user does not have permission to create projects on account', async () => {
// Create a custom actor with ViewOnly permissions for this test
const mockActor = merge(jester2, {
accounts: [{ users: [{ actor: { id: 'view-only-jester' }, role: Role.ViewOnly }] }],
}) as Actor;
jest.mocked(getActorUsername).mockReturnValue('view-only-jester');
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
await expect(createProjectAsync(mockGraphqlClient, mockActor, projectDir)).rejects.toThrow(
`You don't have permission to create a new project on the jester account and no matching project already exists on the account.`
);
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
});
describe('generateAppConfigAsync', () => {
const projectDir = '/test/project-dir';
const mockUpdateUrl = 'https://u.expo.dev/test-project-id';
const createMockApp = (overrides: Partial<AppFragment> = {}): AppFragment => ({
id: 'test-project-id',
name: 'Test App',
fullName: '@testowner/test-app',
slug: 'test-app',
ownerAccount: {
id: 'testowner-account-id',
name: 'testowner',
users: [],
},
...overrides,
});
beforeEach(() => {
jest.mocked(getEASUpdateURL).mockReturnValue(mockUpdateUrl);
jest.mocked(fs.readJson).mockResolvedValue({
expo: {} as ExpoConfig,
});
});
it('should generate the app config', async () => {
const mockApp = createMockApp();
jest.mocked(fs.readJson).mockResolvedValue({
expo: {
name: 'value-to-override',
slug: 'value-to-override',
icon: 'value-to-keep',
} as ExpoConfig,
});
await generateAppConfigAsync(projectDir, mockApp);
expect(getEASUpdateURL).toHaveBeenCalledWith('test-project-id', null);
const expectedConfig = {
expo: {
name: 'Test App',
slug: 'test-app',
scheme: 'TestApp',
icon: 'value-to-keep',
extra: {
eas: {
projectId: 'test-project-id',
},
},
owner: 'testowner',
updates: {
url: mockUpdateUrl,
},
runtimeVersion: {
policy: 'appVersion',
},
ios: {
bundleIdentifier: 'com.testowner.testapp',
},
android: {
package: 'com.testowner.testapp',
},
},
};
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/app.json`, expectedConfig, {
spaces: 2,
});
expectConsoleToContain('Generated app.json');
});
it('should handle invalid characters in the bundle identifier', async () => {
const mockApp = createMockApp({
slug: 'test-app-with-dashes',
ownerAccount: {
id: 'test-owner-account-id',
name: 'test-owner',
users: [],
},
});
await generateAppConfigAsync(projectDir, mockApp);
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/app.json`,
expect.objectContaining({
expo: expect.objectContaining({
ios: expect.objectContaining({
bundleIdentifier: 'com.testowner.testappwithdashes',
}),
}),
}),
{ spaces: 2 }
);
});
it('should handle invalid characters in the owner account name', async () => {
const mockApp = createMockApp({
slug: 'testapp',
ownerAccount: {
id: 'test-owner-123-account-id',
name: 'test-owner-123',
users: [],
},
});
await generateAppConfigAsync(projectDir, mockApp);
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/app.json`,
expect.objectContaining({
expo: expect.objectContaining({
ios: expect.objectContaining({
bundleIdentifier: 'com.testowner123.testapp',
}),
}),
}),
{ spaces: 2 }
);
});
it('should handle invalid characters in the slug', async () => {
const mockApp = createMockApp({
slug: 'test@app#with$symbols',
fullName: '@testowner/test@app#with$symbols',
});
await generateAppConfigAsync(projectDir, mockApp);
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/app.json`,
expect.objectContaining({
expo: expect.objectContaining({
ios: expect.objectContaining({
bundleIdentifier: 'com.testowner.testappwithsymbols',
}),
}),
}),
{ spaces: 2 }
);
});
});
describe('generateEasConfigAsync', () => {
const projectDir = '/test/project-dir';
it('should generate the eas config', async () => {
const expectedEasConfig = {
cli: {
version: '>= 5.0.0',
appVersionSource: 'remote',
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
android: { image: 'latest' },
ios: { image: 'latest' },
},
'development-simulator': {
extends: 'development',
ios: { simulator: true },
},
preview: {
distribution: 'internal',
channel: 'main',
android: { image: 'latest' },
ios: { image: 'latest' },
},
production: {
channel: 'production',
autoIncrement: true,
android: { image: 'latest' },
ios: { image: 'latest' },
},
},
submit: {
production: {},
},
};
await generateEasConfigAsync(projectDir);
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/eas.json`, expectedEasConfig, {
spaces: 2,
});
expectConsoleToContain('Generated eas.json');
});
});
describe('updatePackageJsonAsync', () => {
it('should update the package.json', async () => {
jest.mocked(fs.readJson).mockResolvedValue({
name: 'test-app',
version: '1.0.0',
});
const projectDir = '/test/project-dir';
await updatePackageJsonAsync(projectDir);
const expectedPackageJson = {
name: 'test-app',
version: '1.0.0',
scripts: {
preview: 'npx eas-cli@latest workflow:run publish-preview-update.yml',
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
deploy: 'npx eas-cli@latest workflow:run deploy-to-production.yml',
},
};
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/package.json`,
expectedPackageJson,
{ spaces: 2 }
);
expectConsoleToContain('Updated package.json with scripts');
});
});
describe('copyProjectTemplatesAsync', () => {
it('should copy the project templates', async () => {
const projectDir = '/test/project-dir';
await copyProjectTemplatesAsync(projectDir);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('templates/.eas/workflows'),
expect.stringContaining('.eas/workflows'),
{ errorOnExist: false, overwrite: true }
);
expectConsoleToContain('Created EAS workflow files');
});
});
describe('mergeReadmeAsync', () => {
const projectDir = '/test/project-dir';
it('should merge readme when target section is found', async () => {
const existingReadme = `# My App
This is my app.
## Get a fresh project
Some content here.`;
const readmeAdditions = `## Workflows
This project uses EAS Workflows.`;
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(readmeAdditions);
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(existingReadme);
await mergeReadmeAsync(projectDir);
const expectedMergedReadme = `# My App
This is my app.
## Workflows
This project uses EAS Workflows.
## Get a fresh project
Some content here.`;
expect(fs.writeFile).toHaveBeenCalledWith(`${projectDir}/README.md`, expectedMergedReadme);
expectConsoleToContain('Updated README.md with EAS configuration details');
});
it('should append readme when target section is not found', async () => {
const existingReadme = `# My App
This is my app.`;
const readmeAdditions = `## Workflows
This project uses EAS Workflows.`;
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(readmeAdditions);
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(existingReadme);
await mergeReadmeAsync(projectDir);
const expectedMergedReadme = `# My App
This is my app.
## Workflows
This project uses EAS Workflows.
`;
expect(fs.writeFile).toHaveBeenCalledWith(`${projectDir}/README.md`, expectedMergedReadme);
expectConsoleToContain('Updated README.md with EAS configuration details');
});
});
describe('initializeGitRepositoryAsync', () => {
it('should initialize git repository', async () => {
const projectDir = '/test/project-dir';
await initializeGitRepositoryAsync(projectDir);
expect(fs.remove).toHaveBeenCalledWith(`${projectDir}/.git`);
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['init'],
cwd: projectDir,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['add', '.'],
cwd: projectDir,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['commit', '-m', 'Initial commit'],
cwd: projectDir,
});
});
});
describe('formatScriptCommand', () => {
it('should format script commands for package managers', () => {
expect(formatScriptCommand('start', 'npm')).toBe('npm run start');
expect(formatScriptCommand('start', 'yarn')).toBe('yarn start');
expect(formatScriptCommand('start', 'pnpm')).toBe('pnpm start');
});
});
});
});
@@ -1,472 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../api';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppFragment, Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import {
PackageManager,
installDependenciesAsync,
promptForPackageManagerAsync,
} from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, promptAsync } from '../../prompts';
import { Actor, getActorUsername } from '../../user/User';
import { easCliVersion } from '../../utils/easCli';
export async function promptForTargetDirectoryAsync(
targetProjectDirFromArgs?: string
): Promise<string> {
Log.log(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
Log.newLine();
if (targetProjectDirFromArgs) {
return targetProjectDirFromArgs;
}
const result = await promptAsync({
type: 'text',
name: 'targetProjectDir',
message: 'Where would you like to create your new project directory?',
initial: path.join(process.cwd(), 'new-expo-project'),
});
return result.targetProjectDir;
}
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
Log.log(`📂 Cloning the project to ${targetProjectDir}`);
Log.newLine();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}))
? 'ssh'
: 'https';
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
Log.newLine();
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
githubUsername,
githubRepositoryName,
targetProjectDir,
cloneMethod,
});
return finalTargetProjectDirectory;
}
export async function installProjectDependenciesAsync(projectDir: string): Promise<PackageManager> {
const packageManager = await promptForPackageManagerAsync();
await installDependenciesAsync({
projectDir,
packageManager,
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
});
}
return packageManager;
}
export function getAccountChoices(
actor: Actor,
namesWithSufficientPermissions: Set<string>
): Choice[] {
const sortedAccounts = actor.accounts.sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
return sortedAccounts.map(account => {
const isPersonalAccount = actor.__typename === 'User' && account.name === actor.username;
const accountDisplayName = isPersonalAccount
? `${account.name} (personal account)`
: account.name;
const disabled = !namesWithSufficientPermissions.has(account.name);
return {
title: accountDisplayName,
value: { name: account.name },
...(disabled && {
disabled: true,
description: 'You do not have the required permissions to create projects on this account.',
}),
};
});
}
export async function createProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string
): Promise<string> {
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
let accountName = allAccounts[0].name;
if (allAccounts.length > 1) {
const choices = getAccountChoices(
actor,
accountNamesWhereUserHasSufficientPermissionsToCreateApp
);
accountName = (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
const projectName = getActorUsername(actor) + '-app';
const projectFullName = `@${accountName}/${projectName}`;
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
if (existingProjectIdOnServer) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Project ID configuration canceled. Re-run the command to select a different account/project.`
);
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
throw new Error(
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
);
}
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectLink = link(projectDashboardUrl, { text: projectFullName });
const account = nullthrows(allAccounts.find(a => a.name === accountName));
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
let projectId: string;
try {
projectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName,
});
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
} catch (err) {
spinner.fail();
throw err;
}
const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true });
await createOrModifyExpoConfigAsync(
projectDir,
{
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
},
{ skipSDKVersionRequirement: true }
);
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
return projectId;
}
export function stripInvalidCharactersForBundleIdentifier(string: string): string {
return string.replaceAll(/[^A-Za-z0-9]/g, '');
}
export async function generateConfigFilesAsync(
projectDir: string,
app: AppFragment
): Promise<void> {
await generateAppConfigAsync(projectDir, app);
await generateEasConfigAsync(projectDir);
await updatePackageJsonAsync(projectDir);
await copyProjectTemplatesAsync(projectDir);
await mergeReadmeAsync(projectDir);
}
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
// Android package name requires each component to start with a lowercase letter.
const isUsernameValidSegment = /^[^a-z]/.test(app.ownerAccount.name);
const userPrefix = isUsernameValidSegment ? 'user' : '';
const isSlugValidSegment = /^[^a-z]/.test(app.slug);
const slugPrefix = isSlugValidSegment ? 'app' : '';
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
app.ownerAccount.name
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.slug)}`;
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
const { expo: baseExpoConfig } = await fs.readJson(path.join(projectDir, 'app.json'));
const expoConfig: ExpoConfig = {
name: app.name ?? app.slug,
slug: app.slug,
scheme: stripInvalidCharactersForBundleIdentifier(app.name ?? app.slug),
extra: {
eas: {
projectId: app.id,
},
},
owner: app.ownerAccount.name,
updates: {
url: updateUrl,
},
runtimeVersion: {
policy: 'appVersion',
},
ios: {
bundleIdentifier,
},
android: {
package: bundleIdentifier,
},
};
const mergedConfig = merge(baseExpoConfig, expoConfig);
const appJsonPath = path.join(projectDir, 'app.json');
await fs.writeJson(appJsonPath, { expo: mergedConfig }, { spaces: 2 });
Log.withTick(
`Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.log();
}
export async function generateEasConfigAsync(projectDir: string): Promise<void> {
const easBuildGitHubConfig = {
android: {
image: 'latest',
},
ios: {
image: 'latest',
},
};
const easJson: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
appVersionSource: AppVersionSource.REMOTE,
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
...easBuildGitHubConfig,
},
'development-simulator': {
extends: 'development',
ios: {
simulator: true,
},
},
preview: {
distribution: 'internal',
channel: 'main',
...easBuildGitHubConfig,
},
production: {
channel: 'production',
autoIncrement: true,
...easBuildGitHubConfig,
},
},
submit: {
production: {},
},
};
const easJsonPath = path.join(projectDir, 'eas.json');
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
Log.withTick(
`Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
Log.log();
}
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = await fs.readJson(packageJsonPath);
if (!packageJson.scripts) {
packageJson.scripts = {};
}
packageJson.scripts.preview = 'npx eas-cli@latest workflow:run publish-preview-update.yml';
packageJson.scripts['development-builds'] =
'npx eas-cli@latest workflow:run create-development-builds.yml';
packageJson.scripts.deploy = 'npx eas-cli@latest workflow:run deploy-to-production.yml';
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
Log.withTick('Updated package.json with scripts');
Log.log();
}
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
const templatesSourceDir = path.join(__dirname, 'templates', '.eas', 'workflows');
const easWorkflowsTargetDir = path.join(projectDir, '.eas', 'workflows');
await fs.copy(templatesSourceDir, easWorkflowsTargetDir, {
overwrite: true,
errorOnExist: false,
});
Log.withTick('Created EAS workflow files');
Log.log();
}
export async function mergeReadmeAsync(projectDir: string): Promise<void> {
const readmeTemplatePath = path.join(__dirname, 'templates', 'readme-additions.md');
const projectReadmePath = path.join(projectDir, 'README.md');
const readmeAdditions = await fs.readFile(readmeTemplatePath, 'utf8');
const existingReadme = await fs.readFile(projectReadmePath, 'utf8');
const targetSection = '## Get a fresh project';
const sectionIndex = existingReadme.indexOf(targetSection);
let mergedReadme: string;
if (sectionIndex !== -1) {
// Insert before "## Get a fresh project" section
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
const afterSection = existingReadme.substring(sectionIndex);
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
} else {
// Append to the end if section doesn't exist
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim() + '\n';
}
await fs.writeFile(projectReadmePath, mergedReadme);
Log.withTick('Updated README.md with EAS configuration details');
Log.log();
}
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
await fs.remove(path.join(projectDir, '.git'));
const commands = [['init'], ['add', '.'], ['commit', '-m', 'Initial commit']];
for (const args of commands) {
await runCommandAsync({
cwd: projectDir,
command: 'git',
args,
});
Log.log();
}
}
export const formatScriptCommand = (script: string, packageManager: PackageManager): string => {
if (packageManager === 'npm') {
return `npm run ${script}`;
}
return `${packageManager} ${script}`;
};
export default class New extends EasCommand {
static override aliases = ['new'];
static override description = "create a new project set up with Expo's services.";
static override flags = {};
static override hidden = true;
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args } = await this.parse(New);
const {
loggedIn: { actor, graphqlClient },
} = await this.getContextAsync(New, { nonInteractive: false });
if (actor.__typename === 'Robot') {
throw new Error(
'This command is not available for robot users. Make sure you are not using a robot token and try again.'
);
}
Log.warn(
'This command is not yet implemented. It will create a new project, but it will not be fully configured.'
);
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
Log.newLine();
const targetProjectDirectory = await promptForTargetDirectoryAsync(
args.TARGET_PROJECT_DIRECTORY
);
const projectDirectory = await cloneTemplateAsync(targetProjectDirectory);
const packageManager = await installProjectDependenciesAsync(projectDirectory);
const projectId = await createProjectAsync(graphqlClient, actor, projectDirectory);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
await generateConfigFilesAsync(projectDirectory, app);
await initializeGitRepositoryAsync(projectDirectory);
Log.log('🎉 We finished creating your new project.');
Log.log('Next steps:');
Log.withInfo(`Run \`cd ${projectDirectory}\` to navigate to your project.`);
Log.withInfo(
`Run \`${formatScriptCommand(
'preview',
packageManager
)}\` to create a preview build on EAS. ${learnMore(
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
)}`
);
Log.withInfo(
`Run \`${formatScriptCommand(
'start',
packageManager
)}\` to start developing locally. ${learnMore(
'https://docs.expo.dev/get-started/start-developing/'
)}`
);
Log.withInfo(`See the README.md for more information about your project.`);
Log.newLine();
}
}
@@ -152,7 +152,7 @@ export default class Onboarding extends EasCommand {
}))
? 'ssh'
: 'https';
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preffered git clone method`));
Log.log();
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
@@ -307,6 +307,7 @@ export default class Onboarding extends EasCommand {
autoSubmit: false,
localBuildOptions: {},
freezeCredentials: false,
repack: true,
},
actor,
// eslint-disable-next-line async-protect/async-suffix
@@ -400,7 +401,7 @@ async function configureProjectFromBareDefaultExpoTemplateAsync({
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
app.ownerAccount.name
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.slug)}`;
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
const updateUrl = getEASUpdateURL(app.id);
const easBuildGitHubConfig = {
android: {
@@ -1,21 +0,0 @@
name: Create development builds
jobs:
android_development_build:
name: Build Android
type: build
params:
platform: android
profile: development
ios_device_development_build:
name: Build iOS device
type: build
params:
platform: ios
profile: development
ios_simulator_development_build:
name: Build iOS simulator
type: build
params:
platform: ios
profile: development-simulator
@@ -1,68 +0,0 @@
name: Deploy to production
on:
push:
branches: ['main']
jobs:
fingerprint:
name: Fingerprint
type: fingerprint
get_android_build:
name: Check for existing android build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
profile: production
get_ios_build:
name: Check for existing ios build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
profile: production
build_android:
name: Build Android
needs: [get_android_build]
if: ${{ !needs.get_android_build.outputs.build_id }}
type: build
params:
platform: android
profile: production
build_ios:
name: Build iOS
needs: [get_ios_build]
if: ${{ !needs.get_ios_build.outputs.build_id }}
type: build
params:
platform: ios
profile: production
submit_android_build:
name: Submit Android Build
needs: [build_android]
type: submit
params:
build_id: ${{ needs.build_android.outputs.build_id }}
submit_ios_build:
name: Submit iOS Build
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
publish_android_update:
name: Publish Android update
needs: [get_android_build]
if: ${{ needs.get_android_build.outputs.build_id }}
type: update
params:
branch: production
platform: android
publish_ios_update:
name: Publish iOS update
needs: [get_ios_build]
if: ${{ needs.get_ios_build.outputs.build_id }}
type: update
params:
branch: production
platform: ios
@@ -1,12 +0,0 @@
name: Publish preview update
on:
push:
branches: ['*']
jobs:
publish_preview_update:
name: Publish preview update
type: update
params:
branch: ${{ github.ref_name || 'test' }}
@@ -1,19 +0,0 @@
## Workflows
This project is configured to use [EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) to automate some development and release processes. These commands are set up in [`package.json`](./package.json) and can be run using NPM scripts in your terminal.
### Previews
Run `npm run preview` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
### Development Builds
Run `npm run development-builds` to [create a development build](https://docs.expo.dev/eas/workflows/examples/create-development-builds/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/create-development-builds/#prerequisites) to ensure you have the correct emulator setup on your machine.
### Production Deployments
Run `npm run deploy` to [deploy to production](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/#prerequisites) to ensure you're set up to submit to the Apple and Google stores.
## Hosting
Expo offers hosting for websites and API functions via EAS Hosting. See the [Getting Started](https://docs.expo.dev/eas/hosting/get-started/) guide to learn more.
-19
View File
@@ -26,7 +26,6 @@ import { maybeWarnAboutEasOutagesAsync } from '../utils/statuspageService';
interface RawCommandFlags {
platform?: string;
profile?: string;
'what-to-test'?: string;
latest?: boolean;
id?: string;
path?: string;
@@ -35,19 +34,16 @@ interface RawCommandFlags {
wait: boolean;
'non-interactive': boolean;
'verbose-fastlane': boolean;
groups?: string[];
}
interface CommandFlags {
requestedPlatform: RequestedPlatform;
profile?: string;
whatToTest?: string;
archiveFlags: SubmitArchiveFlags;
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
}
export default class Submit extends EasCommand {
@@ -80,9 +76,6 @@ export default class Submit extends EasCommand {
description: 'App archive url',
exclusive: ['latest', 'id', 'path'],
}),
'what-to-test': Flags.string({
description: `Sets the "What to test" information in TestFlight (iOS only).`,
}),
verbose: Flags.boolean({
description: 'Always print logs from EAS Submit',
default: false,
@@ -96,12 +89,6 @@ export default class Submit extends EasCommand {
default: false,
description: 'Enable verbose logging for the submission process',
}),
groups: Flags.string({
description:
'Internal TestFlight testing groups to add the build to (iOS only). Learn more: https://developer.apple.com/help/app-store-connect/test-a-beta-version/add-internal-testers',
multiple: true,
char: 'g',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
@@ -153,14 +140,12 @@ export default class Submit extends EasCommand {
archiveFlags: flagsWithPlatform.archiveFlags,
nonInteractive: flagsWithPlatform.nonInteractive,
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
groups: flagsWithPlatform.groups,
actor,
graphqlClient,
analytics,
exp,
projectId,
vcsClient,
whatToTest: flagsWithPlatform.whatToTest,
specifiedProfile: flagsWithPlatform.profile,
});
@@ -199,8 +184,6 @@ export default class Submit extends EasCommand {
profile,
'non-interactive': nonInteractive,
'verbose-fastlane': isVerboseFastlaneEnabled,
groups,
'what-to-test': whatToTest,
...archiveFlags
} = flags;
@@ -221,9 +204,7 @@ export default class Submit extends EasCommand {
wait,
profile,
nonInteractive,
whatToTest,
isVerboseFastlaneEnabled,
groups,
};
}
@@ -10,6 +10,7 @@ import {
AndroidSubmissionConfigInput,
IosSubmissionConfigInput,
SubmissionAndroidReleaseStatus,
SubmissionAndroidTrack,
} from '../../graphql/generated';
import { AppStoreConnectApiKeyQuery } from '../../graphql/queries/AppStoreConnectApiKeyQuery';
import { GoogleServiceAccountKeyQuery } from '../../graphql/queries/GoogleServiceAccountKeyQuery';
@@ -95,7 +96,6 @@ export default class SubmitInternal extends EasCommand {
projectId,
vcsClient,
specifiedProfile: flags.profile,
groups: undefined, // use groups from submit profile
});
let config;
@@ -114,7 +114,6 @@ export default class SubmitInternal extends EasCommand {
const configInput: z.input<typeof SubmissionConfig.Ios.SchemaZ> = {
ascAppIdentifier: iosConfig.ascAppIdentifier,
isVerboseFastlaneEnabled: iosConfig.isVerboseFastlaneEnabled ?? undefined,
groups: iosConfig.groups ?? undefined,
...(ascApiKeyJson
? { ascApiJsonKey: ascApiKeyJson }
: {
@@ -140,11 +139,12 @@ export default class SubmitInternal extends EasCommand {
graphqlClient,
})
);
const track = graphQlTrackToConfigTrack[androidConfig.track];
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> = {
changesNotSentForReview,
googleServiceAccountKeyJson,
track: androidConfig.track,
track,
...(releaseStatus === SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS
? {
releaseStatus: SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS,
@@ -215,3 +215,10 @@ const graphQlReleaseStatusToConfigReleaseStatus = {
[SubmissionAndroidReleaseStatus.Completed]: SubmissionConfig.Android.ReleaseStatus.COMPLETED,
[SubmissionAndroidReleaseStatus.Halted]: SubmissionConfig.Android.ReleaseStatus.HALTED,
};
const graphQlTrackToConfigTrack = {
[SubmissionAndroidTrack.Production]: SubmissionConfig.Android.ReleaseTrack.PRODUCTION,
[SubmissionAndroidTrack.Beta]: SubmissionConfig.Android.ReleaseTrack.BETA,
[SubmissionAndroidTrack.Alpha]: SubmissionConfig.Android.ReleaseTrack.ALPHA,
[SubmissionAndroidTrack.Internal]: SubmissionConfig.Android.ReleaseTrack.INTERNAL,
};
@@ -35,7 +35,6 @@ const updateStub: UpdateFragment = {
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
@@ -32,7 +32,6 @@ const updateStub: UpdateFragment = {
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
@@ -32,7 +32,6 @@ const updateStub: UpdateFragment = {
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
@@ -1,4 +1,3 @@
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -48,10 +47,6 @@ export default class UpdateConfigure extends EasCommand {
await vcsClient.ensureRepoExistsAsync();
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp,
projectId,
@@ -59,8 +54,6 @@ export default class UpdateConfigure extends EasCommand {
platform: flags['platform'],
vcsClient,
env: undefined,
forceNativeConfigSync: true,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
+37 -8
View File
@@ -1,12 +1,43 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateGroupMutation,
UpdateMutationDeleteUpdateGroupArgs,
} from '../../graphql/generated';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
async function deleteUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
{
group,
}: {
group: string;
}
): Promise<DeleteUpdateGroupMutation> {
return await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateGroupMutation, UpdateMutationDeleteUpdateGroupArgs>(
gql`
mutation DeleteUpdateGroup($group: ID!) {
update {
deleteUpdateGroup(group: $group) {
group
}
}
}
`,
{ group }
)
.toPromise()
);
}
export default class UpdateDelete extends EasCommand {
static override description = 'delete all the updates in an update group';
@@ -42,7 +73,7 @@ export default class UpdateDelete extends EasCommand {
}
if (!nonInteractive) {
const shouldContinue = await confirmAsync({
const shouldAbort = await confirmAsync({
message:
`🚨${chalk.red('CAUTION')}🚨\n\n` +
`${chalk.yellow(`This will delete all of the updates in group "${group}".`)} ${chalk.red(
@@ -50,18 +81,16 @@ export default class UpdateDelete extends EasCommand {
)}\n\n` +
`If you want to revert to a previous publish, you should use 'update --republish' targeted at the last working update group instead.\n\n` +
`An update group should only be deleted in an emergency like an accidental publish of a secret. In this case user 'update --republish' to revert to the last working update group first and then proceed with the deletion. Deleting an update group when it is the latest publish can lead to inconsistent caching behavior by clients.\n\n` +
`Would you like to continue?`,
`Would you like to abort?`,
});
if (!shouldContinue) {
if (shouldAbort) {
Log.log('Aborted.');
return;
}
}
const receipt = await scheduleUpdateGroupDeletionAsync(graphqlClient, { group });
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
Log.debug('Deletion result', { successfulReceipt });
await deleteUpdateGroupAsync(graphqlClient, { group });
if (jsonFlag) {
printJsonOnlyOutput({ group });
+82 -157
View File
@@ -1,10 +1,8 @@
import { Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
// import { getExpoWebsiteBaseUrl } from '../../api';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { transformFingerprintSource } from '../../build/graphql';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
@@ -14,9 +12,8 @@ import { EasNonInteractiveAndJsonFlags, EasUpdateEnvironmentFlag } from '../../c
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
AssetMapSourceInput,
EnvironmentVariableEnvironment,
FingerprintInfoGroup as GraphqlFingerprintInfoGroup,
FingerprintInfoGroup,
PublishUpdateGroupInput,
StatuspageServiceName,
UpdateInfoGroup,
@@ -24,11 +21,9 @@ import {
UpdateRolloutInfoGroup,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
// import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { maybeUploadAssetMapAsync } from '../../project/maybeUploadAssetMapAsync';
import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprintAsync';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import {
@@ -38,7 +33,6 @@ import {
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
filterCollectedAssetsByRequestedPlatforms,
// findCompatibleBuildsAsync,
generateEasMetadataAsync,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
@@ -212,10 +206,6 @@ export default class UpdatePublish extends EasCommand {
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp: expPossiblyWithoutEasUpdateConfigured,
platform: requestedPlatform,
@@ -223,7 +213,6 @@ export default class UpdatePublish extends EasCommand {
projectId,
vcsClient,
env: undefined,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
const { exp } = await getDynamicPublicProjectConfigAsync();
@@ -279,14 +268,9 @@ export default class UpdatePublish extends EasCommand {
let uploadedAssetCount = 0;
let assetLimitPerUpdateGroup = 0;
let realizedPlatforms: UpdatePublishPlatform[] = [];
let assetMapSource: AssetMapSourceInput | null = null;
try {
const [collectedAssets, maybeAssetMapSource] = await Promise.all([
collectAssetsAsync(distRoot),
maybeUploadAssetMapAsync(distRoot, graphqlClient),
]);
assetMapSource = maybeAssetMapSource;
const collectedAssets = await collectAssetsAsync(distRoot);
const assets = filterCollectedAssetsByRequestedPlatforms(collectedAssets, requestedPlatform);
realizedPlatforms = Object.keys(assets) as UpdatePublishPlatform[];
@@ -494,27 +478,20 @@ export default class UpdatePublish extends EasCommand {
},
};
},
{} as GraphqlFingerprintInfoGroup
{} as FingerprintInfoGroup
);
const assetMapGroup = assetMapSource
? Object.fromEntries(platforms.map(platform => [platform, assetMapSource]))
: null;
return {
branchId: branch.id,
updateInfoGroup: localUpdateInfoGroup,
rolloutInfoGroup: localRolloutInfoGroup,
fingerprintInfoGroup: transformedFingerprintInfoGroup,
assetMapGroup,
runtimeVersion,
message: updateMessage,
gitCommitHash,
isGitWorkingTreeDirty,
awaitingCodeSigningInfo: !!codeSigningInfo,
environment: environment ?? null,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
assetHostOverride: easJsonCliConfig.updateAssetHostOverride ?? null,
};
}
);
@@ -582,147 +559,95 @@ export default class UpdatePublish extends EasCommand {
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
return;
}
if (new Set(newUpdates.map(update => update.group)).size > 1) {
Log.addNewLineIfNone();
Log.log(
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
);
}
Log.addNewLineIfNone();
// const runtimeToCompatibleBuilds = await Promise.all(
// runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(obj =>
// findCompatibleBuildsAsync(graphqlClient, projectId, obj)
// )
// );
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
} else {
if (new Set(newUpdates.map(update => update.group)).size > 1) {
Log.addNewLineIfNone();
Log.log(
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate ? [{ label: 'Android update ID', value: newAndroidUpdate.id }] : []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
...(newAndroidUpdate?.rolloutControlUpdate
? [
{
label: 'Android Rollout',
value: `${newAndroidUpdate.rolloutPercentage}% (Base update ID: ${newAndroidUpdate.rolloutControlUpdate.id})`,
},
]
: []),
...(newIosUpdate?.rolloutControlUpdate
? [
{
label: 'iOS Rollout',
value: `${newIosUpdate.rolloutPercentage}% (Base update ID: ${newIosUpdate.rolloutControlUpdate.id})`,
},
]
: []),
{ label: 'Message', value: updateMessage ?? '' },
...(gitCommitHash
? [
{
label: 'Commit',
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
},
]
: []),
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
if (isUploadedAssetCountAboveWarningThreshold(uploadedAssetCount, assetLimitPerUpdateGroup)) {
Log.warn(
`This update group contains ${uploadedAssetCount} assets and is nearing the server cap of ${assetLimitPerUpdateGroup}.\n` +
`${learnMore('https://docs.expo.dev/eas-update/optimize-assets/', {
learnMoreMessage: 'Consider optimizing your usage of assets',
dim: false,
})}.`
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
...(newAndroidUpdate?.rolloutControlUpdate
? [
{
label: 'Android Rollout',
value: `${newAndroidUpdate.rolloutPercentage}% (Base update ID: ${newAndroidUpdate.rolloutControlUpdate.id})`,
},
]
: []),
...(newIosUpdate?.rolloutControlUpdate
? [
{
label: 'iOS Rollout',
value: `${newIosUpdate.rolloutPercentage}% (Base update ID: ${newIosUpdate.rolloutControlUpdate.id})`,
},
]
: []),
{ label: 'Message', value: updateMessage ?? '' },
...(gitCommitHash
? [
{
label: 'Commit',
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
},
]
: []),
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
if (
isUploadedAssetCountAboveWarningThreshold(uploadedAssetCount, assetLimitPerUpdateGroup)
) {
Log.warn(
`This update group contains ${uploadedAssetCount} assets and is nearing the server cap of ${assetLimitPerUpdateGroup}.\n` +
`${learnMore('https://docs.expo.dev/eas-update/optimize-assets/', {
learnMoreMessage: 'Consider optimizing your usage of assets',
dim: false,
})}.`
);
Log.addNewLineIfNone();
}
}
// NOTE(brentvatne): temporarily disable logging this until we can revisit the formatting
// and the logic for it - it's a bit too aggressive right now, and warns even if you're
// not using EAS Build
//
// const fingerprintsWithoutCompatibleBuilds = runtimeToCompatibleBuilds.find(
// ({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion
// )?.fingerprintInfoGroupWithCompatibleBuilds;
// if (fingerprintsWithoutCompatibleBuilds) {
// const missingBuilds = Object.entries(fingerprintsWithoutCompatibleBuilds).filter(
// ([_platform, fingerprintInfo]) => !fingerprintInfo.build
// );
// if (missingBuilds.length > 0) {
// const project = await AppQuery.byIdAsync(graphqlClient, projectId);
// Log.warn('No compatible builds found for the following fingerprints:');
// for (const [platform, fingerprintInfo] of missingBuilds) {
// const fingerprintUrl = new URL(
// `/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprintInfo.fingerprintHash}`,
// getExpoWebsiteBaseUrl()
// );
// Log.warn(
// formatFields(
// [
// {
// label: `${this.prettyPlatform(platform)} fingerprint`,
// value: fingerprintInfo.fingerprintHash,
// },
// { label: 'URL', value: fingerprintUrl.toString() },
// ],
// {
// labelFormat: label => ` ${chalk.dim(label)}:`,
// }
// )
// );
// Log.addNewLineIfNone();
// }
// }
// }
}
}
// private prettyPlatform(updatePlatform: string): string {
// switch (updatePlatform) {
// case 'android':
// return 'Android';
// case 'ios':
// return 'iOS';
// default:
// return updatePlatform;
// }
// }
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
const nonInteractive = flags['non-interactive'] ?? false;
+184 -22
View File
@@ -1,18 +1,20 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
import {
UpdateToRepublish,
getOrAskUpdateMessageAsync,
getUpdateGroupOrAskForUpdateGroupAsync,
republishAsync,
} from '../../update/republish';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { UpdateToRepublish, republishAsync } from '../../update/republish';
import { truncateString as truncateUpdateMessage } from '../../update/utils';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
@@ -29,7 +31,6 @@ type UpdateRepublishRawFlags = {
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
'rollout-percentage'?: number;
};
type UpdateRepublishFlags = {
@@ -43,7 +44,6 @@ type UpdateRepublishFlags = {
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
rolloutPercentage?: number;
};
export default class UpdateRepublish extends EasCommand {
@@ -86,12 +86,6 @@ export default class UpdateRepublish extends EasCommand {
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
required: false,
}),
'rollout-percentage': Flags.integer({
description: `Percentage of users this update should be immediately available to. Users not in the rollout will be served the previous latest update on the branch, even if that update is itself being rolled out. The specified number must be an integer between 1 and 100. When not specified, this defaults to 100.`,
required: false,
min: 0,
max: 100,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -118,11 +112,7 @@ export default class UpdateRepublish extends EasCommand {
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
const existingUpdates = await getUpdateGroupOrAskForUpdateGroupAsync(
graphqlClient,
projectId,
flags
);
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
const updatesToPublish = existingUpdates.filter(update =>
flags.platform.includes(update.platform as Platform)
);
@@ -140,7 +130,7 @@ export default class UpdateRepublish extends EasCommand {
);
}
if (rawFlags.platform !== 'all') {
if (rawFlags.platform === 'all') {
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
} else {
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
@@ -173,7 +163,6 @@ export default class UpdateRepublish extends EasCommand {
updateMessage,
codeSigningInfo,
json: flags.json,
rolloutPercentage: flags.rolloutPercentage,
});
}
@@ -202,7 +191,6 @@ export default class UpdateRepublish extends EasCommand {
platform,
updateMessage: rawFlags.message,
privateKeyPath,
rolloutPercentage: rawFlags['rollout-percentage'],
json: rawFlags.json ?? false,
nonInteractive,
};
@@ -236,3 +224,177 @@ async function getOrAskTargetBranchAsync(
// if neither provided, assume republish on same branch
return { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName };
}
/** Retrieve the update group from either the update group id, or select from branch name. */
async function getOrAskUpdatesAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
flags: UpdateRepublishFlags
): Promise<UpdateToRepublish[]> {
if (flags.groupId) {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: flags.groupId,
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
if (flags.nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
if (flags.branchName) {
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: flags.branchName,
projectId,
});
}
if (flags.channelName) {
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: flags.channelName,
projectId,
});
}
const { choice } = await promptAsync({
type: 'select',
message: 'Find update by branch or channel?',
name: 'choice',
choices: [
{ title: 'Branch', value: 'branch' },
{ title: 'Channel', value: 'channel' },
],
});
if (choice === 'channel') {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: name,
projectId,
});
} else if (choice === 'branch') {
const { name } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Select branch from which to choose update',
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: name,
projectId,
});
} else {
throw new Error('Must choose update via channel or branch');
}
}
/** Ask the user which update needs to be republished by branch name, this requires interactive mode */
async function askUpdatesFromBranchNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
branchName,
json,
nonInteractive,
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
async function askUpdatesFromChannelNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
channelName,
json,
nonInteractive,
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const { branchName } = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
projectId,
channelName
);
return await askUpdatesFromBranchNameAsync(graphqlClient, {
projectId,
branchName,
json,
nonInteractive,
});
}
/** Get or ask the user for the update (group) message for the republish */
async function getOrAskUpdateMessageAsync(
updates: UpdateToRepublish[],
flags: UpdateRepublishFlags
): Promise<string> {
if (flags.updateMessage) {
return sanitizeUpdateMessage(flags.updateMessage);
}
if (flags.nonInteractive || flags.json) {
throw new Error('Must supply --message when in non-interactive mode');
}
// This command only uses a single update group to republish, meaning these values are always identical
const oldGroupId = updates[0].groupId;
const oldUpdateMessage = updates[0].message;
const { updateMessage } = await promptAsync({
type: 'text',
name: 'updateMessage',
message: 'Provide an update message.',
initial: `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`,
validate: (value: any) => (value ? true : 'Update message may not be empty.'),
});
return sanitizeUpdateMessage(updateMessage);
}
function sanitizeUpdateMessage(updateMessage: string): string {
if (updateMessage !== truncateUpdateMessage(updateMessage, 1024)) {
Log.warn('Update message exceeds the allowed 1024 character limit, truncated update message.');
return truncateUpdateMessage(updateMessage, 1024);
}
return updateMessage;
}
@@ -1,416 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { Flags } from '@oclif/core';
import assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log, { learnMore } from '../../log';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
import { confirmAsync, promptAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import {
UpdateToRepublish,
askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync,
getOrAskUpdateMessageAsync,
getUpdateGroupAsync,
republishAsync,
} from '../../update/republish';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
import { Client } from '../../vcs/vcs';
type RolloutUpdate = UpdateToRepublish & {
rolloutPercentage: NonNullable<UpdateToRepublish['rolloutPercentage']>;
};
type RolloutUpdateWithControlUpdate = RolloutUpdate & {
rolloutControlUpdate: NonNullable<UpdateToRepublish['rolloutControlUpdate']>;
};
type UpdateRevertUpdateRolloutRawFlags = {
branch?: string;
channel?: string;
group?: string;
message?: string;
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
};
type UpdateRevertUpdateRolloutFlags = {
branchName?: string;
channelName?: string;
groupId?: string;
updateMessage?: string;
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
};
export function nonNullish<TValue>(value: TValue | null | undefined): value is NonNullable<TValue> {
return value !== null && value !== undefined;
}
export default class UpdateRevertUpdateRollout extends EasCommand {
static override description = 'revert a rollout update for a project';
static override flags = {
channel: Flags.string({
description: 'Channel name to select an update group to revert the rollout update from',
exclusive: ['branch', 'group'],
}),
branch: Flags.string({
description: 'Branch name to select an update group to revert the rollout update from',
exclusive: ['channel', 'group'],
}),
group: Flags.string({
description: 'Rollout update group ID to revert',
exclusive: ['branch', 'channel'],
}),
message: Flags.string({
char: 'm',
description: 'Short message describing the revert',
required: false,
}),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(UpdateRevertUpdateRollout);
const flags = this.sanitizeFlags(rawFlags);
const {
privateProjectConfig: { exp, projectId, projectDir },
loggedIn: { graphqlClient },
vcsClient,
} = await this.getContextAsync(UpdateRevertUpdateRollout, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});
if (flags.json) {
enableJsonOutput();
}
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
let updateGroupToRepublish: RolloutUpdate[];
if (flags.groupId) {
const updateGroup = await getUpdateGroupAsync(graphqlClient, flags.groupId);
if (!updateGroupIsRolloutUpdateGroup(updateGroup)) {
throw new Error(
`The update group with ID "${flags.groupId}" is not a rollout update group.`
);
}
updateGroupToRepublish = updateGroup;
} else {
const latestUpdateGroupForEachPublishPlatform =
await askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync(
graphqlClient,
projectId,
flags
);
const uniqueUpdateGroups = getUniqueUpdateGroups(
Object.values(latestUpdateGroupForEachPublishPlatform).filter(nonNullish)
);
const rolloutUpdateGroups = uniqueUpdateGroups.filter(updateGroupIsRolloutUpdateGroup);
if (rolloutUpdateGroups.length === 0) {
throw new Error(`No rollout update groups found.`);
}
if (rolloutUpdateGroups.length === 1) {
updateGroupToRepublish = rolloutUpdateGroups[0];
} else {
const { choice: chosenId } = await promptAsync({
type: 'select',
message: 'Which rollout update group would you like to revert?',
name: 'choice',
choices: rolloutUpdateGroups.map(ug => ({
title: `Rollout update group ID: ${ug[0].groupId}, Platform: ${ug[0].platform}, Rollout Percentage: ${ug[0].rolloutPercentage}`,
value: ug[0].groupId,
})),
});
if (!chosenId) {
throw new Error('No rollout update group selected.');
}
const chosenUpdateGroup = rolloutUpdateGroups.find(ug => ug[0].groupId === chosenId);
if (!chosenUpdateGroup) {
throw new Error('No rollout update group selected.');
}
updateGroupToRepublish = chosenUpdateGroup;
}
}
const rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[] | null =
updateGroupIsUpdateGroupWithControlUpdate(updateGroupToRepublish)
? updateGroupToRepublish
: null;
if (rolloutUpdateGroupWithControlUpdates) {
await this.deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
graphqlClient,
exp,
projectId,
rolloutUpdateGroupWithControlUpdates,
codeSigningInfo,
flags,
});
} else {
await this.deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
graphqlClient,
vcsClient,
exp,
projectDir,
projectId,
rolloutUpdateGroup: updateGroupToRepublish,
codeSigningInfo,
flags,
});
}
}
private async deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
graphqlClient,
exp,
projectId,
rolloutUpdateGroupWithControlUpdates,
codeSigningInfo,
flags,
}: {
graphqlClient: ExpoGraphqlClient;
exp: ExpoConfig;
projectId: string;
rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[];
codeSigningInfo: CodeSigningInfo | undefined;
flags: UpdateRevertUpdateRolloutFlags;
}): Promise<void> {
const controlUpdateGroupIdsToRepublish = Array.from(
new Set(rolloutUpdateGroupWithControlUpdates.map(update => update.rolloutControlUpdate.group))
);
const updateGroupsToRepublish = await Promise.all(
controlUpdateGroupIdsToRepublish.map(controlUpdateGroupIdToRepublish =>
getUpdateGroupAsync(graphqlClient, controlUpdateGroupIdToRepublish)
)
);
const updateGroupOrGroupsClause =
controlUpdateGroupIdsToRepublish.length > 1
? `control update groups (IDs: ${controlUpdateGroupIdsToRepublish
.map(id => `"${id}"`)
.join(', ')})`
: `control update group (ID: "${controlUpdateGroupIdsToRepublish[0]}")`;
if (!flags.nonInteractive) {
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupWithControlUpdates[0].groupId}"? This will delete the rollout update group and republish the ${updateGroupOrGroupsClause}.`;
const didConfirm = await confirmAsync({ message: confirmMessage });
if (!didConfirm) {
throw new Error('Aborting...');
}
}
const updateMessages: string[] = [];
for (const updateGroup of updateGroupsToRepublish) {
updateMessages.push(await getOrAskUpdateMessageAsync(updateGroup, flags));
}
// assert all updateGroupsToRepublish have the same branch name and id
const branchNames = updateGroupsToRepublish.flatMap(updateGroup => updateGroup[0].branchName);
const branchIds = updateGroupsToRepublish.map(updateGroup => updateGroup[0].branchId);
assert(
branchNames.every(name => name === branchNames[0]),
'All update groups being republished must belong to the same branch.'
);
assert(
branchIds.every(id => id === branchIds[0]),
'All update groups being republished must belong to the same branch.'
);
const targetBranch = {
branchName: branchNames[0],
branchId: branchIds[0],
};
await this.deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup: rolloutUpdateGroupWithControlUpdates,
});
for (let i = 0; i < updateGroupsToRepublish.length; i++) {
const updateGroupToRepublish = updateGroupsToRepublish[i];
const updateMessage = updateMessages[i];
await republishAsync({
graphqlClient,
app: { exp, projectId },
updatesToPublish: updateGroupToRepublish,
targetBranch,
updateMessage,
codeSigningInfo,
json: flags.json,
});
}
}
private async deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
graphqlClient,
vcsClient,
exp,
projectDir,
projectId,
rolloutUpdateGroup,
codeSigningInfo,
flags,
}: {
graphqlClient: ExpoGraphqlClient;
vcsClient: Client;
exp: ExpoConfig;
projectDir: string;
projectId: string;
rolloutUpdateGroup: RolloutUpdate[];
codeSigningInfo: CodeSigningInfo | undefined;
flags: UpdateRevertUpdateRolloutFlags;
}): Promise<void> {
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
if (!flags.nonInteractive) {
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupId}"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. ${learnMore(
'https://expo.fyi/eas-update-update-rollouts',
{ learnMoreMessage: 'More info' }
)})`;
const didConfirm = await confirmAsync({ message: confirmMessage });
if (!didConfirm) {
throw new Error('Aborting...');
}
}
// check that the expo-updates package version supports roll back to embedded
await enforceRollBackToEmbeddedUpdateSupportAsync(projectDir);
const updateMessage = await getUpdateMessageForCommandAsync(vcsClient, {
updateMessageArg: flags.updateMessage,
autoFlag: false,
nonInteractive: flags.nonInteractive,
jsonFlag: flags.json,
});
await this.deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup,
});
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
const targetBranch = {
name: rolloutUpdateGroup[0].branchName,
id: rolloutUpdateGroup[0].branchId,
};
await publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch: targetBranch,
codeSigningInfo,
platforms,
runtimeVersion,
json: flags.json,
});
}
private async deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup,
}: {
graphqlClient: ExpoGraphqlClient;
rolloutUpdateGroup: RolloutUpdate[];
}): Promise<void> {
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
const updateGroupDeletionReceipt = await scheduleUpdateGroupDeletionAsync(graphqlClient, {
group: rolloutUpdateGroupId,
});
const successfulReceipt = await pollForBackgroundJobReceiptAsync(
graphqlClient,
updateGroupDeletionReceipt
);
Log.debug('Rollout update group deletion result', { successfulReceipt });
}
private sanitizeFlags(
rawFlags: UpdateRevertUpdateRolloutRawFlags
): UpdateRevertUpdateRolloutFlags {
const branchName = rawFlags.branch;
const channelName = rawFlags.channel;
const groupId = rawFlags.group;
const nonInteractive = rawFlags['non-interactive'];
const privateKeyPath = rawFlags['private-key-path'];
if (nonInteractive && !groupId) {
throw new Error('Only --group can be used in non-interactive mode');
}
return {
branchName,
channelName,
groupId,
updateMessage: rawFlags.message,
privateKeyPath,
json: rawFlags.json ?? false,
nonInteractive,
};
}
}
function getUniqueUpdateGroups(updateGroups: UpdateToRepublish[][]): UpdateToRepublish[][] {
const uniqueUpdateGroups = new Map<string, UpdateToRepublish[]>();
for (const updateGroup of updateGroups) {
const groupId = updateGroup[0].groupId;
if (!uniqueUpdateGroups.has(groupId)) {
uniqueUpdateGroups.set(groupId, updateGroup);
}
}
return Array.from(uniqueUpdateGroups.values());
}
function updateGroupIsRolloutUpdateGroup(
updateGroup: UpdateToRepublish[]
): updateGroup is RolloutUpdate[] {
return updateGroup.every(updateIsRolloutUpdate);
}
function updateIsRolloutUpdate(updateGroup: UpdateToRepublish): updateGroup is RolloutUpdate {
return updateGroup.rolloutPercentage !== undefined && updateGroup.rolloutPercentage !== null;
}
function updateGroupIsUpdateGroupWithControlUpdate(
updateGroup: RolloutUpdate[]
): updateGroup is RolloutUpdateWithControlUpdate[] {
return updateGroup.every(updateIsRolloutWithControlUpdate);
}
function updateIsRolloutWithControlUpdate(
updateGroup: RolloutUpdate
): updateGroup is RolloutUpdateWithControlUpdate {
return !!updateGroup.rolloutControlUpdate;
}
@@ -1,24 +1,50 @@
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Platform as PublishPlatform } from '@expo/config';
import { Errors, Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { StatuspageServiceName } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import fetch from '../../fetch';
import {
PublishUpdateGroupInput,
RuntimeFragment,
StatuspageServiceName,
UpdatePublishMutation,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import {
enforceRollBackToEmbeddedUpdateSupportAsync,
getOwnerAccountForProjectIdAsync,
} from '../../project/projectUtils';
import {
RuntimeVersionInfo,
UpdatePublishPlatform,
defaultPublishPlatforms,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
getUpdateMessageForCommandAsync,
} from '../../project/publish';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { selectRuntimeOnBranchAsync } from '../../update/queries';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
import {
CodeSigningInfo,
checkDirectiveBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
getDirectiveBodyAsync,
signBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
type RawUpdateFlags = {
@@ -122,10 +148,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp: expPossiblyWithoutEasUpdateConfigured,
platform: platformFlag,
@@ -133,7 +155,6 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
projectId,
vcsClient,
env: undefined,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
// check that the expo-updates package version supports roll back to embedded
@@ -172,7 +193,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
const selectedRuntime =
runtimeVersionArg ??
(
await selectRuntimeOnBranchAsync(graphqlClient, {
await UpdateRollBackToEmbedded.selectRuntimeAsync(graphqlClient, {
appId: projectId,
branchName,
})
@@ -181,16 +202,196 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
Errors.error('Must select a runtime or provide the --runtimeVersion flag', { exit: 1 });
}
await publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch,
codeSigningInfo,
platforms: realizedPlatforms,
runtimeVersion: selectedRuntime,
json: jsonFlag,
const runtimeToPlatformsAndFingerprintInfoMapping =
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
realizedPlatforms.map(platform => ({
platform,
runtimeVersionInfo: {
runtimeVersion: selectedRuntime,
expoUpdatesRuntimeFingerprint: null,
expoUpdatesRuntimeFingerprintHash: null,
},
}))
);
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
const publishSpinner = ora('Publishing...').start();
try {
newUpdates = await this.publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId: branch.id,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
realizedPlatforms,
});
publishSpinner.succeed('Published!');
} catch (e) {
publishSpinner.fail('Failed to publish updates');
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
} else {
Log.addNewLineIfNone();
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: updateMessage ?? '' },
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
}
}
}
private async publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
realizedPlatforms,
}: {
graphqlClient: ExpoGraphqlClient;
updateMessage: string | undefined;
branchId: string;
codeSigningInfo: CodeSigningInfo | undefined;
runtimeToPlatformsAndFingerprintInfoMapping: (RuntimeVersionInfo & {
platforms: UpdatePublishPlatform[];
})[];
realizedPlatforms: PublishPlatform[];
}): Promise<UpdatePublishMutation['updateBranch']['publishUpdateGroups']> {
const rollbackInfoGroups = Object.fromEntries(
realizedPlatforms.map(platform => [platform, true])
);
// Sort the updates into different groups based on their platform specific runtime versions
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformsAndFingerprintInfoMapping.map(
({ runtimeVersion, platforms }) => {
const localRollbackInfoGroup = Object.fromEntries(
platforms.map(platform => [platform, rollbackInfoGroups[platform]])
);
return {
branchId,
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
runtimeVersion,
message: updateMessage,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
);
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
if (codeSigningInfo) {
Log.log('🔒 Signing roll back');
const updatesTemp = [...newUpdates];
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
const newUpdates = updatesTemp.splice(
0,
Object.keys(nullthrows(updateGroup.rollBackToEmbeddedInfoGroup)).length
);
return {
updateGroup,
newUpdates,
};
});
await Promise.all(
updateGroupsAndTheirUpdates.map(async ({ newUpdates }) => {
await Promise.all(
newUpdates.map(async newUpdate => {
const response = await fetch(newUpdate.manifestPermalink, {
method: 'GET',
headers: { accept: 'multipart/mixed' },
});
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
const directiveSignature = signBody(directiveBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
keyid: codeSigningInfo.codeSigningMetadata.keyid,
sig: directiveSignature,
});
})
);
})
);
}
return newUpdates;
}
private static async selectRuntimeAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
batchSize = 5,
}: {
appId: string;
branchName: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return runtime.version;
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
});
}
@@ -6,8 +6,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { promptAsync } from '../../prompts';
export default class UpdateRollback extends EasCommand {
static override description =
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
static override description = 'roll back to an embedded update or an existing update';
static override flags = {
'private-key-path': Flags.string({
-464
View File
@@ -1,464 +0,0 @@
import { IOSConfig } from '@expo/config-plugins';
import { Platform } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import fg from 'fast-glob';
import fs from 'fs-extra';
import StreamZip from 'node-stream-zip';
import path from 'path';
import tar from 'tar';
import { v4 as uuidv4 } from 'uuid';
import { getBuildLogsUrl } from '../build/utils/url';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
import {
BuildMetadataInput,
DistributionType,
LocalBuildArchiveSourceType,
UploadSessionType,
} from '../graphql/generated';
import { FingerprintMutation } from '../graphql/mutations/FingerprintMutation';
import { LocalBuildMutation } from '../graphql/mutations/LocalBuildMutation';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { promptAsync } from '../prompts';
import * as xcode from '../run/ios/xcode';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { fromNow } from '../utils/date';
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
import { getTmpDirectory } from '../utils/paths';
import { parseBinaryPlistBuffer } from '../utils/plist';
import { createProgressTracker } from '../utils/progress';
export default class BuildUpload extends EasCommand {
static override description = 'upload a local build and generate a sharable link';
static override flags = {
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
'build-path': Flags.string({
description: 'Path for the local build',
}),
fingerprint: Flags.string({
description: 'Fingerprint hash of the local build',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildUpload);
const {
'build-path': buildPath,
fingerprint: manualFingerprintHash,
json: jsonFlag,
'non-interactive': nonInteractive,
} = flags;
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildUpload, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const platform = await this.selectPlatformAsync({ platform: flags.platform, nonInteractive });
const localBuildPath = await resolveLocalBuildPathAsync({
platform,
inputBuildPath: buildPath,
nonInteractive,
});
const {
fingerprintHash: buildFingerprintHash,
developmentClient,
simulator,
...otherMetadata
} = await extractAppMetadataAsync(localBuildPath, platform);
let fingerprint = manualFingerprintHash ?? buildFingerprintHash;
if (fingerprint) {
if (
manualFingerprintHash &&
buildFingerprintHash &&
manualFingerprintHash !== buildFingerprintHash &&
!nonInteractive
) {
const selectedAnswer = await promptAsync({
name: 'fingerprint',
message: `The provided fingerprint hash ${manualFingerprintHash} does not match the fingerprint hash of the build ${buildFingerprintHash}. Which fingerprint do you want to use?`,
type: 'select',
choices: [
{ title: manualFingerprintHash, value: manualFingerprintHash },
{ title: buildFingerprintHash, value: buildFingerprintHash },
],
});
fingerprint = String(selectedAnswer.fingerprint);
}
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, {
hash: fingerprint,
});
}
Log.log(`Using build ${localBuildPath}`);
Log.log(`Fingerprint hash: ${fingerprint ?? 'Unknown'}`);
Log.log('Uploading your app archive to EAS');
const bucketKey = await uploadAppArchiveAsync(graphqlClient, localBuildPath);
const build = await LocalBuildMutation.createLocalBuildAsync(
graphqlClient,
projectId,
{ platform: toAppPlatform(platform), simulator },
{ type: LocalBuildArchiveSourceType.Gcs, bucketKey },
{
distribution: DistributionType.Internal,
fingerprintHash: fingerprint,
developmentClient,
...otherMetadata,
}
);
if (jsonFlag) {
printJsonOnlyOutput({ url: getBuildLogsUrl(build) });
return;
}
Log.withTick(`Shareable link to the build: ${getBuildLogsUrl(build)}`);
}
private async selectPlatformAsync({
nonInteractive,
platform,
}: {
nonInteractive: boolean;
platform?: Platform;
}): Promise<Platform> {
if (nonInteractive && !platform) {
throw new Error('Platform must be provided in non-interactive mode');
}
if (platform) {
return platform;
}
const { resolvedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'resolvedPlatform',
choices: [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
],
});
return resolvedPlatform;
}
}
async function resolveLocalBuildPathAsync({
platform,
inputBuildPath,
nonInteractive,
}: {
platform: Platform;
inputBuildPath?: string;
nonInteractive: boolean;
}): Promise<string> {
const rootDir = process.cwd();
let applicationArchivePatternOrPath: string[] = [];
if (inputBuildPath) {
applicationArchivePatternOrPath.push(inputBuildPath);
} else if (platform === Platform.ANDROID) {
applicationArchivePatternOrPath.push('android/app/build/outputs/**/*.{apk,aab}');
} else {
const xcworkspacePath = await xcode.resolveXcodeProjectAsync(rootDir);
const schemes = IOSConfig.BuildScheme.getRunnableSchemesFromXcodeproj(rootDir);
if (xcworkspacePath && schemes.length > 0) {
for (const scheme of schemes) {
const buildSettings = await xcode.getXcodeBuildSettingsAsync(xcworkspacePath, scheme.name);
applicationArchivePatternOrPath = applicationArchivePatternOrPath.concat(
buildSettings.map(({ buildSettings }) => `${buildSettings.BUILD_DIR}/**/*.app`)
);
}
}
}
let applicationArchives = await findArtifactsAsync({
rootDir,
patternOrPathArray: applicationArchivePatternOrPath,
});
if (applicationArchives.length === 0 && !nonInteractive && !inputBuildPath) {
Log.warn(`No application archives found at ${applicationArchivePatternOrPath}.`);
const { path } = await promptAsync({
type: 'text',
name: 'path',
message: 'Provide a path to the application archive:',
validate: value => (value ? true : 'Path may not be empty.'),
});
applicationArchives = await findArtifactsAsync({
rootDir,
patternOrPathArray: [path],
});
}
if (applicationArchives.length === 1) {
return applicationArchives[0];
}
if (applicationArchives.length > 1) {
// sort by modification time
const applicationArchivesInfo = await Promise.all(
applicationArchives.map(async archivePath => ({
path: archivePath,
stat: await fs.stat(archivePath),
}))
);
applicationArchivesInfo.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
if (nonInteractive) {
return applicationArchivesInfo[0].path;
}
const { selectedPath } = await promptAsync({
type: 'select',
name: 'selectedPath',
message: 'Found multiple application archives. Select one:',
choices: applicationArchivesInfo.map(archive => {
return {
title: `${
archive.path.startsWith(rootDir) ? path.relative(rootDir, archive.path) : archive.path
} (${fromNow(archive.stat.mtime)} ago)`,
value: archive.path,
};
}),
});
return selectedPath;
}
throw new Error(`Found no application archives at ${inputBuildPath}.`);
}
async function findArtifactsAsync({
rootDir,
patternOrPathArray,
}: {
rootDir: string;
patternOrPathArray: string[];
}): Promise<string[]> {
const files = new Set<string>();
for (const patternOrPath of patternOrPathArray) {
if (path.isAbsolute(patternOrPath) && (await fs.pathExists(patternOrPath))) {
files.add(patternOrPath);
} else {
const filesFound = await fg(patternOrPath, {
cwd: rootDir,
onlyFiles: false,
});
filesFound.forEach(file => files.add(file));
}
}
return [...files].map(filePath => {
// User may provide an absolute path as input in which case
// fg will return an absolute path.
if (path.isAbsolute(filePath)) {
return filePath;
}
// User may also provide a relative path in which case
// fg will return a path relative to rootDir.
return path.join(rootDir, filePath);
});
}
async function uploadAppArchiveAsync(
graphqlClient: ExpoGraphqlClient,
originalPath: string
): Promise<string> {
let filePath = originalPath;
if ((await fs.stat(filePath)).isDirectory()) {
await fs.mkdirp(getTmpDirectory());
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
const parentPath = path.dirname(originalPath);
const folderName = path.basename(originalPath);
await tar.create({ cwd: parentPath, file: tarPath, gzip: true }, [folderName]);
filePath = tarPath;
}
const fileSize = (await fs.stat(filePath)).size;
const bucketKey = await uploadFileAtPathToGCSAsync(
graphqlClient,
UploadSessionType.EasShareGcsAppArchive,
filePath,
createProgressTracker({
total: fileSize,
message: 'Uploading to EAS',
completedMessage: 'Uploaded to EAS',
})
);
return bucketKey;
}
function getInfoPlistMetadata(infoPlist: any): {
appName?: string;
appIdentifier?: string;
simulator: boolean;
} {
const appName = infoPlist?.CFBundleDisplayName ?? infoPlist?.CFBundleName;
const appIdentifier = infoPlist?.CFBundleIdentifier;
const simulator = infoPlist?.DTPlatformName?.includes('simulator');
return {
appName,
appIdentifier,
simulator,
};
}
async function extractAppMetadataAsync(
buildPath: string,
platform: Platform
): Promise<{ developmentClient: boolean; simulator: boolean } & BuildMetadataInput> {
let developmentClient = false;
let fingerprintHash: string | undefined;
// By default, we assume the iOS apps are for simulators
let simulator = platform === Platform.IOS;
let appName: string | undefined;
let appIdentifier: string | undefined;
const basePath = platform === Platform.ANDROID ? 'assets/' : buildPath;
const fingerprintFilePath =
platform === Platform.ANDROID ? 'fingerprint' : 'EXUpdates.bundle/fingerprint';
const devMenuBundlePath =
platform === Platform.ANDROID ? 'EXDevMenuApp.android.js' : 'EXDevMenu.bundle/';
const buildExtension = path.extname(buildPath);
if (['.apk', '.aab'].includes(buildExtension)) {
const zip = new StreamZip.async({ file: buildPath });
try {
developmentClient = Boolean(await zip.entry(path.join(basePath, devMenuBundlePath)));
if (await zip.entry(path.join(basePath, fingerprintFilePath))) {
fingerprintHash = (await zip.entryData(path.join(basePath, fingerprintFilePath))).toString(
'utf-8'
);
}
} catch (err) {
Log.error(`Error reading ${buildExtension}: ${err}`);
} finally {
await zip.close();
}
} else if (buildExtension === '.app') {
developmentClient = await fs.exists(path.join(basePath, devMenuBundlePath));
if (await fs.exists(path.join(basePath, 'Info.plist'))) {
const infoPlistBuffer = await fs.readFile(path.join(basePath, 'Info.plist'));
const infoPlist = parseBinaryPlistBuffer(infoPlistBuffer);
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
}
if (await fs.exists(path.join(basePath, fingerprintFilePath))) {
fingerprintHash = await fs.readFile(path.join(basePath, fingerprintFilePath), 'utf8');
}
} else if (buildExtension === '.ipa') {
const zip = new StreamZip.async({ file: buildPath });
try {
const entries = await zip.entries();
const entriesKeys = Object.keys(entries);
await Promise.all(
entriesKeys.map(async path => {
const infoPlistRegex = /^Payload\/[^/]+\.app\/Info\.plist$/;
if (infoPlistRegex.test(path)) {
const infoPlistBuffer = await zip.entryData(entries[path]);
const infoPlist = parseBinaryPlistBuffer(infoPlistBuffer);
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
return;
}
if (path.includes('/EXDevMenu.bundle')) {
developmentClient = true;
return;
}
if (path.includes('EXUpdates.bundle/fingerprint')) {
fingerprintHash = (await zip.entryData(entries[path])).toString('utf-8');
}
})
);
} catch (err) {
Log.error(`Error reading ${buildExtension}: ${err}`);
} finally {
await zip.close();
}
} else {
// Use tar to list files in the archive
try {
let fingerprintHashPromise: Promise<string> | undefined;
let infoPlistPromise: Promise<Buffer> | undefined;
await tar.list({
file: buildPath,
// eslint-disable-next-line async-protect/async-suffix
onentry: entry => {
if (entry.path.endsWith(devMenuBundlePath)) {
developmentClient = true;
}
if (entry.path.endsWith(fingerprintFilePath)) {
fingerprintHashPromise = new Promise<string>(async (resolve, reject) => {
try {
let content = '';
for await (const chunk of entry) {
content += chunk.toString('utf8');
}
resolve(content);
} catch (error) {
reject(error);
}
});
}
if (entry.path.endsWith('Info.plist')) {
infoPlistPromise = new Promise<Buffer>(async (resolve, reject) => {
try {
const chunks: Buffer[] = [];
for await (const chunk of entry) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);
resolve(content);
} catch (error) {
reject(error);
}
});
}
},
});
if (fingerprintHashPromise !== undefined) {
fingerprintHash = await fingerprintHashPromise;
}
if (infoPlistPromise !== undefined) {
const infoPlist = parseBinaryPlistBuffer(await infoPlistPromise);
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
}
} catch (err) {
Log.error(`Error reading ${buildExtension}: ${err}`);
}
}
return {
developmentClient,
fingerprintHash,
simulator,
appName,
appIdentifier,
};
}
@@ -1,243 +0,0 @@
import { parseInputs, parseJsonInputs, parseWorkflowInputsFromYaml } from '../run';
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
const inputs = parseInputs(['key=value']);
expect(inputs).toEqual({ key: 'value' });
});
it('should parse multiple key=value pairs', () => {
const inputs = parseInputs(['key1=value1', 'key2=value2', 'key3=value3']);
expect(inputs).toEqual({
key1: 'value1',
key2: 'value2',
key3: 'value3',
});
});
it('should handle empty value', () => {
const inputs = parseInputs(['key=']);
expect(inputs).toEqual({ key: '' });
});
it('should handle values with equals signs', () => {
const inputs = parseInputs(['key=value=with=equals']);
expect(inputs).toEqual({ key: 'value=with=equals' });
});
it('should handle values with spaces', () => {
const inputs = parseInputs(['key=value with spaces']);
expect(inputs).toEqual({ key: 'value with spaces' });
});
it('should handle special characters in values', () => {
const inputs = parseInputs(['key=value!@#$%^&*()']);
expect(inputs).toEqual({ key: 'value!@#$%^&*()' });
});
it('should throw error for invalid format without equals', () => {
expect(() => parseInputs(['invalid_format'])).toThrow(
'Invalid input format: invalid_format. Expected key=value format.'
);
});
it('should throw error for empty key', () => {
expect(() => parseInputs(['=value'])).toThrow(
'Invalid input format: =value. Key cannot be empty.'
);
});
it('should handle empty array', () => {
const inputs = parseInputs([]);
expect(inputs).toEqual({});
});
it('should handle mix of valid and complex inputs', () => {
const inputs = parseInputs([
'simple=value',
'complex=key=value=with=multiple=equals',
'empty=',
'spaces=value with spaces',
]);
expect(inputs).toEqual({
simple: 'value',
complex: 'key=value=with=multiple=equals',
empty: '',
spaces: 'value with spaces',
});
});
});
describe('parseJsonInputs', () => {
it('should parse simple JSON object', () => {
const json = '{"key": "value", "number": 42, "bool": true}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
key: 'value',
number: 42,
bool: true,
});
});
it('should handle complex objects', () => {
const json = '{"obj": {"nested": "value"}, "arr": [1, 2, 3]}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
obj: { nested: 'value' },
arr: [1, 2, 3],
});
});
it('should handle empty object', () => {
const json = '{}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({});
});
it('should handle string values with special characters', () => {
const json =
'{"special": "value with spaces", "equals": "key=value", "quotes": "value\\"with\\"quotes"}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
special: 'value with spaces',
equals: 'key=value',
quotes: 'value"with"quotes',
});
});
it('should throw error for invalid JSON', () => {
const invalidJson = '{"invalid": json}';
expect(() => parseJsonInputs(invalidJson)).toThrow('Invalid JSON input.');
});
it('should throw error for non-object JSON', () => {
expect(() => parseJsonInputs('["array"]')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('"string"')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('42')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('null')).toThrow('Invalid JSON input.');
});
});
describe('parseWorkflowInputsFromYaml', () => {
it('should parse workflow inputs from YAML', () => {
const yamlConfig = `
on:
workflow_dispatch:
inputs:
environment:
type: string
required: true
description: "Environment to deploy to"
debug:
type: boolean
default: false
description: "Enable debug mode"
version:
type: number
required: true
description: "Version number"
deployment_type:
type: choice
options: ["staging", "production"]
default: "staging"
description: "Type of deployment"
target_env:
type: environment
default: "preview"
description: "Target environment"
jobs:
test:
steps:
- run: echo "test"
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({
environment: {
type: 'string',
required: true,
description: 'Environment to deploy to',
},
debug: {
type: 'boolean',
required: false,
default: false,
description: 'Enable debug mode',
},
version: {
type: 'number',
required: true,
description: 'Version number',
},
deployment_type: {
type: 'choice',
required: false,
default: 'staging',
options: ['staging', 'production'],
description: 'Type of deployment',
},
target_env: {
type: 'environment',
required: false,
default: 'preview',
description: 'Target environment',
},
});
});
it('should handle YAML without workflow_dispatch inputs', () => {
const yamlConfig = `
on:
push:
branches: [main]
jobs:
test:
steps:
- run: echo "test"
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should handle empty YAML', () => {
const yamlConfig = '';
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should handle invalid YAML gracefully', () => {
const yamlConfig = 'invalid: yaml: content:';
const { parseWorkflowInputsFromYaml } = require('../run');
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should default type to string when not specified', () => {
const yamlConfig = `
on:
workflow_dispatch:
inputs:
simple_input:
description: "A simple input"
required: true
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs.simple_input).toEqual({
type: 'string',
description: 'A simple input',
required: true,
});
});
});
@@ -1,93 +0,0 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { choiceFromWorkflowRun, processWorkflowRuns } from '../../commandUtils/workflow/utils';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
export default class WorkflowRunCancel extends EasCommand {
static override description =
'Cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel.';
static override strict = false;
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override flags = {
...EASNonInteractiveFlag,
};
async runAsync(): Promise<void> {
const { argv } = await this.parse(WorkflowRunCancel);
let nonInteractive = false;
const workflowRunIds: Set<string> = new Set();
// Custom parsing of argv
const tokens: string[] = [...argv];
while (tokens.length > 0) {
const token = tokens.shift();
if (token === '--non-interactive') {
nonInteractive = true;
continue;
} else if (token) {
workflowRunIds.add(token);
}
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowRunCancel, {
nonInteractive,
});
if (workflowRunIds.size === 0) {
if (nonInteractive) {
throw new Error('Must supply workflow run IDs as arguments when in non-interactive mode');
}
// Run the workflow run list query and select runs to cancel
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
WorkflowRunStatus.InProgress,
50
);
const runs = processWorkflowRuns(queryResult);
if (runs.length === 0) {
Log.warn('No workflow runs to cancel');
return;
}
const answers = await promptAsync({
type: 'multiselect',
name: 'selectedRuns',
message: 'Select IN_PROGRESS workflow runs to cancel',
choices: runs.map(run => choiceFromWorkflowRun(run)),
});
answers.selectedRuns.forEach((id: string) => {
workflowRunIds.add(id);
});
if (workflowRunIds.size === 0) {
Log.warn('No workflow runs to cancel');
return;
}
}
Log.addNewLineIfNone();
for (const workflowRunId of workflowRunIds) {
try {
await WorkflowRunMutation.cancelWorkflowRunAsync(graphqlClient, {
workflowRunId,
});
Log.log(`Workflow run ${workflowRunId} has been canceled.`);
} catch (e: any) {
Log.error(`Failed to cancel workflow run ${workflowRunId}: ${e}`);
}
}
Log.addNewLineIfNone();
}
}
@@ -1,64 +0,0 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { WorkflowResult } from '../../commandUtils/workflow/types';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log from '../../log';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowList extends EasCommand {
static override hidden = true;
static override description = 'List workflows for the current project';
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(WorkflowList);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowList, {
nonInteractive: true,
});
if (flags.json) {
enableJsonOutput();
}
const workflows = await AppQuery.byIdWorkflowsAsync(graphqlClient, projectId);
const result: WorkflowResult[] = workflows.map(workflow => ({
id: workflow.id,
name: workflow.name,
fileName: workflow.fileName,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
}));
if (flags.json) {
printJsonOnlyOutput(result);
return;
}
Log.addNewLineIfNone();
result.forEach(workflow => {
Log.log(
formatFields([
{ label: 'ID', value: workflow.id },
{ label: 'Name', value: workflow.name ?? '-' },
{ label: 'File name', value: workflow.fileName },
{ label: 'Created At', value: workflow.createdAt },
{ label: 'Updated At', value: workflow.updatedAt },
])
);
Log.addNewLineIfNone();
});
}
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more