Compare commits

..
Author SHA1 Message Date
Dominik Sokal f33d859d75 random change 2022-07-15 11:13:39 +02:00
439 changed files with 10332 additions and 23568 deletions

No files matched your search

-1
View File
@@ -1,2 +1 @@
* text eol=lf
**/yarn.lock linguist-generated
+20
View File
@@ -0,0 +1,20 @@
name: Installer tests
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
jobs:
test:
name: Run the installer
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- run: bash bin/install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: eas --version
+1
View File
@@ -32,6 +32,7 @@ jobs:
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=4096
- run: yarn lint --max-warnings=0
- run: shellcheck bin/*.sh
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
+1 -2
View File
@@ -4,6 +4,5 @@
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "auto"
"arrowParens": "avoid"
}
-204
View File
@@ -10,216 +10,12 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
### 🧹 Chores
## [2.3.0](https://github.com/expo/eas-cli/releases/tag/v2.3.0) - 2022-10-10
### 🎉 New features
- Add new experimental resource class for M1 Macs. ([#1425](https://github.com/expo/eas-cli/pull/1425) by [@szdziedzic](https://github.com/szdziedzic))
## [2.2.1](https://github.com/expo/eas-cli/releases/tag/v2.2.1) - 2022-10-05
### 🐛 Bug fixes
- Make Apple Platform detection more robust. ([#1417](https://github.com/expo/eas-cli/pull/1417) by [@quinlanj](https://github.com/quinlanj))
- Fix creating EAS secret from file. ([#1423](https://github.com/expo/eas-cli/pull/1423) by [@dsokal](https://github.com/dsokal))
## [2.2.0](https://github.com/expo/eas-cli/releases/tag/v2.2.0) - 2022-10-04
### 🛠 Breaking changes
- Prompt before configuring EAS project. ([#1356](https://github.com/expo/eas-cli/pull/1356) by [@wschurman](https://github.com/wschurman))
- Adds paginated support to device commands. Removes the ability to delete multiple devices at once. ([#1381](https://github.com/expo/eas-cli/pull/1381) by [@kgc00](https://github.com/kgc00))
### 🎉 New features
- Add `-e` as a shortcut for `--profile` flag. ([#1342](https://github.com/expo/eas-cli/pull/1342) by [@szdziedzic](https://github.com/szdziedzic))
- Support JSON5 in **eas.json**. ([#1350](https://github.com/expo/eas-cli/pull/1350) by [@dsokal](https://github.com/dsokal))
- Add pagination and interactivity to update commands. ([#1323](https://github.com/expo/eas-cli/pull/1323) by [@kgc00](https://github.com/kgc00))
- Add support for uploading files to EAS Secret. ([#1354](https://github.com/expo/eas-cli/pull/1354) by [@dsokal](https://github.com/dsokal))
- Add pagination to channel commands. ([#1352](https://github.com/expo/eas-cli/pull/1352) by [@kgc00](https://github.com/kgc00))
- Add pagination to build commands. ([#1353](https://github.com/expo/eas-cli/pull/1353) by [@kgc00](https://github.com/kgc00))
- Provide suggestions for developers when archive size is large. ([#1363](https://github.com/expo/eas-cli/pull/1363) by [@szdziedzic](https://github.com/szdziedzic))
- Improve `eas init` command. ([#1376](https://github.com/expo/eas-cli/pull/1376) by [@wschurman](https://github.com/wschurman))
- Warn about outdated channel configuration before build when using eas update. ([#1397](https://github.com/expo/eas-cli/pull/1397) by [@kbrandwijk](https://github.com/kbrandwijk))
- Validate that owner and projectId and slug all are in alignment. ([#1405](https://github.com/expo/eas-cli/pull/1405) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- Fix automatic eas.json generation when running `eas build`. ([#1415](https://github.com/expo/eas-cli/pull/1415) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Standardize the GQL 'Update' schema using UpdateFragmentNode. ([#1348](https://github.com/expo/eas-cli/pull/1348) by [@kgc00](https://github.com/kgc00))
- Remove 'owner' app config field dependency. ([#1368](https://github.com/expo/eas-cli/pull/1368), [#1369](https://github.com/expo/eas-cli/pull/1369), [#1371](https://github.com/expo/eas-cli/pull/1371), [#1372](https://github.com/expo/eas-cli/pull/1372), [#1373](https://github.com/expo/eas-cli/pull/1373), by [@wschurman](https://github.com/wschurman))
- Add Xcode 14 image. ([#1365](https://github.com/expo/eas-cli/pull/1365) by [@szdziedzic](https://github.com/szdziedzic))
- Update max `versionCode` for Android. ([#1400](https://github.com/expo/eas-cli/pull/1400) by [@wkozyra95](https://github.com/wkozyra95))
- Add and use command context to declare command dependencies. ([#1383](https://github.com/expo/eas-cli/pull/1383), [#1384](https://github.com/expo/eas-cli/pull/1384), [#1387](https://github.com/expo/eas-cli/pull/1387), [#1388](https://github.com/expo/eas-cli/pull/1388), [#1390](https://github.com/expo/eas-cli/pull/1390), [#1391](https://github.com/expo/eas-cli/pull/1391), [#1394](https://github.com/expo/eas-cli/pull/1394), [#1402](https://github.com/expo/eas-cli/pull/1402), [#1403](https://github.com/expo/eas-cli/pull/1403), by [@wschurman](https://github.com/wschurman))
- Fix typo in the ad hoc build message. ([#1407](https://github.com/expo/eas-cli/pull/1407) by [@Simek](https://github.com/Simek))
- Improve errors and error messages formatting related to **eas.json**. ([#1414](https://github.com/expo/eas-cli/pull/1414) by [@Simek](https://github.com/Simek))
- Handle errors from platform-specific kill switches to disable free tier builds. ([#1401](https://github.com/expo/eas-cli/pull/1401) by [@szdziedzic](https://github.com/szdziedzic))
- Surface invalid eas.json errors in an optional project context. ([#1418](https://github.com/expo/eas-cli/pull/1418) by [@quinlanj](https://github.com/quinlanj))
- Improve message when project ID doesn't match either slug or owner. ([#1420](https://github.com/expo/eas-cli/pull/1420) by [@brentvatne](https://github.com/brentvatne))
## [2.1.0](https://github.com/expo/eas-cli/releases/tag/v2.1.0) - 2022-09-05
### 🎉 New features
- Add tvOS credentials compatibility for Adhoc and App store builds. ([#1325](https://github.com/expo/eas-cli/pull/1325) by [@quinlanj](https://github.com/quinlanj))
- Add `eas open` command for opening project page in web browser. ([#1337](https://github.com/expo/eas-cli/pull/1337) by [@dsokal](https://github.com/dsokal))
- Add support for buildArtifactPaths. Rename artifactPath to applicationArchivePath. ([#1321](https://github.com/expo/eas-cli/pull/1321) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Fix viewing branch list when a branch has no associated platforms. ([#1326](https://github.com/expo/eas-cli/pull/1326) by [@hbiede](https://github.com/hbiede))
- Fix description of `help` command in help prompt. ([#1341](https://github.com/expo/eas-cli/pull/1341) by [@Simek](https://github.com/Simek))
- Resolve paths from the root when checking for gitignored values. ([#1336](https://github.com/expo/eas-cli/pull/1336) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Add noImplicitOverride to tsconfigs. ([#1327](https://github.com/expo/eas-cli/pull/1327) by [@wschurman](https://github.com/wschurman))
- Fix typos. ([#1340](https://github.com/expo/eas-cli/pull/1340) by [@hbiede](https://github.com/hbiede))
## [2.0.0](https://github.com/expo/eas-cli/releases/tag/v2.0.0) - 2022-09-01
### 🛠 Breaking changes
- Fix typo in `cli.promptToConfigurePushNotifications` field in **eas.json**. EAS CLI will throw an error if the old field name is in **eas.json**. ([#1332](https://github.com/expo/eas-cli/pull/1332) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Fix dynamic config update warning. ([#1322](https://github.com/expo/eas-cli/pull/1322) by [@wschurman](https://github.com/wschurman))
## [1.2.0](https://github.com/expo/eas-cli/releases/tag/v1.2.0) - 2022-08-29
### 🎉 New features
- Add 2022 Apple capabilities. ([#1307](https://github.com/expo/eas-cli/pull/1307) by [@EvanBacon](https://github.com/EvanBacon))
- Print warning when eas-cli is installed as project dependency. ([#1310](https://github.com/expo/eas-cli/pull/1310) by [@dsokal](https://github.com/dsokal))
- Add eas.json to skip push notifications credentials setup. ([#1315](https://github.com/expo/eas-cli/pull/1315) by [@dsokal](https://github.com/dsokal))
- Warn about EAS outages when running `build`, `submit`, and `update` commands. ([#1312](https://github.com/expo/eas-cli/pull/1312) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- Replace promptToCreateProjectIfNotExistsAsync with getProjectIdAsync. ([#1303](https://github.com/expo/eas-cli/pull/1303) by [@wschurman](https://github.com/wschurman))
- Use AppQuery instead of ProjectQuery. ([#1304](https://github.com/expo/eas-cli/pull/1304) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Improve instructions on setting `extra.eas.projectId` in app configuration. ([#1316](https://github.com/expo/eas-cli/pull/1316) by [@dsokal](https://github.com/dsokal))
- Improve copy in EAS Submit - "e-mail" -> email, and make the link to app on App Store go directly to the TestFlight tab. ([#1318](https://github.com/expo/eas-cli/pull/1318) by [@brentvatne](https://github.com/brentvatne))
## [1.1.1](https://github.com/expo/eas-cli/releases/tag/v1.1.1) - 2022-08-23
### 🎉 New features
- Add support for `autoIncrement` option at the root of the build profile. ([#1298](https://github.com/expo/eas-cli/pull/1298) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- Handle trailing backslash in `.gitignore`. ([#1306](https://github.com/expo/eas-cli/pull/1306) by [@wkozyra95](https://github.com/wkozyra95))
- Retry ASC Api Key downloads if it has not fully propagated on Apple's infrastructure. ([#1302](https://github.com/expo/eas-cli/pull/1302) by [@quinlanj](https://github.com/quinlanj))
## [1.1.0](https://github.com/expo/eas-cli/releases/tag/v1.1.0) - 2022-08-23
### 🎉 New features
- Add support for passing platform flag to `credentials` command. ([#1277](https://github.com/expo/eas-cli/pull/1277) by [@Simek](https://github.com/Simek))
### 🐛 Bug fixes
- Prevent throwing dynamic app config write error when configuring project ID. ([#1301](https://github.com/expo/eas-cli/pull/1301) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Remove unused dependency and devDependency from the CLI. ([#1297](https://github.com/expo/eas-cli/pull/1297) by [@Simek](https://github.com/Simek))
## [1.0.0](https://github.com/expo/eas-cli/releases/tag/v1.0.0) - 2022-08-22
### 🛠 Breaking changes
- Remove timeout when waiting for build / submission. ([#1289](https://github.com/expo/eas-cli/pull/1289) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
- Add metadata support for dynamic store.config.js files. ([#1270](https://github.com/expo/eas-cli/pull/1270) by [@byCedric](https://github.com/byCedric))
- Improve reliability of update asset presigned upload requests. ([#1278](https://github.com/expo/eas-cli/pull/1278) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- Rebind `console.info` correctly after `ora` instance stops. ([#1113](https://github.com/expo/eas-cli/pull/1113) by [@EvanBacon](https://github.com/EvanBacon))
- Fix initializing git repository in monorepo. ([#1279](https://github.com/expo/eas-cli/pull/1279) by [@dsokal](https://github.com/dsokal))
- Limit the number of SignedAssetUploadSpecifications fetched at a time. ([#1287](https://github.com/expo/eas-cli/pull/1287) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Remove unused install script. ([#1280](https://github.com/expo/eas-cli/pull/1280), [#1281](https://github.com/expo/eas-cli/pull/1281) by [@wkozyra95](https://github.com/wkozyra95))
- Remove "please" from output ([#1250](https://github.com/expo/eas-cli/pull/1250) by [@jonsamp](https://github.com/jonsamp))
## [0.60.0](https://github.com/expo/eas-cli/releases/tag/v0.60.0) - 2022-08-12
### 🎉 New features
- Add `appVersion` runtime policy. ([#1267](https://github.com/expo/eas-cli/pull/1267) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Add JSON Schema `metadataPath` to iOS submission profile. ([#1269](https://github.com/expo/eas-cli/pull/1269) by [@byCedric](https://github.com/byCedric))
- Add warning when `appVersion` runtime policy is not supported. ([#1271](https://github.com/expo/eas-cli/pull/1271) by [@wkozyra95](https://github.com/wkozyra95))
## [0.59.0](https://github.com/expo/eas-cli/releases/tag/v0.59.0) - 2022-08-10
### 🐛 Bug fixes
- Fix building Android projects locally that don't have execute permissions set for `gradlew`. ([82231](https://github.com/expo/eas-cli/commit/822313e90e94bd6ddd3872061a0c25a8aa4db7cd) by [@dsokal](https://github.com/dsokal))
- Disable `nativeVersion` policy only for remote version source. ([#1261](https://github.com/expo/eas-cli/pull/1261) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Improve wording on version auto-increment. ([#1260](https://github.com/expo/eas-cli/pull/1260) by [@wkozyra95](https://github.com/wkozyra95))
- Upgrade dependencies. ([#1262](https://github.com/expo/eas-cli/pull/1262) by [@dsokal](https://github.com/dsokal))
## [0.58.0](https://github.com/expo/eas-cli/releases/tag/v0.58.0) - 2022-08-09
### 🎉 New features
- Managed app versions support. ([#1209](https://github.com/expo/eas-cli/pull/1209), [#1219](https://github.com/expo/eas-cli/pull/1219), [#1232](https://github.com/expo/eas-cli/pull/1232) by [@wkozyra95](https://github.com/wkozyra95))
- Add a warning when publishing an update with too many assets. ([#1243](https://github.com/expo/eas-cli/pull/1243) by [@kgc00](https://github.com/kgc00))
- Add submission info when building with `--json` option. ([#1246](https://github.com/expo/eas-cli/pull/1246) by [@wkozyra95](https://github.com/wkozyra95))
- Set all environment variables (documented in https://docs.expo.dev/build-reference/variables/) when running local build. ([#1256](https://github.com/expo/eas-cli/pull/1256) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Do not include sources in `eas-json` npm package. ([#1248](https://github.com/expo/eas-cli/pull/1248) by [@wkozyra95](https://github.com/wkozyra95))
- Swallow error when unable to track file in no commit workflow. ([#1109](https://github.com/expo/eas-cli/pull/1109) by [@brentvatne](https://github.com/brentvatne))
## [0.57.0](https://github.com/expo/eas-cli/releases/tag/v0.57.0) - 2022-08-03
### 🎉 New features
- Add new option to `eas device:create` - allow importing devices from Apple Developer Portal. ([#1236](https://github.com/expo/eas-cli/pull/1236) by [@dsokal](https://github.com/dsokal))
- Add support for attaching messages to builds. ([#1237](https://github.com/expo/eas-cli/pull/1237) by [@dsokal](https://github.com/dsokal))
- Introduce interactive pagination for `branch` commands. ([#1213](https://github.com/expo/eas-cli/pull/1213) by [@kgc00](https://github.com/kgc00))
## [0.56.0](https://github.com/expo/eas-cli/releases/tag/v0.56.0) - 2022-07-28
### 🎉 New features
- Display build queue type when waiting. ([#1217](https://github.com/expo/eas-cli/pull/1217) by [@dsokal](https://github.com/dsokal))
- Add better message on how to upgrade EAS CLI. ([#1222](https://github.com/expo/eas-cli/pull/1222) by [@jeremybarbet](https://github.com/jeremybarbet), [#1231](https://github.com/expo/eas-cli/pull/1231) by [@wkozyra95](https://github.com/wkozyra95))
- Invoke export with sourcemaps on update. ([#1228](https://github.com/expo/eas-cli/pull/1228) by [@kbrandwijk](https://github.com/kbrandwijk))
### 🐛 Bug fixes
- Show spinner instead of silently timing out during asset upload. ([#1206](https://github.com/expo/eas-cli/pull/1206) by [@wschurman](https://github.com/wschurman))
- Fix build archive S3 URLs. ([#1207](https://github.com/expo/eas-cli/pull/1207) by [@dsokal](https://github.com/dsokal))
- `autoIncrement` option on iOS will update versions of all targets that depend on an application target. ([#1219](https://github.com/expo/eas-cli/pull/1219) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Increase max number of chained "extends" in build profiles. ([#1208](https://github.com/expo/eas-cli/pull/1208) by [@wkozyra95](https://github.com/wkozyra95))
- Change wording on build warning for bundle id/package name. ([#1211](https://github.com/expo/eas-cli/pull/1211) by [@kbrandwijk](https://github.com/kbrandwijk))
## [0.55.1](https://github.com/expo/eas-cli/releases/tag/v0.55.1) - 2022-07-12
+2 -5
View File
@@ -3,11 +3,8 @@
packages/eas-cli/src/build @dsokal @wkozyra95
packages/eas-cli/src/commands/build @dsokal @wkozyra95
packages/eas-cli/src/submit @dsokal
packages/eas-cli/src/commands/submit.ts @dsokal
packages/eas-cli/src/update @kgc00 @wschurman
packages/eas-cli/src/commands/update @kgc00 @wschurman
packages/eas-cli/src/submit @barthap @dsokal
packages/eas-cli/src/commands/submit.ts @barthap @dsokal
packages/eas-cli/schema @bycedric
packages/eas-cli/src/metadata @bycedric
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# This script downloads the latest release version of EAS CLI from GitHub and
# installs it in /usr/local/bin
{
# All code is inside a block to ensure the script executes only when
# downloaded completely.
set -euo pipefail
prefix="${EAS_PREFIX:-/usr/local}"
temp_dir="$(mktemp -d)"
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]
then
# Use colors when stdout is a terminal and NO_COLOR isn't set.
text_bold=$'\033[1m'
text_normal=$'\033[22m'
text_red=$'\033[31m'
text_default_color=$'\033[39m'
else
# Disable colors
text_bold=""
text_normal=""
text_red=""
text_default_color=""
fi
abort() {
echo -e "$text_bold$text_red$1$text_normal$text_default_color"
exit 1
}
install() {
needs_sudo=""
# Make sure path exists and can be written to by the current user.
if ! mkdir -p "$prefix/lib" || [[ ! -w "$prefix/lib" ]]
then
needs_sudo=true
fi
if ! mkdir -p "$prefix/bin" || [[ ! -w "$prefix/bin" ]]
then
needs_sudo=true
fi
if ! rm -rf "$prefix/lib/eas"
then
needs_sudo=true
fi
# Run commands with sudo if necessary.
if [ -n "$needs_sudo" ]
then
if [ "$EUID" -ne 0 ]
then
echo "Installing requires superuser access."
echo "The sudo command will prompt for your password."
fi
sudo rm -rf "$prefix/lib/eas"
sudo mv "$temp_dir/eas" "$prefix/lib/eas"
sudo ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
else
mv "$temp_dir/eas" "$prefix/lib/eas"
ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
fi
}
if [[ ! ":$PATH:" == *":$prefix/bin:"* ]]; then
abort "Your path is missing $prefix/bin, you need to add this to \$PATH use this installer."
fi
hardware="$(uname -m)"
case "$hardware" in
x86_64)
arch="x64"
;;
arm*)
arch="arm"
;;
*)
abort "Unsupported CPU architecture $hardware."
;;
esac
case "$(uname -s)" in
Darwin*)
platform="darwin"
if [[ "$arch" != "x64" ]]; then
abort "Unsupported CPU architecture $hardware on macOS."
fi
;;
Linux*)
platform="linux"
;;
*)
abort "This installer is supported only on macOS, Linux, and Windows Subsystem for Linux."
;;
esac
url=$(hub release -L 1 | xargs hub release show --show-downloads | grep "${platform}-${arch}.tar.gz")
echo "Installing EAS CLI from $url"
mkdir -p "$temp_dir"
cd "$temp_dir"
echo
echo "Downloading..."
if [ "$(command -v curl)" ]
then
curl --location --progress-bar "$url" | tar xz
else
wget -O- "$url" | tar xz
fi
# Move the installation over and link the executable (may require sudo)
install
# Test the bundled node binary and if it doesn't work remove it (fall back to
# node installed in path).
"$prefix/lib/eas/bin/node" -v > /dev/null || {
rm "$prefix/lib/eas/bin/node"
}
# Test the CLI
executable=$(command -v eas) || abort "Could not run eas after installation."
echo
echo -e "EAS CLI is installed to ${executable}"
eas --version
echo
echo -e "Type ${text_bold}eas --help${text_normal} to get started."
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "2.3.0"
"version": "0.55.1"
}
+12 -12
View File
@@ -17,22 +17,22 @@
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@types/jest": "28.1.6",
"@types/node": "18.6.5",
"eslint": "8.21.0",
"eslint-config-universe": "11.1.0",
"eslint-plugin-async-protect": "2.1.0",
"@types/jest": "27.5.0",
"@types/node": "17.0.32",
"eslint": "8.15.0",
"eslint-config-universe": "11.0.0",
"eslint-plugin-async-protect": "1.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "28.1.3",
"jest-watch-typeahead": "2.0.0",
"jest": "28.1.0",
"jest-watch-typeahead": "1.1.0",
"lerna": "4.0.0",
"oclif": "3.1.2",
"prettier": "2.7.1",
"ts-jest": "28.0.7",
"oclif": "3.0.1",
"prettier": "2.6.2",
"ts-jest": "28.0.2",
"typescript": "4.7.4"
},
"volta": {
"node": "18.6.0",
"yarn": "1.22.19"
"node": "18.2.0",
"yarn": "1.22.18"
}
}
-18
View File
@@ -2,24 +2,6 @@ const rootEslintrc = require('../../.eslintrc.js');
module.exports = {
rules: {
'no-restricted-properties': [
'warn',
{
object: 'it',
property: 'only',
message: 'it.only should not be committed to main.',
},
{
object: 'test',
property: 'only',
message: 'test.only should not be committed to main.',
},
{
object: 'describe',
property: 'only',
message: 'describe.only should not be committed to main.',
},
],
'no-restricted-imports': [
'error',
{
+182 -315
View File
File diff suppressed because it is too large. Load diff
@@ -1,15 +1,13 @@
const fs = require('fs/promises');
#!/usr/bin/env sh
const path = './src/graphql/generated.ts';
OUTFILE="src/graphql/generated.ts"
(async () => {
const generatedCode = await fs.readFile(path, 'utf8');
const comment = `/**
content="/**
* This file was generated using GraphQL Codegen
* Command: yarn generate-graphql-code
* Run this during development for automatic type generation when editing GraphQL documents
* For more info and docs, visit https://graphql-code-generator.com/
*/\n\n`;
*/
"
await fs.writeFile(path, comment + generatedCode);
})();
printf '%s\n%s\n' "$content" "$(cat $OUTFILE)" > $OUTFILE
+1 -1
View File
@@ -14,7 +14,7 @@ generates:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
- ./annotate-graphql-codegen.sh
./graphql.schema.json:
plugins:
- 'introspection'
+1648 -6319
View File
File diff suppressed because it is too large. Load diff
+34 -42
View File
@@ -1,40 +1,38 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "2.3.0",
"version": "0.55.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.34",
"@expo/apple-utils": "0.0.0-alpha.33",
"@expo/code-signing-certificates": "0.0.2",
"@expo/config": "7.0.1",
"@expo/config-plugins": "5.0.1",
"@expo/config-types": "46.0.2",
"@expo/eas-build-job": "0.2.94",
"@expo/eas-json": "2.2.0",
"@expo/config": "6.0.23",
"@expo/config-plugins": "4.1.4",
"@expo/config-types": "45.0.0",
"@expo/eas-build-job": "0.2.77",
"@expo/eas-json": "0.55.0",
"@expo/json-file": "8.2.36",
"@expo/multipart-body-parser": "1.1.0",
"@expo/package-manager": "0.0.56",
"@expo/pkcs12": "0.0.8",
"@expo/plist": "0.0.18",
"@expo/plugin-autocomplete": "1.4.0",
"@expo/plugin-help": "5.3.0",
"@expo/plugin-help": "5.2.0",
"@expo/plugin-warn-if-update-available": "2.3.0",
"@expo/prebuild-config": "5.0.3",
"@expo/prebuild-config": "3.1.6",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/spawn-async": "1.6.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.13.10",
"@urql/core": "2.6.1",
"@oclif/core": "1.9.0",
"@urql/core": "2.4.4",
"@urql/exchange-retry": "0.3.3",
"ajv": "^6.12.6",
"better-opn": "3.0.2",
"chalk": "4.1.2",
"cli-progress": "3.11.2",
"cli-progress": "3.11.0",
"cli-table3": "0.6.2",
"dateformat": "4.6.3",
"env-paths": "2.2.0",
@@ -56,7 +54,7 @@
"keychain": "1.3.0",
"log-symbols": "4.1.0",
"mime": "3.0.0",
"minimatch": "5.1.0",
"minimatch": "5.0.1",
"nanoid": "3.3.4",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
@@ -64,12 +62,12 @@
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"promise-limit": "2.7.0",
"promise-retry": "2.0.1",
"prompts": "2.4.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"semver": "7.3.7",
"slash": "3.0.0",
"strip-ansi": "6.0.0",
"tar": "6.1.11",
"terminal-link": "2.1.1",
"tslib": "2.4.0",
@@ -79,34 +77,32 @@
"wrap-ansi": "7.0.0"
},
"devDependencies": {
"@graphql-codegen/cli": "2.11.5",
"@graphql-codegen/introspection": "2.2.1",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@types/cli-progress": "3.11.0",
"@graphql-codegen/cli": "2.6.2",
"@graphql-codegen/introspection": "2.1.1",
"@graphql-codegen/typescript": "2.4.11",
"@graphql-codegen/typescript-operations": "2.4.0",
"@types/cli-progress": "3.9.2",
"@types/dateformat": "3.0.1",
"@types/envinfo": "7.8.1",
"@types/fs-extra": "9.0.13",
"@types/getenv": "^1.0.0",
"@types/mime": "2.0.3",
"@types/node-fetch": "2.6.2",
"@types/node-forge": "1.0.4",
"@types/promise-retry": "1.1.3",
"@types/node-fetch": "^2.5.7",
"@types/node-forge": "1.0.2",
"@types/prompts": "2.0.14",
"@types/semver": "7.3.10",
"@types/tar": "6.1.2",
"@types/semver": "7.3.9",
"@types/tar": "6.1.1",
"@types/tough-cookie": "4.0.2",
"@types/uuid": "8.3.4",
"@types/wrap-ansi": "3.0.0",
"axios": "0.27.2",
"eslint-plugin-graphql": "4.0.0",
"memfs": "3.4.7",
"form-data": "4.0.0",
"memfs": "3.4.3",
"mockdate": "3.0.5",
"nock": "13.2.9",
"rimraf": "3.0.2",
"ts-deepmerge": "2.0.5",
"ts-mockito": "2.6.1",
"ts-node": "10.9.1",
"nock": "13.2.4",
"tempy": "0.7.0",
"ts-deepmerge": "2.0.1",
"typescript": "4.7.4"
},
"engines": {
@@ -170,11 +166,11 @@
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run ') %><%= chalk.dim(chalk.bold('npm install -g eas-cli')) %><%= chalk.dim('.') %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
"message": "<%= chalk.bold('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available, please upgrade.') %>\n<%= chalk.dim('Proceeding with outdated version') %>\n"
},
"update": {
"node": {
"version": "18.6.0"
"version": "12.13.0"
},
"s3": {
"templates": {
@@ -194,8 +190,8 @@
},
"repository": "expo/eas-cli",
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "rimraf build && yarn build && node ./scripts/prepack.js",
"postpack": "rm -f oclif.manifest.json",
"prepack": "./scripts/prepack.sh",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "tsc --project tsconfig.build.json",
"watch": "yarn build --watch --preserveWatchOutput",
@@ -203,10 +199,6 @@
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"clean": "rimraf dist build tmp node_modules yarn-error.log"
},
"volta": {
"node": "18.6.0",
"yarn": "1.22.19"
"clean": "rm -rf dist build tmp node_modules yarn-error.log"
}
}
+234 -39
View File
@@ -3,6 +3,21 @@
"type": "object",
"definitions": {
"apple": {
"AppleLocalizedPreviewFolder": {
"anyOf": [
{
"description": "Path to folder containing preview files",
"type": "string"
},
{
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
}
}
]
},
"AppleKidsAge": {
"enum": [
"FIVE_AND_UNDER",
@@ -449,43 +464,62 @@
},
"AppleRelease": {
"type": "object",
"description": "Configure how the app is released in the app store.",
"additionalProperties": false,
"defaultSnippets": [
{
"body": {
"automaticRelease": true,
"usesThirdPartyContent": false,
"usesNonExemptEncryption": false
}
}
],
"properties": {
"automaticRelease": {
"description": "If and how the app should automatically be released when approved by App Store review.",
"markdownDescription": "If and how the app should automatically be released when approved by App Store review.\n- `false` - Manually release the app after store approval. (default behavior)\n- `true` - Automatically release after store approval.\n- `Date` - Automatically schedule release on this date, after store approval.\n\nApple does not guarantee that your app is available at the chosen scheduled release date.",
"oneOf": [
"autoReleaseDate": {
"description": "ISO Date to release the app automatically. Leave undefined to manually release the app to the public.",
"defaultSnippets": [
{
"body": "$CURRENT_YEAR-${1:$CURRENT_MONTH}-${2:$CURRENT_DATE}"
}
],
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "boolean"
}
],
"defaultSnippets": [
{
"label": "Manual release",
"description": "Manually release the app after store approval",
"body": false
"type": "string",
"format": "date"
},
{
"label": "Automatic release",
"description": "Automatically release the app after store approval",
"body": true
},
{
"label": "Scheduled release",
"description": "Automatically release the app on this date, and store approval",
"body": "$CURRENT_YEAR-${1:$CURRENT_MONTH}-${2:$CURRENT_DATE}T${3:$CURRENT_HOUR}:00:00+00:00"
"type": "number"
}
]
},
"phasedRelease": {
"isPhasedReleaseEnabled": {
"type": "boolean",
"description": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates. Keep in mind that this version will still be available to all users as a manual update from the App Store. You can pause the phased release for up to 30 days or release this update to all users at any time.\n@see https://help.apple.com/app-store-connect/#/dev3d65fcee1",
"markdownDescription": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates.\n\nKeep in mind that this version will still be available to all users as a manual update from the App Store.\n\nYou can pause the phased release for up to 30 days or release this update to all users at any time.\n\n[Learn More](https://help.apple.com/app-store-connect/#/dev3d65fcee1)"
"description": "Release update over 7-day period instead of instantly.",
"meta": {
"storeInfo": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates. Keep in mind that this version will still be available to all users as a manual update from the App Store. You can pause the phased release for up to 30 days or release this update to all users at any time. [Learn More](https://help.apple.com/app-store-connect/#/dev3d65fcee1)."
}
},
"shouldResetRatings": {
"type": "boolean",
"description": "Should App Store ratings be reset for this version of the app.",
"meta": {
"storeInfo": "You can reset your app’s summary rating for all countries or regions when you release this version. Keep in mind that once this version is released, you won’t be able to restore the rating. Your app’s existing customer reviews will still appear on the App Store."
}
},
"usesThirdPartyContent": {
"type": "boolean"
},
"automaticRelease": {
"type": "boolean",
"description": "Should the app automatically be released when approved by App Store review."
},
"usesNonExemptEncryption": {
"type": "boolean",
"description": "Alternative to setting `ITSAppUsesNonExemptEncryption` in the binary's `Info.plist`."
}
}
},
@@ -572,15 +606,11 @@
"description": "Configuration that is specific to the Apple App Store.",
"additionalProperties": false,
"properties": {
"version": {
"type": "string",
"description": "The version string to edit, or create, if it doesn't exist. All the metadata will be stored on this app version.",
"minLength": 1,
"defaultSnippets": [
{
"body": "${1:1}.${2:0}"
}
]
"advisory": {
"$ref": "#/definitions/apple/AppleAdvisory"
},
"categories": {
"$ref": "#/definitions/apple/AppleCategory"
},
"copyright": {
"type": "string",
@@ -596,12 +626,6 @@
"storeInfo": "The name of the person or entity that owns the exclusive rights to your app, preceded by the year the rights were obtained (for example, \"2008 Acme Inc.\"). Do not provide a URL."
}
},
"advisory": {
"$ref": "#/definitions/apple/AppleAdvisory"
},
"categories": {
"$ref": "#/definitions/apple/AppleCategory"
},
"info": {
"description": "Localized core app info",
"type": "object",
@@ -765,6 +789,177 @@
}
}
},
"preview": {
"anyOf": [
{
"description": "Path to localized folders containing preview files",
"type": "string"
},
{
"description": "Localized previews object. Ex: `\"en-US\": [\"image.png\"]`",
"type": "object",
"additionalProperties": false,
"properties": {
"ar-SA": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Arabic"
},
"ca": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Catalan"
},
"zh-Hans": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Chinese (Simplified)"
},
"zh-Hant": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Chinese (Traditional)"
},
"hr": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Croatian"
},
"cs": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Czech"
},
"da": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Danish"
},
"nl-NL": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Dutch"
},
"en-AU": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "English (Australia)"
},
"en-CA": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "English (Canada)"
},
"en-GB": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "English (U.K.)"
},
"en-US": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "English (U.S.)"
},
"fi": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Finnish"
},
"fr-FR": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "French"
},
"fr-CA": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "French (Canada)"
},
"de-DE": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "German"
},
"el": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Greek"
},
"he": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Hebrew"
},
"hi": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Hindi"
},
"hu": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Hungarian"
},
"id": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Indonesian"
},
"it": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Italian"
},
"ja": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Japanese"
},
"ko": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Korean"
},
"ms": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Malay"
},
"no": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Norwegian"
},
"pl": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Polish"
},
"pt-BR": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Portuguese (Brazil)"
},
"pt-PT": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Portuguese (Portugal)"
},
"ro": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Romanian"
},
"ru": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Russian"
},
"sk": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Slovak"
},
"es-MX": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Spanish (Mexico)"
},
"es-ES": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Spanish (Spain)"
},
"sv": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Swedish"
},
"th": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Thai"
},
"tr": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Turkish"
},
"uk": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Ukrainian"
},
"vi": {
"$ref": "#/definitions/apple/AppleLocalizedPreviewFolder",
"description": "Vietnamese"
}
}
}
]
},
"release": {
"$ref": "#/definitions/apple/AppleRelease",
"description": "Release strategy"
+1 -1
View File
@@ -1,4 +1,4 @@
const fs = require('fs/promises');
const { promises: fs } = require('fs');
(async () => {
// Patch `oclif readme` path and link generation
-7
View File
@@ -1,7 +0,0 @@
const spawn = require('@expo/spawn-async');
(async () => {
if (!process.env.CLI_SIZE_CHECK) {
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
}
})();
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -eo pipefail
rm -rf build
yarn build
if [ -z "$CLI_SIZE_CHECK" ]; then
yarn oclif manifest
fi
-23
View File
@@ -1,23 +0,0 @@
import { JSONValue } from '@expo/json-file';
export class ApiV2Error extends Error {
override readonly name = 'ApiV2Error';
readonly expoApiV2ErrorCode: string;
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
constructor(response: {
message: string;
code: string;
stack?: string;
details?: JSONValue;
metadata?: object;
}) {
super(response.message);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
this.expoApiV2ErrorMetadata = response.metadata;
}
}
+4 -9
View File
@@ -1,10 +1,9 @@
import assert from 'assert';
import nock from 'nock';
import { ApiV2Error } from '../ApiV2Error';
import { ApiV2Client, getExpoApiBaseUrl } from '../api';
import { ApiV2Error, api, getExpoApiBaseUrl } from '../api';
describe(ApiV2Client, () => {
describe('apiClient', () => {
it('converts Expo APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl())
.post('/v2/test')
@@ -22,9 +21,7 @@ describe(ApiV2Client, () => {
let error: Error | null = null;
try {
await new ApiV2Client({ accessToken: null, sessionSecret: null }).postAsync('test', {
body: {},
});
await api.postAsync('test', { body: {} });
} catch (e: any) {
error = e;
}
@@ -44,9 +41,7 @@ describe(ApiV2Client, () => {
let error: Error | null = null;
try {
await new ApiV2Client({ accessToken: null, sessionSecret: null }).postAsync('test', {
body: {},
});
await api.postAsync('test', { body: {} });
} catch (e: any) {
error = e;
}
@@ -0,0 +1,60 @@
import { Actor } from '../user/User';
interface MockProject {
projectRoot: string;
projectTree: Record<string, string>;
appJSON: AppJSON;
packageJSON: PackageJSON;
}
interface PackageJSON {
name: string;
version: string;
description: string;
main: string;
}
interface AppJSON {
expo: {
name: string;
version: string;
slug: string;
sdkVersion: string;
owner?: string;
[key: string]: any;
};
}
export function createTestProject(
user: Actor,
appJSONExtraData?: Record<string, object | string>
): MockProject {
const projectRoot = '/test-project';
const packageJSON: PackageJSON = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const owner = appJSONExtraData?.owner as string | undefined;
const appJSON: AppJSON = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
...appJSONExtraData,
},
};
return {
appJSON,
packageJSON,
projectRoot,
projectTree: {
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
},
};
}
-3
View File
@@ -15,9 +15,6 @@ export enum SubmissionEvent {
SUBMIT_REQUEST_ATTEMPT = 'submit cli request attempt',
SUBMIT_REQUEST_SUCCESS = 'submit cli request success',
SUBMIT_REQUEST_FAIL = 'submit cli request fail',
API_KEY_DOWNLOAD_FAIL = 'submit cli credentials api key download fail',
API_KEY_DOWNLOAD_RETRY = 'submit cli credentials api key download fail temporary',
API_KEY_DOWNLOAD_SUCCESS = 'submit cli credentials api key download succeed',
}
export enum BuildEvent {
+74 -59
View File
@@ -1,78 +1,93 @@
import { JSONValue } from '@expo/json-file';
import { ApiV2Error } from './ApiV2Error';
import fetch, { RequestError, RequestInit } from './fetch';
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
export class ApiV2Error extends Error {
readonly name = 'ApiV2Error';
readonly expoApiV2ErrorCode: string;
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
constructor(response: {
message: string;
code: string;
stack?: string;
details?: JSONValue;
metadata?: object;
}) {
super(response.message);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
this.expoApiV2ErrorMetadata = response.metadata;
}
}
interface RequestOptions {
body: JSONValue;
}
export class ApiV2Client {
constructor(
private readonly authInfo: {
accessToken: string | null;
sessionSecret: string | null;
}
) {}
export const api = {
async putAsync(path: string, options: RequestOptions): Promise<any> {
return await requestAsync(path, { method: 'PUT', body: JSON.stringify(options.body) });
},
public async putAsync(path: string, options: RequestOptions): Promise<any> {
return await this.requestAsync(path, { method: 'PUT', body: JSON.stringify(options.body) });
async postAsync(path: string, options: RequestOptions): Promise<any> {
return await requestAsync(path, { method: 'POST', body: JSON.stringify(options.body) });
},
async deleteAsync(path: string): Promise<any> {
return await requestAsync(path, { method: 'DELETE' });
},
async getAsync(path: string): Promise<any> {
return await requestAsync(path, { method: 'GET' });
},
};
async function requestAsync(path: string, options: RequestInit): Promise<any> {
try {
const response = await fetch(`${getExpoApiBaseUrl()}/v2/${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
});
return await response.json();
} catch (err) {
await handleApiErrorAsync(err);
}
}
public async postAsync(path: string, options: RequestOptions): Promise<any> {
return await this.requestAsync(path, { method: 'POST', body: JSON.stringify(options.body) });
}
public async deleteAsync(path: string): Promise<any> {
return await this.requestAsync(path, { method: 'DELETE' });
}
public async getAsync(path: string): Promise<any> {
return await this.requestAsync(path, { method: 'GET' });
}
private getAuthHeaders(): Record<string, string> {
const token = this.authInfo.accessToken;
if (token) {
return { authorization: `Bearer ${token}` };
}
const sessionSecret = this.authInfo.sessionSecret;
if (sessionSecret) {
return { 'expo-session': sessionSecret };
}
return {};
}
private async requestAsync(path: string, options: RequestInit): Promise<any> {
async function handleApiErrorAsync(err: any): Promise<void> {
if (err instanceof RequestError) {
let result: { [key: string]: any };
try {
const response = await fetch(`${getExpoApiBaseUrl()}/v2/${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
},
});
return await response.json();
} catch (err) {
await ApiV2Client.handleApiErrorAsync(err);
result = (await err.response.json()) as { [key: string]: any };
} catch {
throw new Error(`Malformed api response: ${await err.response.text()}`);
}
if (result.errors?.length) {
throw new ApiV2Error(result.errors[0]);
}
} else {
throw err;
}
}
private static async handleApiErrorAsync(err: any): Promise<void> {
if (err instanceof RequestError) {
let result: { [key: string]: any };
try {
result = (await err.response.json()) as { [key: string]: any };
} catch {
throw new Error(`Malformed api response: ${await err.response.text()}`);
}
if (result.errors?.length) {
throw new ApiV2Error(result.errors[0]);
}
} else {
throw err;
}
function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
if (token) {
return { authorization: `Bearer ${token}` };
}
const sessionSecret = getSessionSecret();
if (sessionSecret) {
return { 'expo-session': sessionSecret };
}
return {};
}
export function getExpoApiBaseUrl(): string {
-135
View File
@@ -1,135 +0,0 @@
import chalk from 'chalk';
import CliTable from 'cli-table3';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { UpdateBranchFragment } from '../graphql/generated';
import { BranchQuery } from '../graphql/queries/BranchQuery';
import Log from '../log';
import { UPDATE_COLUMNS, formatUpdateMessage, getPlatformsForGroup } from '../update/utils';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
export const BRANCHES_LIMIT = 50;
export async function selectBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
promptTitle,
displayTextForListItem,
paginatedQueryOptions,
}: {
projectId: string;
displayTextForListItem: (queryItem: UpdateBranchFragment) => string;
promptTitle: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<UpdateBranchFragment> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select a branch in non-interactive mode.');
}
const selectedBranch = await paginatedQueryWithSelectPromptAsync({
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
promptOptions: {
title: promptTitle,
getIdentifierForQueryItem: updateBranchFragment => updateBranchFragment.id,
createDisplayTextForSelectionPromptListItem: displayTextForListItem,
},
});
if (!selectedBranch) {
throw new Error(`Could not find any branches for project "${projectId}"`);
}
return selectedBranch;
}
export async function listAndRenderBranchesOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
paginatedQueryOptions,
}: {
projectId: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesOnProjectAsync(
graphqlClient,
paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
paginatedQueryOptions.offset,
projectId
);
renderPageOfBranches(branches, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
promptOptions: {
title: 'Load more branches?',
renderListItems: branches => renderPageOfBranches(branches, paginatedQueryOptions),
},
});
}
}
async function queryBranchesOnProjectAsync(
graphqlClient: ExpoGraphqlClient,
limit: number,
offset: number,
projectId: string
): Promise<UpdateBranchFragment[]> {
return await BranchQuery.listBranchesOnAppAsync(graphqlClient, {
appId: projectId,
limit,
offset,
});
}
function renderPageOfBranches(
currentPage: UpdateBranchFragment[],
{ json }: PaginatedQueryOptions
): void {
if (json) {
printJsonOnlyOutput(currentPage);
} else {
const table = new CliTable({
head: ['Branch', ...UPDATE_COLUMNS],
wordWrap: true,
});
table.push(
...currentPage.map(branch => {
if (branch.updates.length === 0) {
return [branch.name, 'N/A', 'N/A', 'N/A', 'N/A'];
}
const latestUpdateOnBranch = branch.updates[0];
return [
branch.name,
formatUpdateMessage(latestUpdateOnBranch),
latestUpdateOnBranch.runtimeVersion,
latestUpdateOnBranch.group,
getPlatformsForGroup({
group: latestUpdateOnBranch.group,
updates: branch.updates,
}),
];
})
);
Log.addNewLineIfNone();
Log.log(chalk.bold('Branches:'));
Log.addNewLineIfNone();
Log.log(table.toString());
}
}
-14
View File
@@ -1,14 +0,0 @@
import { getVcsClient } from '../vcs';
export async function getDefaultBranchNameAsync(): Promise<string> {
return (
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substring(2, 4)}`
);
}
export class BranchNotFoundError extends Error {
constructor(message?: string) {
super(message ?? 'Branch not found.');
}
}
@@ -1,89 +0,0 @@
import { instance, mock } from 'ts-mockito';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectBuildToCancelAsync } from '../../commands/build/cancel';
import { AppPlatform, BuildFragment, BuildPriority, BuildStatus } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { confirmAsync, selectAsync } from '../../prompts';
jest.mock('../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
jest.mock('../../prompts', () => {
return {
selectAsync: jest.fn(),
confirmAsync: jest.fn(),
};
});
jest.mock('../../graphql/queries/BuildQuery', () => {
const actual = jest.requireActual('../../graphql/queries/BuildQuery');
return {
BuildQuery: {
...actual.BuildQuery,
viewBuildsOnAppAsync: jest.fn(),
},
};
});
describe(selectBuildToCancelAsync.name, () => {
const selectedBuildId = uuid();
const projectId = uuid();
beforeEach(() => {
jest
.mocked(BuildQuery.viewBuildsOnAppAsync)
.mockImplementation(async () => [
createMockBuildFragment({ projectId, buildId: selectedBuildId }),
]);
});
it('does not return build id when confirmation is rejected', async () => {
jest.mocked(confirmAsync).mockResolvedValueOnce(false);
const graphqlClient = instance(mock<ExpoGraphqlClient>());
expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(null);
});
it('returns build id when confirmation is confirmed', async () => {
jest.mocked(selectAsync).mockResolvedValueOnce(selectedBuildId);
jest.mocked(confirmAsync).mockResolvedValueOnce(true);
const graphqlClient = instance(mock<ExpoGraphqlClient>());
expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(
selectedBuildId
);
});
});
function createMockBuildFragment({
projectId,
buildId,
}: {
projectId: string;
buildId?: string;
}): BuildFragment {
return {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
project: {
__typename: 'App',
slug: 'test-project',
id: projectId,
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InQueue,
};
}
@@ -1,14 +0,0 @@
import { truncateGitCommitMessage } from '../metadata';
describe(truncateGitCommitMessage, () => {
it('returns undefined if no message was passed', () => {
expect(truncateGitCommitMessage(undefined)).toBeUndefined();
});
it('returns commit message', () => {
expect(truncateGitCommitMessage('a'.repeat(10))).toBe('a'.repeat(10));
});
it('truncates long commit messages', () => {
expect(truncateGitCommitMessage('a'.repeat(5000))).toBe(`${'a'.repeat(4093)}...`);
});
});
@@ -73,39 +73,39 @@ describe(bumpVersionAsync, () => {
await bumpVersionAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('3.0.0');
expect(fakeExp.android?.versionCode).toBe(124);
expect(appJSON.expo.version).toBe('3.0.0');
expect(appJSON.expo.android.versionCode).toBe(124);
const buildGradle = await getAppBuildGradleAsync('/app');
const buildGradle = await getAppBuildGradleAsync('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('124');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
});
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.APP_VERSION', async () => {
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.SHORT_VERSION', async () => {
const nativeVersionName = '1.0.0'; // this should be overwritten by bumpVersionAsync
const fakeExp = initBareWorkflowProject({ versionName: nativeVersionName });
await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('3.0.1');
expect(fakeExp.android?.versionCode).toBe(123);
expect(appJSON.expo.version).toBe('3.0.1');
expect(appJSON.expo.android.versionCode).toBe(123);
const buildGradle = await getAppBuildGradleAsync('/app');
const buildGradle = await getAppBuildGradleAsync('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.1');
@@ -116,17 +116,17 @@ describe(bumpVersionAsync, () => {
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('3.0.0');
expect(fakeExp.android?.versionCode).toBe(123);
expect(appJSON.expo.version).toBe('3.0.0');
expect(appJSON.expo.android.versionCode).toBe(123);
const buildGradle = await getAppBuildGradleAsync('/app');
const buildGradle = await getAppBuildGradleAsync('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
@@ -140,11 +140,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('5.0.0');
expect(fakeExp.android?.versionCode).toBe(127);
expect(appJSON.expo.version).toBe('5.0.0');
@@ -156,11 +156,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('5.0.1');
expect(fakeExp.android?.versionCode).toBe(126);
expect(appJSON.expo.version).toBe('5.0.1');
@@ -172,11 +172,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('5.0.0');
expect(fakeExp.android?.versionCode).toBe(126);
expect(appJSON.expo.version).toBe('5.0.0');
@@ -189,7 +189,7 @@ describe(maybeResolveVersionsAsync, () => {
it('reads the versions from native code', async () => {
const exp = initBareWorkflowProject();
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/app',
'/repo',
exp,
{} as BuildProfile<Platform.ANDROID>
);
@@ -201,7 +201,7 @@ describe(maybeResolveVersionsAsync, () => {
it('reads the versions from expo config', async () => {
const exp = initManagedProject();
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/app',
'/repo',
exp,
{} as BuildProfile<Platform.ANDROID>
);
@@ -240,7 +240,7 @@ function initBareWorkflowProject({
}`,
'./android/app/src/main/AndroidManifest.xml': 'fake',
},
'/app'
'/repo'
);
return fakeExp;
@@ -261,7 +261,7 @@ function initManagedProject(): ExpoConfig {
expo: fakeExp,
}),
},
'/app'
'/repo'
);
return fakeExp;
+8 -25
View File
@@ -1,9 +1,7 @@
import { Android, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
@@ -16,6 +14,7 @@ import {
} from '../../project/android/applicationId';
import { resolveGradleBuildContextAsync } from '../../project/android/gradle';
import { toggleConfirmAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import {
BuildRequestSender,
CredentialsResult,
@@ -29,7 +28,6 @@ import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate'
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteVersionCodeAsync } from './version';
export async function createAndroidContextAsync(
ctx: CommonContext<Platform.ANDROID>
@@ -47,7 +45,7 @@ This means that it will most likely produce an AAB and you will not be able to i
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
if (!confirmed) {
Log.error('Update eas.json and come back again.');
Log.error('Please update eas.json and come back again.');
process.exit(1);
}
}
@@ -58,22 +56,12 @@ This means that it will most likely produce an AAB and you will not be able to i
const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);
if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp, ctx.user);
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
}
const applicationId = await getApplicationIdAsync(ctx.projectDir, ctx.exp, gradleContext);
const versionCodeOverride =
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? await resolveRemoteVersionCodeAsync(ctx.graphqlClient, {
projectDir: ctx.projectDir,
projectId: ctx.projectId,
exp: ctx.exp,
applicationId,
buildProfile,
})
: undefined;
return { applicationId, gradleContext, versionCodeOverride };
return { applicationId, gradleContext };
}
export async function prepareAndroidBuildAsync(
@@ -85,14 +73,10 @@ export async function prepareAndroidBuildAsync(
return await ensureAndroidCredentialsAsync(ctx);
},
syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync(ctx.graphqlClient, {
await syncProjectConfigurationAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
localAutoIncrement:
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false
: ctx.buildProfile.autoIncrement,
projectId: ctx.projectId,
buildProfile: ctx.buildProfile,
});
},
prepareJobAsync: async (
@@ -102,7 +86,6 @@ export async function prepareAndroidBuildAsync(
return await prepareJobAsync(ctx, jobData);
},
sendBuildRequestAsync: async (
graphqlClient: ExpoGraphqlClient,
appId: string,
job: Android.Job,
metadata: Metadata,
@@ -110,7 +93,7 @@ export async function prepareAndroidBuildAsync(
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createAndroidBuildAsync(graphqlClient, {
return await BuildMutation.createAndroidBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
@@ -138,7 +121,7 @@ async function ensureAndroidCredentialsAsync(
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
app: {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
findAccountByName(ctx.user.accounts, ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
@@ -13,10 +13,8 @@ export function transformJob(job: Android.Job): AndroidJobInput {
secrets: job.secrets,
builderEnvironment: job.builderEnvironment,
cache: job.cache,
version: job.version?.versionCode ? { versionCode: job.version.versionCode } : undefined,
gradleCommand: job.gradleCommand,
applicationArchivePath: job.applicationArchivePath,
buildArtifactPaths: job.buildArtifactPaths,
artifactPath: job.artifactPath,
username: job.username,
buildType: job.buildType && transformBuildType(job.buildType),
developmentClient: job.developmentClient,
@@ -5,6 +5,7 @@ import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { getVcsClient } from '../../vcs';
import { BuildContext } from '../context';
@@ -23,7 +24,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Job> {
const username = getUsername(ctx.exp, ctx.user);
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
@@ -71,15 +72,9 @@ export async function prepareJobAsync(
updates: { channel: ctx.buildProfile.channel },
developmentClient: buildProfile.developmentClient,
gradleCommand: buildProfile.gradleCommand,
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
buildArtifactPaths: buildProfile.buildArtifactPaths,
artifactPath: buildProfile.artifactPath,
buildType,
username,
...(ctx.android.versionCodeOverride && {
version: {
versionCode: ctx.android.versionCodeOverride,
},
}),
experimental: {
prebuildCommand: ctx.buildProfile.prebuildCommand,
},
@@ -1,39 +1,34 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { AndroidVersionAutoIncrement } from '@expo/eas-json';
import { AndroidVersionAutoIncrement, BuildProfile } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../log';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function syncProjectConfigurationAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
exp,
localAutoIncrement,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
localAutoIncrement?: AndroidVersionAutoIncrement;
projectId: string;
}
): Promise<void> {
export async function syncProjectConfigurationAsync({
projectDir,
exp,
buildProfile,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: BuildProfile<Platform.ANDROID>;
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
const { autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
await cleanUpOldEasBuildGradleScriptAsync(projectDir);
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else {
+11 -128
View File
@@ -1,22 +1,16 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig, Updates } from '@expo/config-plugins';
import { AndroidConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../graphql/generated';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { ora } from '../../ora';
import {
getAppBuildGradleAsync,
parseGradleCommand,
resolveConfigValue,
} from '../../project/android/gradleUtils';
import { getNextVersionCode } from '../../project/android/versions';
import { resolveWorkflowAsync } from '../../project/workflow';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
@@ -52,10 +46,9 @@ export async function bumpVersionAsync({
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
Log.log('Updated versions in app.json');
await updateNativeVersionsAsync({
await writeVersionsToBuildGradleAsync({
projectDir,
version: exp.version,
versionCode: exp.android?.versionCode,
exp,
});
Log.log('Synchronized versions with build gradle');
}
@@ -81,7 +74,7 @@ export async function bumpVersionInAppJsonAsync({
await bumpAppVersionAsync({ appVersion, projectDir, exp });
} else {
const versionCode = AndroidConfig.Version.getVersionCode(exp);
const bumpedVersionCode = getNextVersionCode(versionCode);
const bumpedVersionCode = versionCode + 1;
Log.log(
`Bumping ${chalk.bold('expo.android.versionCode')} from ${chalk.bold(
versionCode
@@ -123,33 +116,21 @@ export async function maybeResolveVersionsAsync(
}
}
export async function updateNativeVersionsAsync({
async function writeVersionsToBuildGradleAsync({
projectDir,
version,
versionCode,
exp,
}: {
projectDir: string;
version?: string;
versionCode?: number;
}): Promise<void> {
exp: ExpoConfig;
}): Promise<string> {
const buildGradle = await readBuildGradleAsync(projectDir);
if (!buildGradle) {
throw new Error('This project is missing a build.gradle file.');
}
let updatedBuildGradle = buildGradle;
if (version !== undefined) {
updatedBuildGradle = updatedBuildGradle.replace(
new RegExp(`versionName ".*"`),
`versionName "${version}"`
);
}
if (versionCode !== undefined) {
updatedBuildGradle = updatedBuildGradle.replace(
new RegExp(`versionCode.*`),
`versionCode ${versionCode}`
);
}
let updatedBuildGradle = AndroidConfig.Version.setVersionName(exp, buildGradle);
updatedBuildGradle = AndroidConfig.Version.setVersionCode(exp, updatedBuildGradle);
await writeBuildGradleAsync({ projectDir, buildGradle: updatedBuildGradle });
return updatedBuildGradle;
}
async function readBuildGradleAsync(projectDir: string): Promise<string | undefined> {
@@ -170,101 +151,3 @@ async function writeBuildGradleAsync({
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
await fs.writeFile(buildGradlePath, buildGradle);
}
/**
* Returns buildNumber that will be used for the next build. If current build profile
* has an 'autoIncrement' option set, it increments the version on server.
*/
export async function resolveRemoteVersionCodeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
projectId,
exp,
applicationId,
buildProfile,
}: {
projectDir: string;
projectId: string;
exp: ExpoConfig;
applicationId: string;
buildProfile: BuildProfile<Platform.ANDROID>;
}
): Promise<string> {
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
AppPlatform.Android,
applicationId
);
const localVersions = await maybeResolveVersionsAsync(projectDir, exp, buildProfile);
let currentBuildVersion: string;
if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion;
} else {
if (localVersions.appBuildVersion) {
Log.warn(
'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
);
currentBuildVersion = localVersions.appBuildVersion;
} else {
Log.error(
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
);
throw new Error('Remote versions are not configured.');
}
}
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion;
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
const spinner = ora(
`Initializing versionCode with ${chalk.bold(currentBuildVersion)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Android,
applicationIdentifier: applicationId,
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
buildVersion: currentBuildVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
});
spinner.succeed(`Initialized versionCode with ${chalk.bold(currentBuildVersion)}.`);
} catch (err) {
spinner.fail(`Failed to initialize versionCode with ${chalk.bold(currentBuildVersion)}.`);
throw err;
}
return currentBuildVersion;
} else {
const nextBuildVersion = getNextVersionCode(currentBuildVersion);
const spinner = ora(
`Incrementing versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Android,
applicationIdentifier: applicationId,
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
buildVersion: String(nextBuildVersion),
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
});
spinner.succeed(
`Incremented versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
} catch (err) {
spinner.fail(
`Failed to increment versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
throw err;
}
return String(nextBuildVersion);
}
}
+23 -61
View File
@@ -8,7 +8,6 @@ import nullthrows from 'nullthrows';
import { withAnalyticsAsync } from '../analytics/common';
import { BuildEvent } from '../analytics/events';
import { getExpoWebsiteBaseUrl } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
BuildFragment,
@@ -19,14 +18,14 @@ import {
} from '../graphql/generated';
import { BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log';
import Log, { learnMore } from '../log';
import { Ora, ora } from '../ora';
import {
appPlatformDisplayNames,
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../platform';
import { uploadFileAtPathToS3Async } from '../uploads';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -56,7 +55,6 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
sendBuildRequestAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
job: TJob,
metadata: Metadata,
@@ -122,7 +120,7 @@ export async function prepareBuildRequestForPlatformAsync<
return async () => {
if (ctx.localBuildOptions.enable) {
await runLocalBuildAsync(job, metadata, ctx.localBuildOptions);
await runLocalBuildAsync(job, ctx.localBuildOptions);
return undefined;
} else {
try {
@@ -136,40 +134,18 @@ export async function prepareBuildRequestForPlatformAsync<
function handleBuildRequestError(error: any, platform: Platform): never {
if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed. Upgrade to the latest eas-cli version.');
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli version.');
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE'
) {
Log.error(
`EAS Build is down for maintenance. Try again later. Check ${link(
'https://status.expo.dev/'
)} for updates.`
'EAS Build is down for maintenance, please try again later. Check https://status.expo.dev/ for updates.'
);
throw new Error('Build request failed.');
} else if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_FREE_TIER_DISABLED') {
Log.error(
`EAS Build free tier is temporarily disabled and we are not accepting any new builds. Try again later. ${learnMore(
'https://expo.fyi/eas-build-queues'
)}`
);
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_FREE_TIER_DISABLED_IOS'
) {
Log.error(
`EAS Build free tier is temporarily disabled for iOS and we are not accepting any new builds. Try again later. ${learnMore(
'https://expo.fyi/eas-build-queues'
)}`
);
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID'
) {
Log.error(
`EAS Build free tier is temporarily disabled for Android and we are not accepting any new builds. Try again later. ${learnMore(
'https://expo.fyi/eas-build-queues'
)}`
'EAS Build free tier is temporarily disabled, please try again later. Check https://status.expo.dev/ for updates.'
);
throw new Error('Build request failed.');
} else if (
@@ -181,7 +157,7 @@ function handleBuildRequestError(error: any, platform: Platform): never {
throw new Error('Build request failed.');
} else if (error?.graphQLErrors) {
Log.error(
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.'
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, please report the issue.'
);
}
throw error;
@@ -201,21 +177,9 @@ async function uploadProjectAsync<TPlatform extends Platform>(
)}`
);
const projectTarball = await makeProjectTarballAsync();
if (projectTarball.size > 1024 * 1024 * 100) {
Log.warn(
`Your project archive is ${formatBytes(
projectTarball.size
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
'.easignore'
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
);
}
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadFileAtPathToS3Async(
ctx.graphqlClient,
const { bucketKey } = await uploadAsync(
UploadSessionType.EasBuildProjectSources,
projectTarball.path,
createProgressTracker({
@@ -257,7 +221,6 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
}
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.graphqlClient,
ctx.projectId,
job,
metadata,
@@ -279,9 +242,12 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
type MaybeBuildFragment = BuildFragment | null;
export async function waitForBuildEndAsync(
graphqlClient: ExpoGraphqlClient,
{ buildIds, accountName }: { buildIds: string[]; accountName: string },
{ intervalSec = 10 } = {}
{
// 2 hours (max build time limit) + 10 minutes (possible queue time)
timeoutSec = 2 * 60 * 60 + 10 * 60,
intervalSec = 10,
} = {}
): Promise<MaybeBuildFragment[]> {
let spinner;
let originalSpinnerText;
@@ -293,8 +259,9 @@ export async function waitForBuildEndAsync(
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
}
while (true) {
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const endTime = new Date().getTime() + timeoutSec * 1000;
while (new Date().getTime() <= endTime) {
const builds = await getBuildsSafelyAsync(buildIds);
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
@@ -304,15 +271,16 @@ export async function waitForBuildEndAsync(
}
await sleepAsync(intervalSec * 1000);
}
spinner.fail('Timed out');
throw new Error(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
async function getBuildsSafelyAsync(
graphqlClient: ExpoGraphqlClient,
buildIds: string[]
): Promise<MaybeBuildFragment[]> {
async function getBuildsSafelyAsync(buildIds: string[]): Promise<MaybeBuildFragment[]> {
const promises = buildIds.map(async buildId => {
try {
return await BuildQuery.byIdAsync(graphqlClient, buildId, { useCache: false });
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
Log.debug('Failed to fetch the build status', err);
return null;
@@ -384,7 +352,7 @@ async function handleSingleBuildProgressAsync(
);
}
Log.newLine();
Log.log(`Waiting in ${priorityToQueueDisplayName[build.priority]}`);
Log.log('Waiting in queue');
queueProgressBar.start(
build.initialQueuePosition + 1,
build.initialQueuePosition - build.queuePosition + 1,
@@ -417,12 +385,6 @@ async function handleSingleBuildProgressAsync(
return { refetch: true };
}
const priorityToQueueDisplayName: Record<BuildPriority, string> = {
[BuildPriority.Normal]: 'queue',
[BuildPriority.NormalPlus]: 'queue',
[BuildPriority.High]: 'priority queue',
};
const statusToDisplayName: Record<BuildStatus, string> = {
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
[BuildStatus.InQueue]: 'in queue',
+3 -3
View File
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor } from '@expo/eas-json';
import { EasJson, EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -24,7 +24,7 @@ interface ConfigureParams {
export async function ensureProjectConfiguredAsync(
configureParams: ConfigureParams
): Promise<boolean> {
if (await fs.pathExists(EasJsonAccessor.formatEasJsonPath(configureParams.projectDir))) {
if (await fs.pathExists(EasJsonReader.formatEasJsonPath(configureParams.projectDir))) {
return false;
}
@@ -89,7 +89,7 @@ const EAS_JSON_BARE_DEFAULT: EasJson = {
};
async function createEasJsonAsync(projectDir: string): Promise<void> {
const easJsonPath = EasJsonAccessor.formatEasJsonPath(projectDir);
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
const hasAndroidNativeProject =
(await resolveWorkflowAsync(projectDir, Platform.ANDROID)) === Workflow.GENERIC;
+3 -9
View File
@@ -1,9 +1,9 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJson } from '@expo/eas-json';
import { BuildProfile } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
import { TrackingContext } from '../analytics/common';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
@@ -17,20 +17,18 @@ export type CommonContext<T extends Platform> = Omit<BuildContext<T>, 'android'
export interface AndroidBuildContext {
applicationId: string;
gradleContext?: GradleBuildContext;
versionCodeOverride?: string;
}
export interface IosBuildContext {
bundleIdentifier: string;
applicationTargetBuildSettings: XCBuildConfiguration['buildSettings'];
applicationTarget: Target;
targets: Target[];
xcodeBuildContext: XcodeBuildContext;
buildNumberOverride?: string;
}
export interface BuildContext<T extends Platform> {
accountName: string;
easJsonCliConfig: EasJson['cli'];
buildProfile: BuildProfile<T>;
buildProfileName: string;
resourceClass: BuildResourceClass;
@@ -39,16 +37,12 @@ export interface BuildContext<T extends Platform> {
exp: ExpoConfig;
localBuildOptions: LocalBuildOptions;
nonInteractive: boolean;
noWait: boolean;
runFromCI: boolean;
platform: T;
projectDir: string;
projectId: string;
projectName: string;
message?: string;
trackingCtx: TrackingContext;
user: Actor;
graphqlClient: ExpoGraphqlClient;
workflow: Workflow;
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
ios: T extends Platform.IOS ? IosBuildContext : undefined;
+15 -36
View File
@@ -1,19 +1,18 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJson } from '@expo/eas-json';
import { BuildProfile } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import getenv from 'getenv';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
import { TrackingContext } from '../analytics/common';
import { Analytics, BuildEvent } from '../analytics/events';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { BuildResourceClass } from '../graphql/generated';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { Actor } from '../user/User';
import { findAccountByName } from '../user/Account';
import { ensureLoggedInAsync } from '../user/actions';
import { createAndroidContextAsync } from './android/build';
import { BuildContext, CommonContext } from './context';
import { createIosContextAsync } from './ios/build';
@@ -22,50 +21,37 @@ import { LocalBuildOptions } from './local';
export async function createBuildContextAsync<T extends Platform>({
buildProfileName,
buildProfile,
easJsonCliConfig,
clearCache = false,
localBuildOptions,
nonInteractive,
noWait,
nonInteractive = false,
platform,
projectDir,
resourceClass,
message,
actor,
graphqlClient,
getDynamicProjectConfigAsync,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
easJsonCliConfig: EasJson['cli'];
clearCache: boolean;
localBuildOptions: LocalBuildOptions;
nonInteractive: boolean;
noWait: boolean;
platform: T;
projectDir: string;
resourceClass: BuildResourceClass;
message?: string;
actor: Actor;
graphqlClient: ExpoGraphqlClient;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: buildProfile.env });
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
const user = await ensureLoggedInAsync();
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const projectId = await getProjectIdAsync(exp, { env: buildProfile.env });
const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = account.id;
const runFromCI = getenv.boolish('CI', false);
const accountId = findAccountByName(user.accounts, accountName)?.id;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
exp,
nonInteractive,
projectDir,
user: actor,
graphqlClient,
user,
env: buildProfile.env,
easJsonCliConfig,
});
const devClientProperties = getDevClientEventProperties({
@@ -80,33 +66,26 @@ export async function createBuildContextAsync<T extends Platform>({
project_id: projectId,
project_type: workflow,
...devClientProperties,
no_wait: noWait,
run_from_ci: runFromCI,
};
Analytics.logEvent(BuildEvent.BUILD_COMMAND, trackingCtx);
const commonContext: CommonContext<T> = {
accountName: account.name,
accountName,
buildProfile,
buildProfileName,
resourceClass,
easJsonCliConfig,
clearCache,
credentialsCtx,
exp,
localBuildOptions,
nonInteractive,
noWait,
platform,
projectDir,
projectId,
projectName,
trackingCtx,
user: actor,
graphqlClient,
user,
workflow,
message,
runFromCI,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
@@ -3,7 +3,6 @@ import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import type { XCBuildConfiguration } from 'xcode';
import { readPlistAsync } from '../../../utils/plist';
import {
@@ -18,12 +17,6 @@ import {
jest.mock('fs');
const getXCBuildConfigurationFromPbxproj = jest.spyOn(
IOSConfig.Target,
'getXCBuildConfigurationFromPbxproj'
);
const getPbxproj = jest.spyOn(IOSConfig.XcodeUtils, 'getPbxproj');
afterAll(async () => {
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
@@ -58,34 +51,14 @@ describe(evaluateTemplateString, () => {
// bare workflow
describe(bumpVersionAsync, () => {
beforeEach(() => {
getPbxproj.mockImplementation(() => ({} as any));
});
afterEach(() => {
getXCBuildConfigurationFromPbxproj.mockReset();
getPbxproj.mockReset();
});
it('bumps expo.ios.buildNumber and CFBundleVersion when strategy = BumpStrategy.BUILD_NUMBER', async () => {
const fakeExp = initBareWorkflowProject();
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist',
},
} as XCBuildConfiguration)
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -103,25 +76,13 @@ describe(bumpVersionAsync, () => {
it('bumps expo.ios.buildNumber and CFBundleVersion for non default Info.plist location', async () => {
const fakeExp = initBareWorkflowProject({ infoPlistName: 'Info2.plist' });
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
},
} as XCBuildConfiguration)
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -136,27 +97,14 @@ describe(bumpVersionAsync, () => {
expect(infoPlist['CFBundleVersion']).toBe('2');
});
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.APP_VERSION', async () => {
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initBareWorkflowProject();
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist',
},
} as XCBuildConfiguration)
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -173,25 +121,12 @@ describe(bumpVersionAsync, () => {
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initBareWorkflowProject();
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist',
},
} as XCBuildConfiguration)
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -353,9 +288,9 @@ describe(getInfoPlistPath, () => {
function initBareWorkflowProject({
appVersion = '1.0.0',
buildNumber = '1',
version = '1',
infoPlistName = 'Info.plist',
}: { appVersion?: string; buildNumber?: string; infoPlistName?: string } = {}): ExpoConfig {
}: { appVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
@@ -376,7 +311,7 @@ function initBareWorkflowProject({
<key>CFBundleShortVersionString</key>
<string>${appVersion}</string>
<key>CFBundleVersion</key>
<string>${buildNumber}</string>
<string>${version}</string>
</dict>
</plist>`,
},
+26 -25
View File
@@ -1,8 +1,8 @@
import { IOSConfig } from '@expo/config-plugins';
import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { IosCredentials } from '../../credentials/ios/types';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
@@ -16,7 +16,6 @@ import { ensureIosCredentialsAsync } from './credentials';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteBuildNumberAsync } from './version';
export async function createIosContextAsync(
ctx: CommonContext<Platform.IOS>
@@ -24,7 +23,7 @@ export async function createIosContextAsync(
const { buildProfile } = ctx;
if (ctx.workflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp, ctx.user);
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
}
checkNodeEnvVariable(ctx);
@@ -45,22 +44,14 @@ export async function createIosContextAsync(
env: buildProfile.env,
});
const applicationTarget = findApplicationTarget(targets);
const buildNumberOverride =
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? await resolveRemoteBuildNumberAsync(ctx.graphqlClient, {
projectDir: ctx.projectDir,
projectId: ctx.projectId,
exp: ctx.exp,
applicationTarget,
buildProfile,
})
: undefined;
const applicationTargetBuildSettings = resolveBuildSettings(ctx, applicationTarget);
return {
bundleIdentifier: applicationTarget.bundleIdentifier,
applicationTarget,
applicationTargetBuildSettings,
targets,
xcodeBuildContext,
buildNumberOverride,
};
}
@@ -73,15 +64,11 @@ export async function prepareIosBuildAsync(
return ensureIosCredentialsAsync(ctx, ctx.ios.targets);
},
syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync(ctx.graphqlClient, {
await syncProjectConfigurationAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
targets: ctx.ios.targets,
localAutoIncrement:
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false
: ctx.buildProfile.autoIncrement,
projectId: ctx.projectId,
buildProfile: ctx.buildProfile,
buildSettings: ctx.ios.applicationTargetBuildSettings,
});
},
prepareJobAsync: async (
@@ -94,7 +81,6 @@ export async function prepareIosBuildAsync(
});
},
sendBuildRequestAsync: async (
graphqlClient: ExpoGraphqlClient,
appId: string,
job: Ios.Job,
metadata: Metadata,
@@ -102,7 +88,7 @@ export async function prepareIosBuildAsync(
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createIosBuildAsync(graphqlClient, {
return await BuildMutation.createIosBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
@@ -111,3 +97,18 @@ export async function prepareIosBuildAsync(
},
});
}
function resolveBuildSettings(
ctx: CommonContext<Platform.IOS>,
applicationTarget: Target
): XCBuildConfiguration['buildSettings'] {
if (ctx.workflow === Workflow.MANAGED) {
return {};
}
const project = IOSConfig.XcodeUtils.getPbxproj(ctx.projectDir);
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
targetName: applicationTarget.targetName,
buildConfiguration: applicationTarget.buildConfiguration,
});
return xcBuildConfiguration?.buildSettings ?? {};
}
@@ -1,7 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
import { getAppFromContextAsync } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
@@ -16,7 +16,7 @@ export async function ensureIosCredentialsAsync(
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
app: getAppFromContext(buildCtx.credentialsCtx),
targets,
distribution: buildCtx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
+4 -4
View File
@@ -1,4 +1,4 @@
import { Ios } from '@expo/eas-build-job';
import { Env, Ios } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated';
@@ -14,11 +14,9 @@ export function transformJob(job: Ios.Job): IosJobInput {
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
version: job.version?.buildNumber ? { buildNumber: job.version.buildNumber } : undefined,
scheme: job.scheme,
buildConfiguration: job.buildConfiguration,
applicationArchivePath: job.applicationArchivePath,
buildArtifactPaths: job.buildArtifactPaths,
artifactPath: job.artifactPath,
username: job.username,
developmentClient: job.developmentClient,
simulator: job.simulator,
@@ -28,6 +26,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
function transformIosSecrets(secrets: {
buildCredentials?: Ios.BuildCredentials;
env?: Env;
}): IosJobSecretsInput {
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
@@ -43,5 +42,6 @@ function transformIosSecrets(secrets: {
return {
buildCredentials,
environmentSecrets: secrets.env,
};
}
+3 -9
View File
@@ -4,6 +4,7 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { getVcsClient } from '../../vcs';
import { BuildContext } from '../context';
@@ -25,7 +26,7 @@ export async function prepareJobAsync(
): Promise<Job> {
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const username = getUsername(ctx.exp, ctx.user);
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
if (jobData.credentials) {
const targetNames = Object.keys(jobData.credentials);
@@ -63,15 +64,8 @@ export async function prepareJobAsync(
simulator: ctx.buildProfile.simulator,
scheme: jobData.buildScheme,
buildConfiguration: ctx.buildProfile.buildConfiguration,
applicationArchivePath:
ctx.buildProfile.applicationArchivePath ?? ctx.buildProfile.artifactPath,
buildArtifactPaths: ctx.buildProfile.buildArtifactPaths,
artifactPath: ctx.buildProfile.artifactPath,
username,
...(ctx.ios.buildNumberOverride && {
version: {
buildNumber: ctx.ios.buildNumberOverride,
},
}),
experimental: {
prebuildCommand: ctx.buildProfile.prebuildCommand,
},
@@ -1,38 +1,33 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { IosVersionAutoIncrement } from '@expo/eas-json';
import { BuildProfile, IosVersionAutoIncrement } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../credentials/ios/types';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function syncProjectConfigurationAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
exp,
targets,
localAutoIncrement,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
targets: Target[];
localAutoIncrement?: IosVersionAutoIncrement;
projectId: string;
}
): Promise<void> {
export async function syncProjectConfigurationAsync({
projectDir,
exp,
buildProfile,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: BuildProfile<Platform.IOS>;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
const { autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets });
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, buildSettings });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
+43 -185
View File
@@ -1,26 +1,16 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig, Updates } from '@expo/config-plugins';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import chalk from 'chalk';
import path from 'path';
import type { XCBuildConfiguration } from 'xcode';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../credentials/ios/types';
import { AppPlatform } from '../../graphql/generated';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { findApplicationTarget } from '../../project/ios/target';
import { getNextBuildNumber, isValidBuildNumber } from '../../project/ios/versions';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import uniqBy from '../../utils/expodash/uniqBy';
import { readPlistAsync, writePlistAsync } from '../../utils/plist';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
import { bumpAppVersionAsync } from '../utils/version';
export enum BumpStrategy {
APP_VERSION,
@@ -32,25 +22,21 @@ export async function bumpVersionAsync({
bumpStrategy,
projectDir,
exp,
targets,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
bumpStrategy: BumpStrategy;
targets: Target[];
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
Log.log('Updated versions in app.json');
await updateNativeVersionsAsync({
projectDir,
version: exp.version,
buildNumber: exp.ios?.buildNumber,
targets,
});
await writeVersionsToInfoPlistAsync({ projectDir, exp, infoPlist, buildSettings });
Log.log('Synchronized versions with Info.plist');
}
@@ -73,8 +59,10 @@ export async function bumpVersionInAppJsonAsync({
await bumpAppVersionAsync({ appVersion, projectDir, exp });
} else {
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
if (isValidBuildNumber(buildNumber)) {
const bumpedBuildNumber = getNextBuildNumber(buildNumber);
if (buildNumber.match(/^\d+(\.\d+)*$/)) {
const comps = buildNumber.split('.');
comps[comps.length - 1] = String(Number(comps[comps.length - 1]) + 1);
const bumpedBuildNumber = comps.join('.');
Log.log(
`Bumping ${chalk.bold('expo.ios.buildNumber')} from ${chalk.bold(
buildNumber
@@ -133,21 +121,12 @@ export async function readBuildNumberAsync(
export async function maybeResolveVersionsAsync(
projectDir: string,
exp: ExpoConfig,
targets: Target[]
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const applicationTarget = findApplicationTarget(targets);
try {
return {
appBuildVersion: await readBuildNumberAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
),
appVersion: await readShortVersionAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
),
appBuildVersion: await readBuildNumberAsync(projectDir, exp, buildSettings),
appVersion: await readShortVersionAsync(projectDir, exp, buildSettings),
};
} catch (err: any) {
Log.warn('Failed to read app versions.');
@@ -158,6 +137,23 @@ export async function maybeResolveVersionsAsync(
}
}
async function writeVersionsToInfoPlistAsync({
projectDir,
exp,
infoPlist,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
infoPlist: IOSConfig.InfoPlist;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<IOSConfig.InfoPlist> {
let updatedInfoPlist = IOSConfig.Version.setVersion(exp, infoPlist);
updatedInfoPlist = IOSConfig.Version.setBuildNumber(exp, updatedInfoPlist);
await writeInfoPlistAsync({ projectDir, infoPlist: updatedInfoPlist, buildSettings });
return updatedInfoPlist;
}
export function getInfoPlistPath(
projectDir: string,
buildSettings: XCBuildConfiguration['buildSettings']
@@ -185,55 +181,24 @@ async function readInfoPlistAsync(
return ((await readPlistAsync(infoPlistPath)) ?? {}) as IOSConfig.InfoPlist;
}
export async function updateNativeVersionsAsync({
async function writeInfoPlistAsync({
projectDir,
version,
buildNumber,
targets,
infoPlist,
buildSettings,
}: {
projectDir: string;
version?: string;
buildNumber?: string;
targets: Target[];
infoPlist: IOSConfig.InfoPlist;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
const project = IOSConfig.XcodeUtils.getPbxproj(projectDir);
const iosDir = path.join(projectDir, 'ios');
const infoPlistFiles: string[] = [];
for (const target of targets) {
const { targetName, buildConfiguration } = target;
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
targetName,
buildConfiguration,
});
const infoPlist = xcBuildConfiguration?.buildSettings?.INFOPLIST_FILE;
if (infoPlist) {
const evaluatedInfoPlistPath = trimQuotes(
evaluateTemplateString(infoPlist, {
SRCROOT: iosDir,
})
);
const absolutePath = path.isAbsolute(evaluatedInfoPlistPath)
? evaluatedInfoPlistPath
: path.join(iosDir, evaluatedInfoPlistPath);
infoPlistFiles.push(path.normalize(absolutePath));
}
}
const uniqueInfoPlistPaths = uniqBy(infoPlistFiles, i => i);
for (const infoPlistPath of uniqueInfoPlistPaths) {
const infoPlist = (await readPlistAsync(infoPlistPath)) as IOSConfig.InfoPlist;
if (buildNumber) {
infoPlist.CFBundleVersion = buildNumber;
}
if (version) {
infoPlist.CFBundleShortVersionString = version;
}
await writePlistAsync(infoPlistPath, infoPlist);
}
const infoPlistPath = getInfoPlistPath(projectDir, buildSettings);
await writePlistAsync(infoPlistPath, infoPlist);
}
function trimQuotes(s: string): string {
return s?.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
function ensureStaticConfigExists(projectDir: string): void {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
throw new Error('autoIncrement option is not supported when using app.config.js');
}
}
export function evaluateTemplateString(
@@ -251,110 +216,3 @@ export function evaluateTemplateString(
}
});
}
/**
* Returns buildNumber that will be used for the next build. If current build profile
* has an 'autoIncrement' option set, it increments the version on server.
*/
export async function resolveRemoteBuildNumberAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
projectId,
exp,
applicationTarget,
buildProfile,
}: {
projectDir: string;
projectId: string;
exp: ExpoConfig;
applicationTarget: Target;
buildProfile: BuildProfile<Platform.IOS>;
}
): Promise<string> {
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
AppPlatform.Ios,
applicationTarget.bundleIdentifier
);
const localBuildNumber = await readBuildNumberAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
);
const localShortVersion = await readShortVersionAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
);
let currentBuildVersion: string;
if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion;
} else {
if (localBuildNumber) {
Log.warn(
'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
);
currentBuildVersion = localBuildNumber;
} else {
Log.error(
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
);
throw new Error('Remote versions are not configured.');
}
}
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion;
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
const spinner = ora(
`Initializing buildNumber with ${chalk.bold(currentBuildVersion)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Ios,
applicationIdentifier: applicationTarget.bundleIdentifier,
storeVersion: localShortVersion ?? '1.0.0',
buildVersion: currentBuildVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
});
spinner.succeed(`Initialized buildNumber with ${chalk.bold(currentBuildVersion)}.`);
} catch (err) {
spinner.fail(`Failed to initialize buildNumber with ${chalk.bold(currentBuildVersion)}.`);
throw err;
}
return currentBuildVersion;
} else {
const nextBuildVersion = getNextBuildNumber(currentBuildVersion);
const spinner = ora(
`Incrementing buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Ios,
applicationIdentifier: applicationTarget.bundleIdentifier,
storeVersion: localShortVersion ?? '1.0.0',
buildVersion: nextBuildVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
});
spinner.succeed(
`Incremented buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
} catch (err) {
spinner.fail(
`Failed to increment buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
throw err;
}
return nextBuildVersion;
}
}
+8 -15
View File
@@ -1,4 +1,4 @@
import { Job, Metadata } from '@expo/eas-build-job';
import { Job } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process';
import semver from 'semver';
@@ -6,7 +6,7 @@ import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '0.0.110';
const PLUGIN_PACKAGE_VERSION = '0.0.85';
export interface LocalBuildOptions {
enable: boolean;
@@ -18,12 +18,8 @@ export interface LocalBuildOptions {
verbose?: boolean;
}
export async function runLocalBuildAsync(
job: Job,
metadata: Metadata,
options: LocalBuildOptions
): Promise<void> {
const { command, args } = await getCommandAndArgsAsync(job, metadata);
export async function runLocalBuildAsync(job: Job, options: LocalBuildOptions): Promise<void> {
const { command, args } = await getCommandAndArgsAsync(job);
let spinner;
if (!options.verbose) {
spinner = ora().start(options.skipNativeBuild ? 'Preparing project' : 'Building project');
@@ -57,18 +53,15 @@ export async function runLocalBuildAsync(
}
}
async function getCommandAndArgsAsync(
job: Job,
metadata: Metadata
): Promise<{ command: string; args: string[] }> {
const jobAndMetadataBase64 = Buffer.from(JSON.stringify({ job, metadata })).toString('base64');
async function getCommandAndArgsAsync(job: Job): Promise<{ command: string; args: string[] }> {
const jobBase64 = Buffer.from(JSON.stringify({ job })).toString('base64');
if (process.env.EAS_LOCAL_BUILD_PLUGIN_PATH) {
return {
command: process.env.EAS_LOCAL_BUILD_PLUGIN_PATH,
args: [jobAndMetadataBase64],
args: [jobBase64],
};
} else {
const args = [`${PLUGIN_PACKAGE_NAME}@${PLUGIN_PACKAGE_VERSION}`, jobAndMetadataBase64];
const args = [`${PLUGIN_PACKAGE_NAME}@${PLUGIN_PACKAGE_VERSION}`, jobBase64];
if (await isAtLeastNpm7Async()) {
// npx shipped with npm >= 7.0.0 requires the "-y" flag to run commands without
// prompting the user to install a package that is used for the first time
+8 -42
View File
@@ -14,6 +14,7 @@ import {
readChannelSafelyAsync as readIosChannelSafelyAsync,
readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync,
} from '../update/ios/UpdatesModule';
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { getVcsClient } from '../vcs';
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
@@ -23,13 +24,12 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<Metadata> {
const vcsClient = getVcsClient();
const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
const distribution =
('simulator' in ctx.buildProfile && ctx.buildProfile.simulator
? 'simulator'
: ctx.buildProfile.distribution) ?? 'store';
const metadata: Metadata = {
const metadata = {
trackingContext: ctx.trackingCtx,
...(await maybeResolveVersionsAsync(ctx)),
cliVersion: easCliVersion,
@@ -43,20 +43,14 @@ export async function collectMetadataAsync<T extends Platform>(
appName: ctx.exp.name,
appIdentifier: resolveAppIdentifier(ctx),
buildProfile: ctx.buildProfileName,
gitCommitHash: await vcsClient.getCommitHashAsync(),
gitCommitMessage: truncateGitCommitMessage(
(await vcsClient.getLastCommitMessageAsync()) ?? undefined
),
isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, ctx.user),
message: ctx.message,
gitCommitHash: await getVcsClient().getCommitHashAsync(),
isGitWorkingTreeDirty: await getVcsClient().hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, await ensureLoggedInAsync()),
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
ctx as BuildContext<Platform.IOS>
),
}),
runWithNoWaitFlag: ctx.noWait,
runFromCI: ctx.runFromCI,
};
return sanitizeMetadata(metadata);
}
@@ -66,32 +60,14 @@ async function maybeResolveVersionsAsync<T extends Platform>(
): Promise<{ appBuildVersion?: string; appVersion?: string }> {
if (ctx.platform === Platform.IOS) {
const iosContext = ctx as BuildContext<Platform.IOS>;
const resolvedVersion = await maybeResolveIosVersionsAsync(
return await maybeResolveIosVersionsAsync(
ctx.projectDir,
ctx.exp,
iosContext.ios.targets
iosContext.ios.applicationTargetBuildSettings
);
if (iosContext.ios.buildNumberOverride) {
return {
...resolvedVersion,
appBuildVersion: iosContext.ios.buildNumberOverride,
};
}
return resolvedVersion;
} else if (ctx.platform === Platform.ANDROID) {
const androidCtx = ctx as BuildContext<Platform.ANDROID>;
const resolvedVersion = await maybeResolveAndroidVersionsAsync(
ctx.projectDir,
ctx.exp,
androidCtx.buildProfile
);
if (androidCtx.android.versionCodeOverride) {
return {
...resolvedVersion,
appBuildVersion: androidCtx.android.versionCodeOverride,
};
}
return resolvedVersion;
return await maybeResolveAndroidVersionsAsync(ctx.projectDir, ctx.exp, androidCtx.buildProfile);
} else {
throw new Error(`Unsupported platform ${ctx.platform}`);
}
@@ -173,13 +149,3 @@ function resolveIosEnterpriseProvisioning(
): IosEnterpriseProvisioning | undefined {
return ctx.buildProfile.enterpriseProvisioning;
}
export function truncateGitCommitMessage(
msg: string | undefined,
maxLength: number = 4096
): string | undefined {
if (msg === undefined) {
return undefined;
}
return msg.length > maxLength ? `${msg.substring(0, maxLength - 3)}...` : msg;
}
-74
View File
@@ -1,74 +0,0 @@
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { BuildFilter, BuildFragment } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { printJsonOnlyOutput } from '../utils/json';
import { paginatedQueryWithConfirmPromptAsync } from '../utils/queries';
import { formatGraphQLBuild } from './utils/formatBuild';
export const BUILDS_LIMIT = 50;
export async function listAndRenderBuildsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
projectDisplayName,
filter,
paginatedQueryOptions,
}: {
projectId: string;
projectDisplayName: string;
filter?: BuildFilter;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
filter,
});
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions });
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit,
offset,
filter,
}),
promptOptions: {
title: 'Load more builds?',
renderListItems: builds =>
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions }),
},
});
}
}
function renderPageOfBuilds({
builds,
projectDisplayName,
paginatedQueryOptions,
}: {
builds: BuildFragment[];
projectDisplayName: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): void {
if (paginatedQueryOptions.json) {
printJsonOnlyOutput(builds);
} else {
const list = builds.map(build => formatGraphQLBuild(build)).join(`\n\n${chalk.dim('———')}\n\n`);
Log.addNewLineIfNone();
Log.log(chalk.bold(`Builds for ${projectDisplayName}:`));
Log.log(`\n${list}`);
}
}
+26 -134
View File
@@ -1,26 +1,15 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AppVersionSource,
BuildProfile,
EasJson,
EasJsonAccessor,
EasJsonUtils,
SubmitProfile,
} from '@expo/eas-json';
import { BuildProfile, EasJsonReader, SubmitProfile } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
BuildFragment,
BuildResourceClass,
BuildStatus,
BuildWithSubmissionsFragment,
SubmissionFragment,
} from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import {
@@ -31,20 +20,12 @@ import {
} from '../platform';
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
import { validateAppVersionRuntimePolicySupportAsync } from '../project/projectUtils';
import {
validateAppConfigForRemoteVersionSource,
validateBuildProfileVersionSettings,
} from '../project/remoteVersionSource';
import { createSubmissionContextAsync } from '../submit/context';
import {
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish,
submitAsync,
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../submit/submit';
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
import { Actor } from '../user/User';
import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles';
import { getVcsClient } from '../vcs';
@@ -74,53 +55,23 @@ export interface BuildFlags {
submitProfile?: string;
localBuildOptions: LocalBuildOptions;
userInputResourceClass?: UserInputResourceClass;
message?: string;
}
const iosUserInputResourceClassToBuildResourceClassMapping: Record<
UserInputResourceClass,
BuildResourceClass
const platformToGraphQLResourceClassMapping: Record<
Platform,
Record<UserInputResourceClass, BuildResourceClass>
> = {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.IosLarge,
[UserInputResourceClass.M1_EXPERIMENTAL]: BuildResourceClass.IosM1Large,
[Platform.ANDROID]: {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.AndroidLarge,
},
[Platform.IOS]: {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.IosLarge,
},
};
const androidUserInputResourceClassToBuildResourceClassMapping: Record<
Exclude<UserInputResourceClass, UserInputResourceClass.M1_EXPERIMENTAL>,
BuildResourceClass
> = {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.AndroidLarge,
};
function resolveResourceClass(
platform: Platform,
resourceClassInput: UserInputResourceClass
): BuildResourceClass {
if (platform !== Platform.IOS && resourceClassInput === UserInputResourceClass.M1_EXPERIMENTAL) {
throw new Error(
`Resource class ${UserInputResourceClass.M1_EXPERIMENTAL} is only available for iOS builds`
);
}
return platform === Platform.ANDROID
? androidUserInputResourceClassToBuildResourceClassMapping[
resourceClassInput as Exclude<
UserInputResourceClass,
UserInputResourceClass.M1_EXPERIMENTAL
>
]
: iosUserInputResourceClassToBuildResourceClassMapping[resourceClassInput];
}
export async function runBuildAndSubmitAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
flags: BuildFlags,
actor: Actor,
getDynamicProjectConfigAsync: DynamicConfigContextFn
): Promise<void> {
export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFlags): Promise<void> {
await getVcsClient().ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(flags.nonInteractive);
@@ -128,14 +79,12 @@ export async function runBuildAndSubmitAsync(
projectDir,
nonInteractive: flags.nonInteractive,
});
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
const easJsonReader = new EasJsonReader(projectDir);
const platforms = toPlatforms(flags.requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonAccessor,
easJsonReader,
platforms,
profileName: flags.profile ?? undefined,
});
@@ -146,12 +95,8 @@ export async function runBuildAndSubmitAsync(
buildProfiles,
});
for (const buildProfile of buildProfiles) {
validateBuildProfileVersionSettings(buildProfile, easJsonCliConfig);
}
const startedBuilds: {
build: BuildWithSubmissionsFragment | BuildFragment;
build: BuildFragment;
buildProfile: ProfileData<'build'>;
}[] = [];
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
@@ -162,14 +107,10 @@ export async function runBuildAndSubmitAsync(
flags,
moreBuilds: platforms.length > 1,
buildProfile,
resourceClass: resolveResourceClass(
buildProfile.platform,
flags.userInputResourceClass ?? UserInputResourceClass.DEFAULT
),
easJsonCliConfig,
actor,
graphqlClient,
getDynamicProjectConfigAsync,
resourceClass:
platformToGraphQLResourceClassMapping[buildProfile.platform][
flags.userInputResourceClass ?? UserInputResourceClass.DEFAULT
],
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -188,7 +129,7 @@ export async function runBuildAndSubmitAsync(
const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) {
const submitProfiles = await getProfilesAsync({
easJsonAccessor,
easJsonReader,
platforms,
profileName: flags.submitProfile,
type: 'submit',
@@ -208,10 +149,6 @@ export async function runBuildAndSubmitAsync(
submitProfile,
nonInteractive: flags.nonInteractive,
});
startedBuild.build = await BuildQuery.withSubmissionsByIdAsync(
graphqlClient,
startedBuild.build.id
);
submissions.push(submission);
}
@@ -228,37 +165,20 @@ export async function runBuildAndSubmitAsync(
}
const { accountName } = Object.values(buildCtxByPlatform)[0];
const builds = await waitForBuildEndAsync(graphqlClient, {
const builds = await waitForBuildEndAsync({
buildIds: startedBuilds.map(({ build }) => build.id),
accountName,
});
if (!flags.json) {
printBuildResults(builds);
}
printBuildResults(builds, flags.json);
const haveAllBuildsFailedOrCanceled = builds.every(
build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
);
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
if (flags.json) {
printJsonOnlyOutput(builds);
}
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
} else {
const completedSubmissions = await waitForSubmissionsToCompleteAsync(
graphqlClient,
submissions
);
if (flags.json) {
printJsonOnlyOutput(
await Promise.all(
builds
.filter((i): i is BuildWithSubmissionsFragment => !!i)
.map(build => BuildQuery.withSubmissionsByIdAsync(graphqlClient, build.id))
)
);
}
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions);
// the following function also exits with non zero code if any of the submissions failed
await waitForSubmissionsToCompleteAsync(submissions);
}
}
@@ -268,20 +188,12 @@ async function prepareAndStartBuildAsync({
moreBuilds,
buildProfile,
resourceClass,
easJsonCliConfig,
actor,
graphqlClient,
getDynamicProjectConfigAsync,
}: {
projectDir: string;
flags: BuildFlags;
moreBuilds: boolean;
buildProfile: ProfileData<'build'>;
resourceClass: BuildResourceClass;
easJsonCliConfig: EasJson['cli'];
actor: Actor;
graphqlClient: ExpoGraphqlClient;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
@@ -289,15 +201,9 @@ async function prepareAndStartBuildAsync({
clearCache: flags.clearCache,
buildProfile: buildProfile.profile,
nonInteractive: flags.nonInteractive,
noWait: !flags.wait,
platform: buildProfile.platform,
projectDir,
localBuildOptions: flags.localBuildOptions,
easJsonCliConfig,
message: flags.message,
actor,
graphqlClient,
getDynamicProjectConfigAsync,
});
if (moreBuilds) {
@@ -310,17 +216,6 @@ async function prepareAndStartBuildAsync({
);
}
await validateBuildProfileConfigMatchesProjectConfigAsync(
buildCtx.exp,
buildProfile,
buildCtx.projectId,
flags.nonInteractive
);
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
}
if (buildCtx.workflow === Workflow.MANAGED) {
if (!sdkVersionChecked) {
await checkExpoSdkIsSupportedAsync(buildCtx);
@@ -370,16 +265,13 @@ async function prepareAndStartSubmissionAsync({
const submissionCtx = await createSubmissionContextAsync({
platform,
projectDir,
projectId: build.project.id,
profile: submitProfile,
archiveFlags: { id: build.id },
nonInteractive,
env: buildProfile.env,
credentialsCtx: buildCtx.credentialsCtx,
applicationIdentifier: buildCtx.android?.applicationId ?? buildCtx.ios?.bundleIdentifier,
actor: buildCtx.user,
graphqlClient: buildCtx.graphqlClient,
projectId: buildCtx.projectId,
exp: buildCtx.exp,
});
if (moreBuilds) {
-5
View File
@@ -16,9 +16,4 @@ export enum BuildDistributionType {
export enum UserInputResourceClass {
DEFAULT = 'default',
LARGE = 'large',
/**
* @experimental
* This resource class is not yet ready to be used in production. For testing purposes only. Might be deprecated / deleted at any time.
*/
M1_EXPERIMENTAL = 'm1-experimental',
}
@@ -14,6 +14,7 @@ import {
} from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform';
import { printJsonOnlyOutput } from '../../utils/json';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
export function printLogsUrls(builds: BuildFragment[]): void {
@@ -31,14 +32,18 @@ export function printLogsUrls(builds: BuildFragment[]): void {
}
}
export function printBuildResults(builds: (BuildFragment | null)[]): void {
Log.newLine();
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(build);
export function printBuildResults(builds: (BuildFragment | null)[], json: boolean): void {
if (json) {
printJsonOnlyOutput(builds);
} else {
(builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
Log.newLine();
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(build);
} else {
(builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
}
}
}
@@ -94,14 +99,14 @@ export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationIn
return;
}
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
Log.warn('This command is using API that soon will be deprecated. Upgrade EAS CLI.');
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
Log.warn("Changes won't affect your project config.");
Log.warn(deprecationInfo.message);
} else if (deprecationInfo.type === EasBuildDeprecationInfoType.UserFacing) {
Log.warn('This command is using API that soon will be deprecated.');
Log.warn(deprecationInfo.message);
} else {
Log.warn('An unexpected warning was encountered. Report it as a bug:');
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
Log.warn(deprecationInfo);
}
}
+6 -10
View File
@@ -28,7 +28,7 @@ export async function maybeBailOnRepoStatusAsync(): Promise<void> {
});
if (!answer) {
throw new Error('Commit all changes. Aborting...');
throw new Error('Please commit all changes. Aborting...');
}
}
@@ -39,12 +39,12 @@ export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<vo
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
Log.log(
`This operation needs to be run on a clean working tree. ${chalk.bold(
'Commit all your changes before proceeding'
`This operation needs to be run on a clean working tree, please ${chalk.bold(
'commit all your changes before proceeding'
)}.`
);
if (nonInteractive) {
throw new Error('Commit all changes. Aborting...');
throw new Error('Please commit all changes. Aborting...');
}
const answer = await confirmAsync({
message: `Commit changes to git?`,
@@ -52,7 +52,7 @@ export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<vo
if (answer) {
await commitPromptAsync({ commitAllFiles: true });
} else {
throw new Error('Commit all changes. Aborting...');
throw new Error('Please commit all changes. Aborting...');
}
}
@@ -70,11 +70,7 @@ export async function commitPromptAsync({
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
await getVcsClient().commitAsync({
commitAllFiles,
commitMessage: message,
nonInteractive: false,
});
await getVcsClient().commitAsync({ commitAllFiles, commitMessage: message });
}
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
-205
View File
@@ -1,205 +0,0 @@
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import {
ViewBranchesOnUpdateChannelQueryVariables,
ViewUpdateChannelsOnAppQueryVariables,
} from '../graphql/generated';
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { logChannelDetails } from './utils';
export const CHANNELS_LIMIT = 25;
export async function selectChannelOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
selectionPromptTitle,
paginatedQueryOptions,
}: {
projectId: string;
selectionPromptTitle: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<UpdateChannelObject> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select a channel in non-interactive mode.');
}
const updateChannel = await paginatedQueryWithSelectPromptAsync({
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryChannelsOnAppAsync(graphqlClient, { appId: projectId, limit, offset }),
promptOptions: {
title: selectionPromptTitle,
createDisplayTextForSelectionPromptListItem: updateChannel => updateChannel.name,
getIdentifierForQueryItem: updateChannel => updateChannel.id,
},
});
if (!updateChannel) {
throw new Error(`Could not find any channels for app "${projectId}"`);
}
return updateChannel;
}
export async function listAndRenderChannelsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
paginatedQueryOptions,
}: {
projectId: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const channels = await queryChannelsOnAppAsync(graphqlClient, {
appId: projectId,
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
});
renderPageOfChannels(channels, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryChannelsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
promptOptions: {
title: 'Load more channels?',
renderListItems: channels => renderPageOfChannels(channels, paginatedQueryOptions),
},
});
}
}
export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId: appId,
channelName,
paginatedQueryOptions,
}: {
projectId: string;
channelName: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, { appId, channelName });
renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, {
appId,
channelName,
offset: paginatedQueryOptions.offset,
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
});
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, {
channelName,
appId,
offset,
limit,
}),
promptOptions: {
title: 'Load more channels?',
renderListItems: branches =>
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions),
},
});
}
}
async function queryChannelsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, offset, limit }: ViewUpdateChannelsOnAppQueryVariables
): Promise<UpdateChannelObject[]> {
return await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
appId,
offset,
limit,
});
}
async function queryBranchesAndUpdateGroupsOnChannelAsync(
graphqlClient: ExpoGraphqlClient,
args: ViewBranchesOnUpdateChannelQueryVariables
): Promise<UpdateBranchOnChannelObject[]> {
return await BranchQuery.listBranchesOnChannelAsync(graphqlClient, args);
}
function renderPageOfChannels(
currentPage: UpdateChannelObject[],
{ json }: PaginatedQueryOptions
): void {
if (json) {
printJsonOnlyOutput({ currentPage });
} else {
for (const channel of currentPage) {
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
logChannelDetails(channel);
}
}
}
function renderPageOfBranchesOnChannel(
channel: UpdateChannelObject,
currentPage: UpdateBranchOnChannelObject[],
{ json }: PaginatedQueryOptions
): void {
const channelWithNewBranches = { ...channel, updateBranches: currentPage };
if (json) {
printJsonOnlyOutput({ currentPage: channelWithNewBranches });
} else {
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
logChannelDetails(channelWithNewBranches);
}
}
function renderChannelHeaderContent({
channelName,
channelId,
}: {
channelName: string;
channelId: string;
}): void {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
Log.log(
formatFields([
{ label: 'Name', value: channelName },
{ label: 'ID', value: channelId },
])
);
Log.addNewLineIfNone();
Log.log(chalk`{bold Branches pointed at this channel and their most recent update group:}`);
}
-112
View File
@@ -1,112 +0,0 @@
import assert from 'assert';
import CliTable from 'cli-table3';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { UPDATE_COLUMNS, getUpdateGroupDescriptionsWithBranch } from '../update/utils';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export function logChannelDetails(channel: UpdateChannelObject): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
const updateChannelsTable = new CliTable({
head: ['branch', ...(isRollout ? ['rollout percent'] : []), ...UPDATE_COLUMNS],
wordWrap: true,
});
if (branchMapping.data.length > 2) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
for (const currentBranch of channel.updateBranches) {
const updateGroupDescriptions = getUpdateGroupDescriptionsWithBranch(
currentBranch.updateGroups
);
const isRolloutBranch = isRollout && rolloutBranchIds.includes(currentBranch.id);
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === currentBranch.id;
updateGroupDescriptions.forEach(({ branch, message, runtimeVersion, group, platforms }) => {
updateChannelsTable.push([
branch,
...(isRolloutBranch
? [isBaseBranch ? `${rolloutPercent! * 100}%` : `${(1 - rolloutPercent!) * 100}%`]
: []),
message,
runtimeVersion,
group,
platforms,
]);
});
}
Log.log(updateChannelsTable.toString());
}
+37 -121
View File
@@ -1,4 +1,6 @@
import { EasJsonReader } from '@expo/eas-json';
import { Command } from '@oclif/core';
import semver from 'semver';
import {
AnalyticsEvent,
@@ -6,148 +8,62 @@ import {
initAsync as initAnalyticsAsync,
logEvent,
} from '../analytics/rudderstackClient';
import SessionManager from '../user/SessionManager';
import ContextField from './context/ContextField';
import { DynamicProjectConfigContextField } from './context/DynamicProjectConfigContextField';
import LoggedInContextField from './context/LoggedInContextField';
import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
import { OptionalProjectConfigContextField } from './context/OptionalProjectConfigContextField';
import ProjectConfigContextField from './context/ProjectConfigContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import SessionManagementContextField from './context/SessionManagementContextField';
type ContextInput<
T extends {
[name: string]: any;
} = object
> = {
[P in keyof T]: ContextField<T[P]>;
};
type ContextOutput<
T extends {
[name: string]: any;
} = object
> = {
[P in keyof T]: T[P];
};
import { findProjectRootAsync } from '../project/projectUtils';
import { getUserAsync } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { setVcsClient } from '../vcs';
import GitClient from '../vcs/clients/git';
export default abstract class EasCommand extends Command {
protected static readonly ContextOptions = {
/**
* Require this command to be run when logged-in. Returns the logged-in actor and a logged-in
* graphql client in the context.
*/
LoggedIn: {
loggedIn: new LoggedInContextField(),
},
/**
* Do not require this command to be run when logged-in, but if it is get the logged-in actor and a
* maybe-logged-in graphql client.
*/
MaybeLoggedIn: {
maybeLoggedIn: new MaybeLoggedInContextField(),
},
/**
* Specify this context requirement if the command needs to mutate the user session.
*/
SessionManagment: {
sessionManager: new SessionManagementContextField(),
},
/**
* Require the project to be identified and registered on server if this command is being
* run within a project directory, null otherwise.
*/
OptionalProjectConfig: {
projectConfig: new OptionalProjectConfigContextField(),
},
/**
* Require this command to be run in a project directory. Return the project directory in the context.
*/
ProjectDir: {
projectDir: new ProjectDirContextField(),
},
/**
* Provides functions to load the project config when dynamic config options are needed (custom Env for example).
*/
DynamicProjectConfig: {
// eslint-disable-next-line async-protect/async-suffix
getDynamicProjectConfigAsync: new DynamicProjectConfigContextField(),
},
/**
* Require the project to be identified and registered on server. Returns the project config in the context.
*/
ProjectConfig: {
projectConfig: new ProjectConfigContextField(),
},
};
/**
* Context allows for subclasses (commands) to declare their prerequisites in a type-safe manner.
* These declarative definitions each output a context property that is the result of the prerequisite being
* satisfied. These allow a unified common interface to be shared amongst commands in order to provide a more
* consistent CLI experience.
*
* For example, let's say a command needs the EAS project ID to make a GraphQL mutation. It should declare that
* it requires the `ProjectConfig` context, and then call `getContextAsync` to get the project ID.
* When user data is unavailable locally, determines if the command will
* force the user to log in
*/
static contextDefinition: ContextInput = {};
/**
* Execute the context in the contextDefinition to satisfy command prerequisites.
*/
protected async getContextAsync<
C extends {
[name: string]: any;
} = object
>(
commandClass: { contextDefinition: ContextInput<C> },
{ nonInteractive }: { nonInteractive: boolean }
): Promise<ContextOutput<C>> {
const contextDefinition = commandClass.contextDefinition;
const contextValuePairs = await Promise.all(
Object.keys(contextDefinition).map(async contextKey => {
return [
contextKey,
await contextDefinition[contextKey].getValueAsync({
nonInteractive,
sessionManager: this.sessionManager,
}),
];
})
);
return Object.fromEntries(contextValuePairs);
}
/**
* The user session manager. Responsible for coordinating all user session related state.
* If needed in a subclass, SessionManager ContextOption.
*/
private readonly sessionManager = new SessionManager();
protected requiresAuthentication = true;
protected mustBeRunInsideProject = true;
protected abstract runAsync(): Promise<any>;
// eslint-disable-next-line async-protect/async-suffix
async run(): Promise<any> {
await initAnalyticsAsync();
if (this.mustBeRunInsideProject) {
await this.applyCliConfigAsync();
}
// this is needed for logEvent call below as it identifies the user in the analytics system
await this.sessionManager.getUserAsync();
if (this.requiresAuthentication) {
const { flags } = await this.parse();
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await ensureLoggedInAsync({ nonInteractive });
} else {
await getUserAsync();
}
logEvent(AnalyticsEvent.ACTION, {
// id is assigned by oclif in constructor based on the filepath:
// commands/submit === submit, commands/build/list === build:list
action: `eas ${this.id}`,
});
return this.runAsync();
}
// eslint-disable-next-line async-protect/async-suffix
override async finally(err: Error): Promise<any> {
async finally(err: Error): Promise<any> {
await flushAnalyticsAsync();
return super.finally(err);
}
private async applyCliConfigAsync(): Promise<void> {
const projectDir = await findProjectRootAsync();
const easJsonReader = new EasJsonReader(projectDir);
const config = await easJsonReader.getCliConfigAsync();
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 in eas.json (${config.version})`
);
}
if (config?.requireCommit) {
setVcsClient(new GitClient());
}
}
}
@@ -1,9 +1,11 @@
import { flushAsync, initAsync, logEvent } from '../../analytics/rudderstackClient';
import SessionManager from '../../user/SessionManager';
import { jester as mockJester } from '../../credentials/__tests__/fixtures-constants';
import { getUserAsync } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
import EasCommand from '../EasCommand';
jest.mock('../../user/User');
jest.mock('../../user/SessionManager');
jest.mock('../../user/actions', () => ({ ensureLoggedInAsync: jest.fn() }));
jest.mock('../../analytics/rudderstackClient', () => {
const { AnalyticsEvent } = jest.requireActual('../../analytics/rudderstackClient');
return {
@@ -26,13 +28,23 @@ afterAll(() => {
});
beforeEach(() => {
jest
.mocked(getUserAsync)
.mockReset()
.mockImplementation(async () => mockJester);
jest
.mocked(ensureLoggedInAsync)
.mockReset()
.mockImplementation(async () => mockJester);
jest.mocked(initAsync).mockReset();
jest.mocked(flushAsync).mockReset();
jest.mocked(logEvent).mockReset();
});
const createTestEasCommand = (): typeof EasCommand => {
const createTestEasCommand = (authValue: boolean): typeof EasCommand => {
class TestEasCommand extends EasCommand {
requiresAuthentication = authValue;
async runAsync(): Promise<void> {}
}
@@ -49,30 +61,37 @@ describe(EasCommand.name, () => {
//
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
// and look for "Config.load"
it('ensures the user data is read', async () => {
const TestEasCommand = createTestEasCommand();
it('ensures the user is logged in', async () => {
const TestEasCommand = createTestEasCommand(true);
await TestEasCommand.run();
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
expect(sessionManagerSpy).toBeCalledTimes(1);
expect(ensureLoggedInAsync).toHaveBeenCalled();
}, 15_000);
it('ensures the user data is read from cache', async () => {
const TestEasCommand = createTestEasCommand(false);
await TestEasCommand.run();
expect(jest.mocked(getUserAsync)).toBeCalledTimes(1);
expect(jest.mocked(ensureLoggedInAsync)).not.toBeCalled();
});
it('initializes analytics', async () => {
const TestEasCommand = createTestEasCommand();
const TestEasCommand = createTestEasCommand(true);
await TestEasCommand.run();
expect(initAsync).toHaveBeenCalled();
});
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand();
const TestEasCommand = createTestEasCommand(true);
await TestEasCommand.run();
expect(flushAsync).toHaveBeenCalled();
});
it('logs events', async () => {
const TestEasCommand = createTestEasCommand();
const TestEasCommand = createTestEasCommand(true);
await TestEasCommand.run();
expect(logEvent).toHaveBeenCalledWith('action', {
@@ -83,7 +102,7 @@ describe(EasCommand.name, () => {
describe('after exceptions', () => {
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand();
const TestEasCommand = createTestEasCommand(true);
try {
await TestEasCommand.run().then(() => {
throw new Error('foo');
@@ -1,10 +0,0 @@
import SessionManager from '../../user/SessionManager';
export interface ContextOptions {
sessionManager: SessionManager;
nonInteractive: boolean;
}
export default abstract class ContextField<T> {
abstract getValueAsync(options: ContextOptions): Promise<T>;
}
@@ -1,34 +0,0 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoConfigOptions, getExpoConfig } from '../../project/expoConfig';
import ContextField, { ContextOptions } from './ContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
projectId: string;
exp: ExpoConfig;
projectDir: string;
}>;
export class DynamicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
async getValueAsync({
nonInteractive,
sessionManager,
}: ContextOptions): Promise<DynamicConfigContextFn> {
return async (options?: ExpoConfigOptions) => {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = getExpoConfig(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
env: options?.env,
});
const exp = getExpoConfig(projectDir, options);
return {
exp,
projectDir,
projectId,
};
};
}
}
@@ -1,20 +0,0 @@
import { Actor } from '../../user/User';
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
export default class LoggedInContextField extends ContextField<{
actor: Actor;
graphqlClient: ExpoGraphqlClient;
}> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<{
actor: Actor;
graphqlClient: ExpoGraphqlClient;
}> {
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
return { actor, graphqlClient };
}
}
@@ -1,23 +0,0 @@
import { Actor } from '../../user/User';
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
export default class MaybeLoggedInContextField extends ContextField<{
actor: Actor | null;
graphqlClient: ExpoGraphqlClient;
}> {
async getValueAsync({ sessionManager }: ContextOptions): Promise<{
actor: Actor | null;
graphqlClient: ExpoGraphqlClient;
}> {
const authenticationInfo = {
accessToken: sessionManager.getAccessToken(),
sessionSecret: sessionManager.getSessionSecret(),
};
const graphqlClient = createGraphqlClient(authenticationInfo);
const actor = await sessionManager.getUserAsync();
return { actor: actor ?? null, graphqlClient };
}
}
@@ -1,49 +0,0 @@
import { ExpoConfig } from '@expo/config-types';
import { InvalidEasJsonError } from '@expo/eas-json/build/errors';
import { getExpoConfig } from '../../project/expoConfig';
import ContextField, { ContextOptions } from './ContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
export class OptionalProjectConfigContextField extends ContextField<
| {
projectId: string;
exp: ExpoConfig;
projectDir: string;
}
| undefined
> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<
| {
projectId: string;
exp: ExpoConfig;
projectDir: string;
}
| undefined
> {
let projectDir: string;
try {
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
if (!projectDir) {
return undefined;
}
} catch (e) {
if (e instanceof InvalidEasJsonError) {
throw e;
}
return undefined;
}
const expBefore = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
});
const exp = getExpoConfig(projectDir);
return {
exp,
projectDir,
projectId,
};
}
}
@@ -1,31 +0,0 @@
import { ExpoConfig } from '@expo/config-types';
import { getExpoConfig } from '../../project/expoConfig';
import ContextField, { ContextOptions } from './ContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
export default class ProjectConfigContextField extends ContextField<{
projectId: string;
exp: ExpoConfig;
projectDir: string;
}> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<{
projectId: string;
exp: ExpoConfig;
projectDir: string;
}> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
});
const exp = getExpoConfig(projectDir);
return {
projectId,
exp,
projectDir,
};
}
}
@@ -1,8 +0,0 @@
import ContextField from './ContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
export default class ProjectDirContextField extends ContextField<string> {
async getValueAsync(): Promise<string> {
return await findProjectDirAndVerifyProjectSetupAsync();
}
}
@@ -1,8 +0,0 @@
import SessionManager from '../../user/SessionManager';
import ContextField, { ContextOptions } from './ContextField';
export default class SessionManagementContextField extends ContextField<SessionManager> {
async getValueAsync({ sessionManager }: ContextOptions): Promise<SessionManager> {
return sessionManager;
}
}
@@ -1,62 +0,0 @@
import { vol } from 'memfs';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
jest.mock('@expo/config');
jest.mock('fs');
jest.mock('../../../../prompts');
beforeEach(() => {
jest.resetAllMocks();
});
describe(findProjectRootAsync, () => {
beforeEach(() => {
vol.reset();
});
it('throws if not inside the project directory', async () => {
vol.fromJSON(
{
'./README.md': '1',
},
'/app'
);
await expect(findProjectRootAsync({ cwd: '/app' })).rejects.toThrow(
'Run this command inside a project directory.'
);
});
it('defaults to process.cwd() if defaultToProcessCwd = true', async () => {
const spy = jest.spyOn(process, 'cwd').mockReturnValue('/this/is/fake/process/cwd');
try {
vol.fromJSON(
{
'./README.md': '1',
},
'/app'
);
const projectDir = await findProjectRootAsync({
cwd: '/app',
defaultToProcessCwd: true,
});
expect(projectDir).toBe('/this/is/fake/process/cwd');
} finally {
spy.mockRestore();
}
});
it('returns the root directory of the project', async () => {
vol.fromJSON(
{
'./README.md': '1',
'./package.json': '2',
'./src/index.ts': '3',
},
'/app'
);
const projectRoot = await findProjectRootAsync({ cwd: '/app/src' });
expect(projectRoot).toBe('/app');
});
});
@@ -1,179 +0,0 @@
import { getConfig, modifyConfigAsync } from '@expo/config';
import { vol } from 'memfs';
import { anything, instance, mock, when } from 'ts-mockito';
import { Role } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { learnMore } from '../../../../log';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import SessionManager from '../../../../user/SessionManager';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from '../getProjectIdAsync';
jest.mock('@expo/config');
jest.mock('fs');
jest.mock('../../../../graphql/queries/AppQuery');
jest.mock('../../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
jest.mock('../../../../user/User');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
}),
}));
jest.mock('../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
describe(getProjectIdAsync, () => {
let sessionManager: SessionManager;
beforeEach(() => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
id: 'user_id',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
});
sessionManager = instance(sessionManagerMock);
vol.fromJSON(
{
'./README.md': '1',
'./package.json': '2',
'./src/index.ts': '3',
},
'/app'
);
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
});
it('gets the project ID from app config if exists', async () => {
jest.mocked(getConfig).mockReturnValue({
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
} as any);
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
fullName: '@notnotbrent/test',
slug: 'test',
ownerAccount: { name: 'notnotbrent' } as any,
});
await expect(
getProjectIdAsync(
sessionManager,
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
{ nonInteractive: false }
)
).resolves.toEqual('1234');
});
it('throws when the owner is out of sync', async () => {
jest.mocked(getConfig).mockReturnValue({
exp: { name: 'test', slug: 'test', owner: 'wat', extra: { eas: { projectId: '1234' } } },
} as any);
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
fullName: '@notnotbrent/test',
slug: 'test',
ownerAccount: { name: 'notnotbrent' } as any,
});
await expect(
getProjectIdAsync(
sessionManager,
{ name: 'test', slug: 'test', owner: 'wat', extra: { eas: { projectId: '1234' } } },
{ nonInteractive: false }
)
).rejects.toThrow(
`Project config: Project identified by "extra.eas.projectId" (notnotbrent) is not owned by owner specified in the "owner" field (wat). ${learnMore(
'https://expo.fyi/eas-project-id'
)}`
);
});
it('throws when the slug is out of sync', async () => {
jest.mocked(getConfig).mockReturnValue({
exp: { name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
} as any);
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
fullName: '@notnotbrent/test',
slug: 'test',
ownerAccount: { name: 'notnotbrent' } as any,
});
await expect(
getProjectIdAsync(
sessionManager,
{ name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
{ nonInteractive: false }
)
).rejects.toThrow(
`Project config: Slug for project identified by "extra.eas.projectId" (test) does not match the "slug" field (wat). ${learnMore(
'https://expo.fyi/eas-project-id'
)}`
);
});
it('fetches the project ID when not in app config, and sets it in the config', async () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'success',
config: { expo: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } } },
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '2345');
const projectId = await getProjectIdAsync(
sessionManager,
{ name: 'test', slug: 'test' },
{
nonInteractive: false,
}
);
expect(projectId).toEqual('2345');
expect(modifyConfigAsync).toHaveBeenCalledTimes(1);
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', {
extra: { eas: { projectId: '2345' } },
});
});
it('throws if writing the ID back to the config fails', async () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'fail',
config: null,
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '4567');
await expect(
getProjectIdAsync(sessionManager, { name: 'test', slug: 'test' }, { nonInteractive: false })
).rejects.toThrow();
});
});
@@ -1,63 +0,0 @@
import {
Client,
OperationContext,
OperationResult,
PromisifiedSource,
TypedDocumentNode,
cacheExchange,
createClient as createUrqlClient,
dedupExchange,
fetchExchange,
} from '@urql/core';
import { retryExchange } from '@urql/exchange-retry';
import { DocumentNode } from 'graphql';
import fetch from 'node-fetch';
import { getExpoApiBaseUrl } from '../../../api';
import { httpsProxyAgent } from '../../../fetch';
export interface ExpoGraphqlClient extends Client {
query<Data = any, Variables extends object = object>(
query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
variables: Variables | undefined,
context: Partial<OperationContext> & { additionalTypenames: string[] }
): PromisifiedSource<OperationResult<Data, Variables>>;
}
export function createGraphqlClient(authInfo: {
accessToken: string | null;
sessionSecret: string | null;
}): ExpoGraphqlClient {
return createUrqlClient({
url: getExpoApiBaseUrl() + '/graphql',
exchanges: [
dedupExchange,
cacheExchange,
retryExchange({
maxDelayMs: 4000,
retryIf: (err, operation) => {
return !!(
err &&
!operation.context.noRetry &&
(err.networkError || err.graphQLErrors.some(e => e?.extensions?.isTransient))
);
},
}),
fetchExchange,
],
// @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>'.
fetch,
fetchOptions: (): RequestInit => {
const headers: Record<string, string> = {};
if (authInfo.accessToken) {
headers.authorization = `Bearer ${authInfo.accessToken}`;
} else if (authInfo.sessionSecret) {
headers['expo-session'] = authInfo.sessionSecret;
}
return {
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
headers,
};
},
}) as ExpoGraphqlClient;
}
@@ -1,118 +0,0 @@
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import pkgDir from 'pkg-dir';
import semver from 'semver';
import { learnMore } from '../../../log';
import { easCliVersion } from '../../../utils/easCli';
import { getVcsClient, setVcsClient } from '../../../vcs';
import GitClient from '../../../vcs/clients/git';
async function applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonAccessor = new EasJsonAccessor(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
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 in eas.json (${config.version})`
);
}
if (config?.requireCommit) {
setVcsClient(new GitClient());
}
}
async function ensureEasCliIsNotInDependenciesAsync(projectDir: string): Promise<void> {
let printCliVersionWarning = false;
const consoleWarn = (msg?: string): void => {
if (msg) {
// eslint-disable-next-line no-console
console.warn(chalk.yellow(msg));
} else {
// eslint-disable-next-line no-console
console.warn();
}
};
if (await isEasCliInDependenciesAsync(projectDir)) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your project dependencies.`);
}
const maybeRepoRoot = PackageManagerUtils.findWorkspaceRoot(projectDir) ?? projectDir;
if (maybeRepoRoot !== projectDir && (await isEasCliInDependenciesAsync(maybeRepoRoot))) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your monorepo dependencies.`);
}
if (printCliVersionWarning) {
consoleWarn(
`It's recommended to use the ${chalk.bold(
'"cli.version"'
)} field in eas.json to enforce the ${chalk.bold('eas-cli')} version for your project.`
);
consoleWarn(
learnMore('https://github.com/expo/eas-cli#enforcing-eas-cli-version-for-your-project')
);
consoleWarn();
}
}
async function isEasCliInDependenciesAsync(dir: string): Promise<boolean> {
const packageJsonPath = path.join(dir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
return (
packageJson?.dependencies?.['eas-cli'] !== undefined ||
packageJson?.devDependencies?.['eas-cli'] !== undefined
);
}
/**
* @returns the project root directory.
*
* @deprecated Should not be used outside of context functions.
*/
export async function findProjectRootAsync({
cwd,
defaultToProcessCwd = false,
}: {
cwd?: string;
defaultToProcessCwd?: boolean;
} = {}): Promise<string> {
const projectRootDir = await pkgDir(cwd);
if (!projectRootDir) {
if (!defaultToProcessCwd) {
throw new Error('Run this command inside a project directory.');
} else {
return process.cwd();
}
} else {
let vcsRoot;
try {
vcsRoot = path.normalize(await getVcsClient().getRootPathAsync());
} catch {}
if (vcsRoot && vcsRoot.startsWith(projectRootDir) && vcsRoot !== projectRootDir) {
throw new Error(
`package.json is outside of the current git repository (project root: ${projectRootDir}, git root: ${vcsRoot}.`
);
}
return projectRootDir;
}
}
/**
* Determine the project root directory and ensure some constraints about the project setup
* like CLI version and dependencies.
* @returns the project root directory
*
* @deprecated Should not be used outside of context functions.
*/
export async function findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
const projectDir = await findProjectRootAsync();
await applyCliConfigAsync(projectDir);
await ensureEasCliIsNotInDependenciesAsync(projectDir);
return projectDir;
}
@@ -1,157 +0,0 @@
import { getProjectConfigDescription, modifyConfigAsync } from '@expo/config';
import { ExpoConfig } from '@expo/config-types';
import { Env } from '@expo/eas-build-job';
import chalk from 'chalk';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { learnMore } from '../../../log';
import { ora } from '../../../ora';
import { getExpoConfig } from '../../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { toAppPrivacy } from '../../../project/projectUtils';
import SessionManager from '../../../user/SessionManager';
import { Actor } from '../../../user/User';
import { createGraphqlClient } from './createGraphqlClient';
import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync';
/**
* Save an EAS project ID to the appropriate field in the app config.
*
* @deprecated Should not be used outside of context functions except in the init command.
*/
export async function saveProjectIdToAppConfigAsync(
projectDir: string,
projectId: string,
options: { env?: Env } = {}
): Promise<void> {
const exp = getExpoConfig(projectDir, options);
const result = await modifyConfigAsync(projectDir, {
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
});
switch (result.type) {
case 'success':
break;
case 'warn': {
Log.warn();
Log.warn(
`Warning: Your project uses dynamic app configuration, and the EAS project ID can't automatically be added to it.`
);
Log.warn(
chalk.dim(
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
)
);
Log.warn();
Log.warn(
`To complete the setup process, set "${chalk.bold(
'extra.eas.projectId'
)}" in your ${chalk.bold(getProjectConfigDescription(projectDir))}:`
);
Log.warn();
Log.warn(chalk.bold(JSON.stringify({ expo: { extra: { eas: { projectId } } } }, null, 2)));
Log.warn();
throw new Error(result.message);
}
case 'fail':
throw new Error(result.message);
default:
throw new Error('Unexpected result type from modifyConfigAsync');
}
}
/**
* Get the EAS project ID from the app config. If the project ID is not set in the config,
* use the owner/slug to identify an EAS project on the server (asking for confirmation first),
* and attempt to save the EAS project ID to the appropriate field in the app config. If unable to
* save to the app config, throw an error.
*
* @returns the EAS project ID
*
* @deprecated Should not be used outside of context functions.
*/
export async function getProjectIdAsync(
sessionManager: SessionManager,
exp: ExpoConfig,
options: { env?: Env; nonInteractive: boolean }
): Promise<string> {
// all codepaths in this function require a logged-in user with access to the owning account
// since they either query the app via graphql or create it, which includes getting info about
// the owner
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive: options.nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const localProjectId = exp.extra?.eas?.projectId;
if (localProjectId) {
// check that the local project ID matches account and slug
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, localProjectId);
if (exp.owner && exp.owner !== appForProjectId.ownerAccount.name) {
throw new Error(
`Project config: Project identified by "extra.eas.projectId" (${
appForProjectId.ownerAccount.name
}) is not owned by owner specified in the "owner" field (${exp.owner}). ${learnMore(
'https://expo.fyi/eas-project-id'
)}`
);
}
if (exp.slug && exp.slug !== appForProjectId.slug) {
throw new Error(
`Project config: Slug for project identified by "extra.eas.projectId" (${
appForProjectId.slug
}) does not match the "slug" field (${exp.slug}). ${learnMore(
'https://expo.fyi/eas-project-id'
)}`
);
}
return localProjectId;
}
const projectDir = await findProjectRootAsync();
if (!projectDir) {
throw new Error('This command must be run inside a project directory.');
}
Log.warn('EAS project not configured.');
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
if (exp.owner) {
return exp.owner;
}
switch (user.__typename) {
case 'User':
return user.username;
case 'Robot':
throw new Error(
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
);
}
};
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
graphqlClient,
{
accountName: getAccountNameForEASProjectSync(exp, actor),
projectName: exp.slug,
privacy: toAppPrivacy(exp.privacy),
},
{
nonInteractive: options.nonInteractive,
},
actor
);
const spinner = ora(`Linking local project to EAS project ${projectId}`).start();
try {
await saveProjectIdToAppConfigAsync(projectDir, projectId, options);
spinner.succeed(`Linked local project to EAS project ${projectId}`);
} catch (e: any) {
spinner.fail();
throw e;
}
return projectId;
}
@@ -1,23 +0,0 @@
import { Flags } from '@oclif/core';
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
dependsOn: ['non-interactive'],
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export const EASNonInteractiveFlag = {
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export const EasJsonOnlyFlag = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
}),
};
@@ -1,64 +0,0 @@
import { Flags } from '@oclif/core';
import { EasNonInteractiveAndJsonFlags } from './flags';
export const getPaginatedQueryOptions = (
flags: Partial<
Record<keyof typeof EasPaginatedQueryFlags | keyof typeof EasNonInteractiveAndJsonFlags, any>
>
): PaginatedQueryOptions => {
return {
...('limit' in flags && { limit: flags.limit }),
offset: flags.offset ?? 0,
nonInteractive: flags['non-interactive'] ?? false,
json: flags.json ?? false,
};
};
const parseFlagInputStringAsInteger = (
input: string,
flagName: string,
lowerLimit: number,
upperLimit: number
): number => {
const inputAsNumber = Number(input);
if (isNaN(inputAsNumber)) {
throw new Error(`Unable to parse ${input} as a number`);
}
if (inputAsNumber < lowerLimit || inputAsNumber > upperLimit) {
throw new Error(`--${flagName} must be between ${lowerLimit} and ${upperLimit}`);
}
return inputAsNumber;
};
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
}: {
defaultTo: number;
limit: number;
}): any =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
export const EasPaginatedQueryFlags = {
offset: Flags.integer({
description: 'Start queries from specified index. Use for paginating results. Defaults to 0.',
// eslint-disable-next-line async-protect/async-suffix
parse: async input =>
parseFlagInputStringAsInteger(input, 'offset', 0, Number.MAX_SAFE_INTEGER),
}),
limit: getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 }),
};
// options required to control a paginated query
// from user input
export type PaginatedQueryOptions = {
nonInteractive: boolean;
json: boolean;
limit?: number;
offset: number;
};
@@ -1,17 +1,16 @@
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { showLoginPromptAsync } from '../../user/actions';
export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
static override aliases = ['login'];
static description = 'log in with your Expo account';
static aliases = ['login'];
static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};
protected mustBeRunInsideProject = false;
protected requiresAuthentication = false;
async runAsync(): Promise<void> {
const { sessionManager } = await this.getContextAsync(AccountLogin, { nonInteractive: false });
await sessionManager.showLoginPromptAsync();
await showLoginPromptAsync();
Log.log('Logged in');
}
}
@@ -1,17 +1,16 @@
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { logoutAsync } from '../../user/User';
export default class AccountLogout extends EasCommand {
static override description = 'log out';
static override aliases = ['logout'];
static description = 'log out';
static aliases = ['logout'];
static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};
protected mustBeRunInsideProject = false;
protected requiresAuthentication = false;
async runAsync(): Promise<void> {
const { sessionManager } = await this.getContextAsync(AccountLogout, { nonInteractive: false });
await sessionManager.logoutAsync();
await logoutAsync();
Log.log('Logged out');
}
}
+8 -11
View File
@@ -2,22 +2,19 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { getActorDisplayName } from '../../user/User';
import { getActorDisplayName, getUserAsync } from '../../user/User';
export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
static override aliases = ['whoami'];
static description = 'show the username you are logged in as';
static aliases = ['whoami'];
static override contextDefinition = {
...this.ContextOptions.MaybeLoggedIn,
};
protected mustBeRunInsideProject = false;
protected requiresAuthentication = false;
async runAsync(): Promise<void> {
const {
maybeLoggedIn: { actor },
} = await this.getContextAsync(AccountView, { nonInteractive: true });
if (actor) {
Log.log(chalk.green(getActorDisplayName(actor)));
const user = await getUserAsync();
if (user) {
Log.log(chalk.green(getActorDisplayName(user)));
} else {
Log.warn('Not logged in');
process.exit(1);
+4 -2
View File
@@ -3,9 +3,11 @@ import Log from '../log';
import UserSettings from '../user/UserSettings';
export default class AnalyticsView extends EasCommand {
static override description = 'display or change analytics settings';
static description = 'display or change analytics settings';
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
static args = [{ name: 'STATUS', options: ['on', 'off'] }];
protected requiresAuthentication = false;
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
+34 -36
View File
@@ -1,25 +1,29 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { getDefaultBranchNameAsync } from '../../branch/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables,
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getVcsClient } from '../../vcs';
export async function createUpdateBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: CreateUpdateBranchForAppMutationVariables
): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
export async function createUpdateBranchOnAppAsync({
appId,
name,
}: CreateUpdateBranchForAppMutationVariables): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
@@ -48,9 +52,9 @@ export async function createUpdateBranchOnAppAsync(
}
export default class BranchCreate extends EasCommand {
static override description = 'create a branch';
static description = 'create a branch';
static override args = [
static args = [
{
name: 'name',
required: false,
@@ -58,56 +62,50 @@ export default class BranchCreate extends EasCommand {
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: 'return a json with the new branch ID and name.',
default: false,
}),
};
async runAsync(): Promise<void> {
let {
args: { name },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(BranchCreate);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchCreate, {
nonInteractive,
});
if (jsonFlag) {
if (flags.json) {
enableJsonOutput();
}
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
if (!name) {
const validationMessage = 'Branch name may not be empty.';
if (nonInteractive) {
if (flags.json) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide a branch name:',
initial: await getDefaultBranchNameAsync(),
message: 'Please name the branch:',
initial:
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
}
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, { appId: projectId, name });
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
if (jsonFlag) {
if (flags.json) {
printJsonOnlyOutput(newBranch);
} else {
Log.withTick(
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(
projectDisplayName
)}.`
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(fullName)}.`
);
}
}
+43 -51
View File
@@ -1,12 +1,9 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { withErrorHandlingAsync } from '../../graphql/client';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
DeleteUpdateBranchMutationVariables,
@@ -15,14 +12,19 @@ import {
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function getBranchInfoAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: GetBranchInfoQueryVariables
): Promise<GetBranchInfoQuery> {
async function getBranchInfoAsync({
appId,
name,
}: GetBranchInfoQueryVariables): Promise<GetBranchInfoQuery> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetBranchInfoQuery, GetBranchInfoQueryVariables>(
@@ -50,10 +52,9 @@ async function getBranchInfoAsync(
return data;
}
async function deleteBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ branchId }: DeleteUpdateBranchMutationVariables
): Promise<DeleteUpdateBranchResult> {
async function deleteBranchOnAppAsync({
branchId,
}: DeleteUpdateBranchMutationVariables): Promise<DeleteUpdateBranchResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
@@ -76,77 +77,70 @@ async function deleteBranchOnAppAsync(
}
export default class BranchDelete extends EasCommand {
static override description = 'delete a branch';
static description = 'delete a branch';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
static override args = [
static args = [
{
name: 'name',
required: false,
description: 'Name of the branch to delete',
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
static flags = {
json: Flags.boolean({
description: `return JSON with the edited branch's ID and name.`,
default: false,
}),
};
async runAsync(): Promise<void> {
let {
args: { name: branchName },
flags,
args: { name },
flags: { json: jsonFlag },
} = await this.parse(BranchDelete);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchDelete, { nonInteractive });
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
if (!branchName) {
if (!name) {
const validationMessage = 'branch name may not be empty.';
if (nonInteractive) {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: branchName } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
displayTextForListItem: updateBranch => updateBranch.name,
promptTitle: 'Which branch would you like to delete?',
paginatedQueryOptions,
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the branch to delete:',
validate: value => (value ? true : validationMessage),
}));
}
const data = await getBranchInfoAsync(graphqlClient, { appId: projectId, name: branchName });
const data = await getBranchInfoAsync({ appId: projectId, name });
const branchId = data.app?.byId.updateBranchByName?.id;
if (!branchId) {
throw new Error(`Could not find branch ${branchName} on ${projectDisplayName}`);
throw new Error(`Could not find branch ${name} on ${fullName}`);
}
if (!nonInteractive) {
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete branch: "${branchName}" and all of the updates published on it.` +
`You are about to permamently delete branch: "${name}" and all of the updates published on it.` +
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Cancelled deletion of branch: "${branchName}".`);
Log.error(`Cancelled deletion of branch: "${name}".`);
process.exit(1);
}
}
const deletionResult = await deleteBranchOnAppAsync(graphqlClient, {
const deletionResult = await deleteBranchOnAppAsync({
branchId,
});
@@ -154,9 +148,7 @@ export default class BranchDelete extends EasCommand {
printJsonOnlyOutput(deletionResult);
} else {
Log.withTick(
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
projectDisplayName
)}.`
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(fullName)}.`
);
}
}
+93 -23
View File
@@ -1,36 +1,106 @@
import { listAndRenderBranchesOnAppAsync } from '../../branch/queries';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import CliTable from 'cli-table3';
import { print } from 'graphql';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
BranchesByAppQuery,
BranchesByAppQueryVariables,
UpdateBranchFragment,
} from '../../graphql/generated';
import { UpdateBranchFragmentNode } from '../../graphql/types/UpdateBranch';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const BRANCHES_LIMIT = 10_000;
export async function listBranchesAsync({
projectId,
}: {
projectId: string;
}): Promise<UpdateBranchFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<BranchesByAppQuery, BranchesByAppQueryVariables>(
gql`
query BranchesByAppQuery($appId: String!, $limit: Int!) {
app {
byId(appId: $appId) {
id
updateBranches(offset: 0, limit: $limit) {
id
...UpdateBranchFragment
}
}
}
}
${print(UpdateBranchFragmentNode)}
`,
{
appId: projectId,
limit: BRANCHES_LIMIT,
},
{ additionalTypenames: ['UpdateBranch'] }
)
.toPromise()
);
return data?.app?.byId.updateBranches ?? [];
}
export default class BranchList extends EasCommand {
static override description = 'list all branches';
static description = 'list all branches';
static override flags = {
...EasPaginatedQueryFlags,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: 'return output as JSON',
default: false,
}),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BranchList);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchList, {
nonInteractive: flags['non-interactive'],
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (paginatedQueryOptions.json) {
if (flags.json) {
enableJsonOutput();
}
await listAndRenderBranchesOnAppAsync(graphqlClient, { projectId, paginatedQueryOptions });
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const branches = await listBranchesAsync({ projectId });
if (flags.json) {
printJsonOnlyOutput(branches);
} else {
const table = new CliTable({
head: ['Branch', ...UPDATE_COLUMNS],
wordWrap: true,
});
table.push(
...branches.map(branch => [
branch.name,
formatUpdate(branch.updates[0]),
branch.updates[0]?.runtimeVersion ?? 'N/A',
branch.updates[0]?.group ?? 'N/A',
getPlatformsForGroup({
updates: branch.updates,
group: branch.updates[0]?.group,
}),
])
);
Log.addNewLineIfNone();
Log.log(chalk.bold('Branches with their most recent update group:'));
Log.log(table.toString());
if (branches.length >= BRANCHES_LIMIT) {
Log.warn(`Showing first ${BRANCHES_LIMIT} branches, some results might be omitted.`);
}
}
}
}
@@ -1,8 +1,8 @@
import EasCommand from '../../commandUtils/EasCommand';
export default class BranchPublish extends EasCommand {
static override description = 'deprecated, use "eas update"';
static override hidden = true;
static description = 'deprecated, use "eas update"';
static hidden = true;
async runAsync(): Promise<void> {
throw new Error(BranchPublish.description);
+29 -30
View File
@@ -3,9 +3,7 @@ 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 { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
EditUpdateBranchInput,
EditUpdateBranchMutation,
@@ -13,14 +11,20 @@ import {
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function renameUpdateBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name, newName }: EditUpdateBranchInput
): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
async function renameUpdateBranchOnAppAsync({
appId,
name,
newName,
}: EditUpdateBranchInput): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<EditUpdateBranchMutation, EditUpdateBranchMutationVariables>(
@@ -48,9 +52,9 @@ async function renameUpdateBranchOnAppAsync(
}
export default class BranchRename extends EasCommand {
static override description = 'rename a branch';
static description = 'rename a branch';
static override flags = {
static flags = {
from: Flags.string({
description: 'current name of the branch.',
required: false,
@@ -59,57 +63,52 @@ export default class BranchRename extends EasCommand {
description: 'new name of the branch.',
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
json: Flags.boolean({
description: `return a json with the edited branch's ID and name.`,
default: false,
}),
};
async runAsync(): Promise<void> {
let {
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
flags: { json: jsonFlag, from: currentName, to: newName },
} = await this.parse(BranchRename);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchRename, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
if (!currentName) {
const validationMessage = 'current name may not be empty.';
if (nonInteractive) {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ currentName } = await promptAsync({
type: 'text',
name: 'currentName',
message: "Provide the name of the branch you'd like to rename:",
message: 'Please enter the current name of the branch to rename:',
validate: value => (value ? true : validationMessage),
}));
}
if (!newName) {
const validationMessage = 'new name may not be empty.';
if (nonInteractive) {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ newName } = await promptAsync({
type: 'text',
name: 'newName',
message: `Rename ${currentName}`,
message: `Please rename ${currentName}`,
validate: value => (value ? true : validationMessage),
}));
}
const editedBranch = await renameUpdateBranchOnAppAsync(graphqlClient, {
const editedBranch = await renameUpdateBranchOnAppAsync({
appId: projectId,
name: currentName!,
newName: newName!,
@@ -121,7 +120,7 @@ export default class BranchRename extends EasCommand {
Log.withTick(
`️Renamed branch from ${currentName} to ${chalk.bold(
editedBranch.name
)} on project ${chalk.bold(projectDisplayName)}.`
)} on project ${chalk.bold(fullName)}.`
);
}
}
+80 -50
View File
@@ -1,18 +1,22 @@
import { selectBranchOnAppAsync } from '../../branch/queries';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import Table from 'cli-table3';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { listAndRenderUpdateGroupsOnBranchAsync } from '../../update/queries';
import { enableJsonOutput } from '../../utils/json';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update/utils';
import groupBy from '../../utils/expodash/groupBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
static override description = 'view a branch';
static description = 'view a branch';
static override args = [
static args = [
{
name: 'name',
required: false,
@@ -20,57 +24,83 @@ export default class BranchView extends EasCommand {
},
];
static override flags = {
...EasPaginatedQueryFlags,
...EasNonInteractiveAndJsonFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 25, limit: 50 }),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: `return a json with the branch's ID name and recent update groups.`,
default: false,
}),
};
async runAsync(): Promise<void> {
let {
args: { name: branchName },
flags,
args: { name },
flags: { json: jsonFlag },
} = await this.parse(BranchView);
const { 'non-interactive': nonInteractive } = flags;
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchView, {
nonInteractive,
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (paginatedQueryOptions.json) {
if (jsonFlag) {
enableJsonOutput();
}
if (!branchName) {
if (nonInteractive) {
throw new Error('Branch name may not be empty.');
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
({ name: branchName } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Which branch would you like to view?',
displayTextForListItem: updateBranch => updateBranch.name,
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: paginatedQueryOptions.json,
nonInteractive,
offset: 0,
},
if (!name) {
const validationMessage = 'Branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the branch to view:',
validate: value => (value ? true : validationMessage),
}));
}
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions,
const { app } = await UpdateQuery.viewBranchAsync({
appId: projectId,
name,
});
const UpdateBranch = app?.byId.updateBranchByName;
if (!UpdateBranch) {
throw new Error(`Could not find branch "${name}"`);
}
const updates = Object.values(groupBy(UpdateBranch.updates, u => u.group)).map(
group => group[0]
);
if (jsonFlag) {
printJsonOnlyOutput({ ...UpdateBranch, updates });
} else {
const groupTable = new Table({
head: UPDATE_COLUMNS,
wordWrap: true,
});
for (const update of updates) {
groupTable.push([
formatUpdate(update),
update.runtimeVersion,
update.group,
getPlatformsForGroup({
updates: UpdateBranch.updates,
group: update.group,
}),
]);
}
Log.addNewLineIfNone();
Log.log(chalk.bold('Branch:'));
Log.log(
formatFields([
{ label: 'Name', value: UpdateBranch.name },
{ label: 'ID', value: UpdateBranch.id },
])
);
Log.addNewLineIfNone();
Log.log(chalk.bold('Recently published update groups:'));
Log.log(groupTable.toString());
}
}
}
+36 -76
View File
@@ -2,9 +2,7 @@ import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
Build,
BuildStatus,
@@ -15,13 +13,15 @@ import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { appPlatformEmojis } from '../../platform';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../prompts';
async function cancelBuildAsync(
graphqlClient: ExpoGraphqlClient,
buildId: string
): Promise<Pick<Build, 'id' | 'status'>> {
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
@@ -59,46 +59,36 @@ function formatUnfinishedBuild(
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
}
export async function selectBuildToCancelAsync(
graphqlClient: ExpoGraphqlClient,
async function selectBuildToCancelAsync(
projectId: string,
projectDisplayName: string
projectFullName: string
): Promise<string | null> {
const spinner = ora().start('Fetching the uncompleted builds…');
let builds;
try {
const [newBuilds, inQueueBuilds, inProgressBuilds] = await Promise.all([
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: 10,
filter: { status: BuildStatus.New },
}),
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: 10,
filter: { status: BuildStatus.InQueue },
}),
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: 10,
filter: { status: BuildStatus.InProgress },
}),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.New } }),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.InQueue } }),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.InProgress } }),
]);
spinner.stop();
builds = [...newBuilds, ...inQueueBuilds, ...inProgressBuilds];
} catch (error) {
spinner.fail(
`Something went wrong and we couldn't fetch the builds for the project ${projectDisplayName}.`
`Something went wrong and we couldn't fetch the builds for the project ${projectFullName}.`
);
throw error;
}
if (builds.length === 0) {
Log.warn(`There aren't any uncompleted builds for the project ${projectDisplayName}.`);
Log.warn(`There aren't any uncompleted builds for the project ${projectFullName}.`);
return null;
} else if (builds.length === 1) {
Log.log('Found one build');
Log.log(formatUnfinishedBuild(builds[0]));
await confirmAsync({
message: 'Do you want to cancel it?',
});
return builds[0].id;
} else {
const buildId = await selectAsync<string>(
'Which build do you want to cancel?',
@@ -107,73 +97,43 @@ export async function selectBuildToCancelAsync(
value: build.id,
}))
);
return (await confirmAsync({
message: 'Are you sure you want to cancel it?',
}))
? buildId
: null;
return buildId;
}
}
async function ensureBuildExistsAsync(
graphqlClient: ExpoGraphqlClient,
buildId: string
): Promise<void> {
async function ensureBuildExistsAsync(buildId: string): Promise<void> {
try {
await BuildQuery.byIdAsync(graphqlClient, buildId);
await BuildQuery.byIdAsync(buildId);
} catch {
throw new Error(`Couldn't find a build matching the id ${buildId}`);
}
}
export default class BuildCancel extends EasCommand {
static override description = 'cancel a build';
static description = 'cancel a build';
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
static args = [{ name: 'BUILD_ID' }];
async runAsync(): Promise<void> {
const {
args: { BUILD_ID: buildIdFromArg },
flags: { 'non-interactive': nonInteractive },
} = await this.parse(BuildCancel);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildCancel, {
nonInteractive,
});
const { BUILD_ID: buildIdFromArg } = (await this.parse(BuildCancel)).args;
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
if (buildIdFromArg) {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
await ensureBuildExistsAsync(buildIdFromArg);
}
let buildId: string | null = buildIdFromArg;
const buildId = buildIdFromArg || (await selectBuildToCancelAsync(projectId, projectFullName));
if (!buildId) {
if (nonInteractive) {
throw new Error('BUILD_ID must not be empty in non-interactive mode');
}
buildId = await selectBuildToCancelAsync(graphqlClient, projectId, displayName);
if (!buildId) {
return;
}
return;
}
const spinner = ora().start('Canceling the build…');
try {
const { status } = await cancelBuildAsync(graphqlClient, buildId);
const { status } = await cancelBuildAsync(buildId);
if (status === BuildStatus.Canceled) {
spinner.succeed('Build canceled');
} else {
@@ -7,7 +7,8 @@ import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import Log, { learnMore } from '../../log';
import { RequestedPlatform } from '../../platform';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
@@ -15,9 +16,9 @@ import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } fro
import { getVcsClient } from '../../vcs';
export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
static description = 'configure the project to support EAS Build';
static override flags = {
static flags = {
platform: Flags.enum({
description: 'Platform to configure',
char: 'p',
@@ -25,19 +26,8 @@ export default class BuildConfigure extends EasCommand {
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildConfigure);
const {
projectConfig: { exp, projectId, projectDir },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildConfigure, {
nonInteractive: false,
});
Log.log(
'💡 The following process will configure your iOS and/or Android project to be compatible with EAS Build. These changes only apply to your local project files and you can safely revert them at any time.'
@@ -45,6 +35,7 @@ export default class BuildConfigure extends EasCommand {
await getVcsClient().ensureRepoExistsAsync();
const projectDir = await findProjectRootAsync();
const expoUpdatesIsInstalled = isExpoUpdatesInstalled(projectDir);
const platform =
@@ -64,17 +55,19 @@ export default class BuildConfigure extends EasCommand {
// configure expo-updates
if (expoUpdatesIsInstalled) {
const exp = getExpoConfig(projectDir);
if ([RequestedPlatform.Android, RequestedPlatform.All].includes(platform)) {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
if (workflow === Workflow.GENERIC) {
await syncAndroidUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
await syncAndroidUpdatesConfigurationAsync(projectDir, exp);
}
}
if ([RequestedPlatform.Ios, RequestedPlatform.All].includes(platform)) {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) {
await syncIosUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
await syncIosUpdatesConfigurationAsync(projectDir, exp);
}
}
}
+31 -184
View File
@@ -1,22 +1,13 @@
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import figures from 'figures';
import fs from 'fs-extra';
import path from 'path';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { UserInputResourceClass } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import Log from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
import { selectAsync } from '../../prompts';
import uniq from '../../utils/expodash/uniq';
import { findProjectRootAsync } from '../../project/projectUtils';
import { enableJsonOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
interface RawBuildFlags {
platform?: string;
@@ -32,13 +23,12 @@ interface RawBuildFlags {
'auto-submit': boolean;
'auto-submit-with-profile'?: string;
'resource-class'?: UserInputResourceClass;
message?: string;
}
export default class Build extends EasCommand {
static override description = 'start a build';
static description = 'start a build';
static override flags = {
static flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'],
@@ -47,16 +37,23 @@ export default class Build extends EasCommand {
default: false,
hidden: true,
}),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
default: false,
}),
'skip-project-configuration': Flags.boolean({
default: false,
hidden: true,
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
}),
local: Flags.boolean({
default: false,
description: 'Run build locally [experimental]',
@@ -89,60 +86,21 @@ export default class Build extends EasCommand {
hidden: true,
description: 'The instance type that will be used to run this build [experimental]',
}),
message: Flags.string({
char: 'm',
description: 'A short message describing the build',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(Build);
if (rawFlags.json) {
enableJsonOutput();
}
const flags = this.sanitizeFlags(rawFlags);
const flags = await this.sanitizeFlagsAsync(rawFlags);
const {
loggedIn: { actor, graphqlClient },
getDynamicProjectConfigAsync,
projectDir,
} = await this.getContextAsync(Build, {
nonInteractive: flags.nonInteractive,
});
const projectDir = await findProjectRootAsync();
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
if (!flags.localBuildOptions.enable) {
await maybeWarnAboutEasOutagesAsync(
graphqlClient,
flags.autoSubmit
? [StatuspageServiceName.EasBuild, StatuspageServiceName.EasSubmit]
: [StatuspageServiceName.EasBuild]
);
}
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
await runBuildAndSubmitAsync(
graphqlClient,
projectDir,
flagsWithPlatform,
actor,
getDynamicProjectConfigAsync
);
await runBuildAndSubmitAsync(projectDir, flags);
}
private sanitizeFlags(
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
private async sanitizeFlagsAsync(flags: RawBuildFlags): Promise<BuildFlags> {
const nonInteractive = flags['non-interactive'];
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
@@ -153,23 +111,22 @@ export default class Build extends EasCommand {
if (flags.json && !nonInteractive) {
Errors.error('--json is allowed only when building in non-interactive mode', { exit: 1 });
}
if (
flags['resource-class'] === UserInputResourceClass.M1_EXPERIMENTAL &&
flags.platform !== Platform.IOS
) {
Errors.error(
`Resource class ${UserInputResourceClass.M1_EXPERIMENTAL} is only available for iOS builds`,
{
exit: 1,
}
);
}
const requestedPlatform =
flags.platform &&
Object.values(RequestedPlatform).includes(flags.platform.toLowerCase() as RequestedPlatform)
? (flags.platform.toLowerCase() as RequestedPlatform)
: undefined;
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
if (flags.local) {
if (flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined) {
// TODO: implement this
Errors.error('Auto-submits are not yet supported when building locally', { exit: 1 });
}
if (requestedPlatform === RequestedPlatform.All) {
Errors.error('Builds for multiple platforms are not supported with flag --local', {
exit: 1,
});
} else if (process.platform !== 'darwin' && requestedPlatform === RequestedPlatform.Ios) {
Errors.error('Unsupported platform, macOS is required to build apps for iOS', { exit: 1 });
}
}
if (flags['skip-credentials-check']) {
Log.warnDeprecatedFlag(
@@ -186,11 +143,6 @@ export default class Build extends EasCommand {
Log.newLine();
}
const message = flags['message'];
if (message && message.length > 1024) {
Errors.error('Message cannot be longer than 1024 characters.', { exit: 1 });
}
const profile = flags['profile'];
return {
requestedPlatform,
@@ -211,111 +163,6 @@ export default class Build extends EasCommand {
autoSubmit: flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
userInputResourceClass: flags['resource-class'] ?? UserInputResourceClass.DEFAULT,
message,
};
}
private async ensurePlatformSelectedAsync(
flags: Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform }
): Promise<BuildFlags> {
const requestedPlatform = await selectRequestedPlatformAsync(flags.requestedPlatform);
if (flags.localBuildOptions.enable) {
if (flags.autoSubmit) {
// TODO: implement this
Errors.error('Auto-submits are not yet supported when building locally', { exit: 1 });
}
if (requestedPlatform === RequestedPlatform.All) {
Errors.error('Builds for multiple platforms are not supported with flag --local', {
exit: 1,
});
} else if (process.platform !== 'darwin' && requestedPlatform === RequestedPlatform.Ios) {
Errors.error('Unsupported platform, macOS is required to build apps for iOS', { exit: 1 });
}
}
return {
...flags,
requestedPlatform,
};
}
}
async function handleDeprecatedEasJsonAsync(
projectDir: string,
nonInteractive: boolean
): Promise<void> {
if (!(await fs.pathExists(EasJsonAccessor.formatEasJsonPath(projectDir)))) {
return;
}
const easJsonAccessor = new EasJsonAccessor(projectDir);
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
const platformAndProfileNames: [Platform, string][] = profileNames.flatMap(profileName => [
[Platform.ANDROID, profileName],
[Platform.IOS, profileName],
]);
const deprecatedProfiles: [Platform, string][] = [];
for (const [platform, profileName] of platformAndProfileNames) {
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
profileName
);
if (buildProfile.artifactPath) {
deprecatedProfiles.push([platform, profileName]);
}
}
if (deprecatedProfiles.length === 0) {
return;
}
const deprecatedProfileNames = uniq(deprecatedProfiles.map(([, profileName]) => profileName));
Log.warn(`Some of your build profiles use deprecated field ${chalk.bold('artifactPath')}:`);
for (const profileName of deprecatedProfileNames) {
Log.warn(`- ${profileName}`);
}
Log.newLine();
if (nonInteractive) {
Log.warn(
`${figures.warning} Action required: rename ${chalk.bold('artifactPath')} to ${chalk.bold(
'applicationArchivePath'
)} in all of the build profiles listed above.`
);
Log.warn(
`See ${link('https://docs.expo.dev/build-reference/eas-json/')} for more information.`
);
Log.warn(
`This warning will become an error in a future EAS CLI release. This build will continue to use the ${chalk.bold(
'artifactPath'
)} setting.`
);
return;
}
const rename = await selectAsync('Do you want us to handle renaming the field for you?', [
{ title: 'Yes', value: true },
{ title: 'No, I will edit eas.json manually (EAS CLI exits)', value: false },
]);
if (!rename) {
Errors.exit(1);
}
await easJsonAccessor.readRawJsonAsync();
for (const [platform, profileName] of deprecatedProfiles) {
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.build[profileName][platform].applicationArchivePath =
easJsonRawObject.build[profileName][platform].artifactPath;
delete easJsonRawObject.build[profileName][platform].artifactPath;
return easJsonRawObject;
});
}
await easJsonAccessor.writeAsync();
Log.withTick('Updated eas.json');
}
+20 -39
View File
@@ -9,6 +9,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { findProjectRootAsync } from '../../project/projectUtils';
import { getTmpDirectory } from '../../utils/paths';
import { getVcsClient } from '../../vcs';
@@ -24,17 +25,16 @@ const STAGE_DESCRIPTION = `Stage of the build you want to inspect.
post-build - builds the native project and leaves the output directory for inspection`;
export default class BuildInspect extends EasCommand {
static override description =
static description =
'inspect the state of the project at specific build stages, useful for troubleshooting';
static override flags = {
static flags = {
platform: Flags.enum({
char: 'p',
options: [RequestedPlatform.Android, RequestedPlatform.Ios],
required: true,
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
@@ -61,22 +61,8 @@ export default class BuildInspect extends EasCommand {
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildInspect);
const {
loggedIn: { actor, graphqlClient },
getDynamicProjectConfigAsync,
projectDir,
} = await this.getContextAsync(BuildInspect, {
nonInteractive: false,
});
const outputDirectory = path.resolve(process.cwd(), flags.output);
const tmpWorkingdir = path.join(getTmpDirectory(), uuidv4());
@@ -94,30 +80,25 @@ export default class BuildInspect extends EasCommand {
await vcs.makeShallowCopyAsync(tmpWorkingdir);
await this.copyToOutputDirAsync(tmpWorkingdir, outputDirectory);
} else {
const projectDir = await findProjectRootAsync();
try {
await runBuildAndSubmitAsync(
graphqlClient,
projectDir,
{
nonInteractive: false,
wait: true,
clearCache: false,
json: false,
autoSubmit: false,
requestedPlatform: flags.platform,
profile: flags.profile,
localBuildOptions: {
enable: true,
...(flags.stage === InspectStage.PRE_BUILD ? { skipNativeBuild: true } : {}),
...(flags.stage === InspectStage.POST_BUILD ? { skipCleanup: true } : {}),
verbose: flags.verbose,
workingdir: tmpWorkingdir,
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
},
await runBuildAndSubmitAsync(projectDir, {
nonInteractive: false,
wait: true,
clearCache: false,
json: false,
autoSubmit: false,
requestedPlatform: flags.platform,
profile: flags.profile,
localBuildOptions: {
enable: true,
...(flags.stage === InspectStage.PRE_BUILD ? { skipNativeBuild: true } : {}),
...(flags.stage === InspectStage.POST_BUILD ? { skipCleanup: true } : {}),
verbose: flags.verbose,
workingdir: tmpWorkingdir,
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
},
actor,
getDynamicProjectConfigAsync
);
});
if (!flags.verbose) {
Log.log(chalk.green('Build successful'));
}
+71 -48
View File
@@ -1,30 +1,36 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { BUILDS_LIMIT, listAndRenderBuildsOnAppAsync } from '../../build/queries';
import { BuildDistributionType, BuildStatus } from '../../build/types';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import {
AppPlatform,
DistributionType,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput } from '../../utils/json';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildList extends EasCommand {
static override description = 'list all builds for your project';
static description = 'list all builds for your project';
static override flags = {
static flags = {
platform: Flags.enum({
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.Ios],
}),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
}),
status: Flags.enum({
options: [
BuildStatus.NEW,
@@ -50,59 +56,76 @@ export default class BuildList extends EasCommand {
appIdentifier: Flags.string(),
buildProfile: Flags.string(),
gitCommitHash: Flags.string(),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: BUILDS_LIMIT }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
limit: Flags.integer(),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const {
json: jsonFlag,
json,
platform: requestedPlatform,
status: buildStatus,
distribution: buildDistribution,
'non-interactive': nonInteractive,
limit = 10,
} = flags;
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildList, {
nonInteractive,
});
if (jsonFlag) {
if (json) {
enableJsonOutput();
}
const platform = toAppPlatform(requestedPlatform);
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
const graphqlBuildDistribution = toGraphQLBuildDistribution(buildDistribution);
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
await listAndRenderBuildsOnAppAsync(graphqlClient, {
projectId,
projectDisplayName: displayName,
filter: {
platform,
status: graphqlBuildStatus,
distribution: graphqlBuildDistribution,
channel: flags.channel,
appVersion: flags.appVersion,
appBuildVersion: flags.appBuildVersion,
sdkVersion: flags.sdkVersion,
runtimeVersion: flags.runtimeVersion,
appIdentifier: flags.appIdentifier,
buildProfile: flags.buildProfile,
gitCommitHash: flags.gitCommitHash,
},
paginatedQueryOptions,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build list for the project…');
try {
const builds = await BuildQuery.allForAppAsync(projectId, {
limit,
filter: {
platform,
status: graphqlBuildStatus,
distribution: graphqlBuildDistribution,
channel: flags.channel,
appVersion: flags.appVersion,
appBuildVersion: flags.appBuildVersion,
sdkVersion: flags.sdkVersion,
runtimeVersion: flags.runtimeVersion,
appIdentifier: flags.appIdentifier,
buildProfile: flags.buildProfile,
gitCommitHash: flags.gitCommitHash,
},
});
if (builds.length) {
if (platform || graphqlBuildStatus) {
spinner.succeed(
`Showing ${builds.length} matching builds for the project ${projectName}`
);
} else {
spinner.succeed(`Showing last ${builds.length} builds for the project ${projectName}`);
}
if (json) {
printJsonOnlyOutput(builds);
} else {
const list = builds
.map(build => formatGraphQLBuild(build))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
}
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the build list ${projectName}`);
throw e;
}
}
}
@@ -1,126 +0,0 @@
import { getRuntimeVersionNullable } from '@expo/config-plugins/build/utils/Updates';
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { selectPlatformAsync } from '../../../platform';
import { VERSION_CODE_REQUIREMENTS, isValidVersionCode } from '../../../project/android/versions';
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import { getDisplayNameForProjectIdAsync } from '../../../project/projectUtils';
import {
ensureVersionSourceIsRemoteAsync,
getBuildVersionName,
validateAppConfigForRemoteVersionSource,
} from '../../../project/remoteVersionSource';
import { promptAsync } from '../../../prompts';
export default class BuildVersionSetView extends EasCommand {
static override description = 'Update version of an app.';
static override flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'],
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSetView);
const {
loggedIn: { actor, graphqlClient },
getDynamicProjectConfigAsync,
projectDir,
} = await this.getContextAsync(BuildVersionSetView, {
nonInteractive: false,
});
const platform = await selectPlatformAsync(flags.platform);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
const profile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
flags.profile ?? undefined
);
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: profile.env });
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
validateAppConfigForRemoteVersionSource(exp, platform);
const applicationIdentifier = await getApplicationIdentifierAsync(
projectDir,
exp,
profile,
platform,
actor
);
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
toAppPlatform(platform),
applicationIdentifier
);
const currentStateMessage = remoteVersions?.buildVersion
? `Project ${chalk.bold(displayName)} with ${getApplicationIdentifierName(
platform
)} "${applicationIdentifier}" is configured with ${getBuildVersionName(platform)} ${
remoteVersions.buildVersion
}.`
: `Project ${chalk.bold(displayName)} with ${getApplicationIdentifierName(
platform
)} "${applicationIdentifier}" does not have any ${getBuildVersionName(
platform
)} configured.`;
const versionPromptMessage = remoteVersions?.buildVersion
? `What version would you like to set?`
: `What version would you like to initialize it with?`;
Log.log(currentStateMessage);
const { version } = await promptAsync({
type: platform === Platform.ANDROID ? 'number' : 'text',
name: 'version',
message: versionPromptMessage,
validate:
platform === Platform.ANDROID
? value => isValidVersionCode(value) || `Invalid value: ${VERSION_CODE_REQUIREMENTS}.`
: value => isValidBuildNumber(value) || `Invalid value: ${BUILD_NUMBER_REQUIREMENTS}.`,
});
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: toAppPlatform(platform),
applicationIdentifier,
storeVersion: exp.version ?? '1.0.0',
buildVersion: String(version),
runtimeVersion: getRuntimeVersionNullable(exp, platform) ?? undefined,
});
}
}
function getApplicationIdentifierName(platform: Platform): string {
if (platform === Platform.ANDROID) {
return 'application ID';
} else {
return 'bundle identifier';
}
}
@@ -1,199 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJsonAccessor } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../../build/android/version';
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../../build/ios/version';
import EasCommand from '../../../commandUtils/EasCommand';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import {
appPlatformDisplayNames,
selectRequestedPlatformAsync,
toPlatforms,
} from '../../../platform';
import { getAppBuildGradleAsync } from '../../../project/android/gradleUtils';
import { VERSION_CODE_REQUIREMENTS, isValidVersionCode } from '../../../project/android/versions';
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
import { resolveXcodeBuildContextAsync } from '../../../project/ios/scheme';
import { resolveTargetsAsync } from '../../../project/ios/target';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import {
ensureVersionSourceIsRemoteAsync,
getBuildVersionName,
validateAppConfigForRemoteVersionSource,
} from '../../../project/remoteVersionSource';
import { resolveWorkflowAsync } from '../../../project/workflow';
import { getProfilesAsync } from '../../../utils/profiles';
interface SyncContext<T extends Platform> {
projectDir: string;
exp: ExpoConfig;
workflow: Workflow;
profile: BuildProfile<T>;
buildVersion: string;
}
export default class BuildVersionSyncView extends EasCommand {
public static override description =
'Update a version in native code with a value stored on EAS servers';
public static override flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSyncView);
const {
loggedIn: { actor, graphqlClient },
getDynamicProjectConfigAsync,
projectDir,
} = await this.getContextAsync(BuildVersionSyncView, {
nonInteractive: true,
});
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
const platforms = toPlatforms(requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
});
for (const profileInfo of buildProfiles) {
const { exp, projectId } = await getDynamicProjectConfigAsync({
env: profileInfo.profile.env,
});
validateAppConfigForRemoteVersionSource(exp, profileInfo.platform);
const platformDisplayName = appPlatformDisplayNames[toAppPlatform(profileInfo.platform)];
const applicationIdentifier = await getApplicationIdentifierAsync(
projectDir,
exp,
profileInfo.profile,
profileInfo.platform,
actor
);
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
toAppPlatform(profileInfo.platform),
applicationIdentifier
);
const workflow = await resolveWorkflowAsync(projectDir, profileInfo.platform);
if (!remoteVersions?.buildVersion) {
Log.warn(
`Skipping versions sync for ${platformDisplayName}. There are no versions configured on Expo servers, use "eas build:version:set" or run a build to initialize it.`
);
continue;
}
if (workflow === Workflow.MANAGED) {
Log.warn(
`The remote value for the ${platformDisplayName} ${getBuildVersionName(
profileInfo.platform
)} is ${chalk.bold(
remoteVersions?.buildVersion
)}, but it was not synced to the local project. This command has no effect on projects using managed workflow.`
);
continue;
}
if (profileInfo.platform === Platform.ANDROID) {
this.syncAndroidAsync({
projectDir,
exp,
profile: profileInfo.profile as BuildProfile<Platform.ANDROID>,
workflow,
buildVersion: remoteVersions.buildVersion,
});
} else {
this.syncIosAsync({
projectDir,
exp,
profile: profileInfo.profile as BuildProfile<Platform.IOS>,
workflow,
buildVersion: remoteVersions.buildVersion,
});
}
Log.withTick(
`Successfully updated the ${platformDisplayName} ${getBuildVersionName(
profileInfo.platform
)} in native code to ${chalk.bold(remoteVersions?.buildVersion)}.`
);
}
}
private async syncIosAsync({
workflow,
projectDir,
exp,
profile,
buildVersion,
}: SyncContext<Platform.IOS>): Promise<void> {
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{ exp, projectDir, nonInteractive: false },
profile
);
const targets = await resolveTargetsAsync({
projectDir,
exp,
xcodeBuildContext,
env: profile.env,
});
if (!isValidBuildNumber(buildVersion)) {
throw new Error(`Invalid buildNumber (${buildVersion}), ${BUILD_NUMBER_REQUIREMENTS}.`);
}
if (workflow === Workflow.GENERIC) {
await updateIosNativeVersionsAsync({
projectDir,
buildNumber: buildVersion,
targets,
});
}
}
private async syncAndroidAsync({
projectDir,
workflow,
buildVersion,
}: SyncContext<Platform.ANDROID>): Promise<void> {
if (!isValidVersionCode(buildVersion)) {
throw new Error(`Invalid versionCode (${buildVersion}), ${VERSION_CODE_REQUIREMENTS}.`);
}
if (workflow === Workflow.GENERIC) {
const buildGradle = await getAppBuildGradleAsync(projectDir);
const isMultiFlavor =
buildGradle.android?.productFlavors || buildGradle.android?.flavorDimensions;
if (isMultiFlavor) {
throw new Error(
"Versions in native code can't be automatically synced in multi-flavor Android projects. If you are using EAS Build with app version source set to remote, the correct values will be injected at the build time."
);
}
await updateAndroidNativeVersionsAsync({ projectDir, versionCode: Number(buildVersion) });
}
}
}
+24 -28
View File
@@ -1,25 +1,28 @@
import { Flags } from '@oclif/core';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { BuildFragment } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildView extends EasCommand {
static override description = 'view a build for your project';
static description = 'view a build for your project';
static override args = [{ name: 'BUILD_ID' }];
static args = [{ name: 'BUILD_ID' }];
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
}),
};
async runAsync(): Promise<void> {
@@ -27,17 +30,14 @@ export default class BuildView extends EasCommand {
args: { BUILD_ID: buildId },
flags,
} = await this.parse(BuildView);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildView, {
nonInteractive: true,
});
if (flags.json) {
enableJsonOutput();
}
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build…');
@@ -45,24 +45,20 @@ export default class BuildView extends EasCommand {
let build: BuildFragment;
if (buildId) {
build = await BuildQuery.byIdAsync(graphqlClient, buildId);
build = await BuildQuery.byIdAsync(buildId);
} else {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: 1,
});
const builds = await BuildQuery.allForAppAsync(projectId, { limit: 1 });
if (builds.length === 0) {
spinner.fail(`Couldn't find any builds for the project ${displayName}`);
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
return;
}
build = builds[0];
}
if (buildId) {
spinner.succeed(`Found a matching build for the project ${displayName}`);
spinner.succeed(`Found a matching build for the project ${projectName}`);
} else {
spinner.succeed(`Showing the last build for the project ${displayName}`);
spinner.succeed(`Showing the last build for the project ${projectName}`);
}
if (flags.json) {
@@ -75,7 +71,7 @@ export default class BuildView extends EasCommand {
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
} else {
spinner.fail(
`Something went wrong and we couldn't fetch the last build for the project ${displayName}`
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
);
}
+46 -55
View File
@@ -1,35 +1,35 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import { BranchNotFoundError } from '../../branch/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
CreateUpdateChannelOnAppMutation,
CreateUpdateChannelOnAppMutationVariables,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { createUpdateBranchOnAppAsync } from '../branch/create';
export async function createUpdateChannelOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
channelName,
branchId,
}: {
appId: string;
channelName: string;
branchId: string;
}
): Promise<CreateUpdateChannelOnAppMutation> {
export async function createUpdateChannelOnAppAsync({
appId,
channelName,
branchId,
}: {
appId: string;
channelName: string;
branchId: string;
}): Promise<CreateUpdateChannelOnAppMutation> {
// Point the new channel at a branch with its same name.
const branchMapping = JSON.stringify({
data: [{ branchId, branchMappingLogic: 'true' }],
@@ -60,9 +60,9 @@ export async function createUpdateChannelOnAppAsync(
}
export default class ChannelCreate extends EasCommand {
static override description = 'create a channel';
static description = 'create a channel';
static override args = [
static args = [
{
name: 'name',
required: false,
@@ -70,69 +70,62 @@ export default class ChannelCreate extends EasCommand {
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description:
'print output as a JSON object with the new channel ID, name and branch mapping.',
default: false,
}),
};
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags: { json: jsonFlag },
} = await this.parse(ChannelCreate);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelCreate, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
if (!channelName) {
const validationMessage = 'Channel name may not be empty.';
if (nonInteractive) {
if (jsonFlag) {
throw new Error(validationMessage);
}
({ name: channelName } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide a channel name:',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
}
let branchId: string;
let branchMessage: string;
const existingBranch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: channelName,
});
try {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
if (existingBranch) {
branchId = existingBranch.id;
branchMessage = `We found a branch with the same name`;
} else {
const newBranch = await createUpdateBranchOnAppAsync({
appId: projectId,
name: channelName,
});
branchId = branch.id;
branchMessage = `We found a branch with the same name`;
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId: projectId,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
} else {
throw error;
}
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
}
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createUpdateChannelOnAppAsync(graphqlClient, {
} = await createUpdateChannelOnAppAsync({
appId: projectId,
channelName,
branchId,
@@ -140,7 +133,7 @@ export default class ChannelCreate extends EasCommand {
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on project with id ${projectId}`
`Could not create channel with name ${channelName} on app with id ${projectId}`
);
}
@@ -149,9 +142,7 @@ export default class ChannelCreate extends EasCommand {
} else {
Log.addNewLineIfNone();
Log.withTick(
`Created a new channel on project ${chalk.bold(
await getDisplayNameForProjectIdAsync(graphqlClient, projectId)
)}`
`Created a new channel on project ${chalk.bold(await getProjectFullNameAsync(exp))}`
);
Log.log(
formatFields([
+88 -51
View File
@@ -1,109 +1,146 @@
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateChannelMutation,
DeleteUpdateChannelMutationVariables,
DeleteUpdateChannelResult,
GetChannelInfoQuery,
GetChannelInfoQueryVariables,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, 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 hidden = true;
static description = 'Delete a channel';
static override args = [
static args = [
{
name: 'name',
required: false,
description: 'Name of the channel to delete',
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: 'print output as a JSON object',
default: false,
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
}),
};
async runAsync(): Promise<void> {
const {
args: { name: nameArg },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags: { json: jsonFlag, 'non-interactive': nonInteractiveFlag },
} = await this.parse(ChannelDelete);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelDelete, {
nonInteractive,
});
if (jsonFlag && !nonInteractiveFlag) {
throw new Error('--json is allowed only in non-interactive mode');
}
if (jsonFlag) {
enableJsonOutput();
}
let channelId, channelName;
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
let name;
if (nameArg) {
const { id, name } = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: nameArg,
});
channelId = id;
channelName = name;
name = nameArg;
} else {
if (nonInteractive) {
if (nonInteractiveFlag) {
throw new Error('Channel name must be set when running in non-interactive mode');
}
const { id, name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to delete',
paginatedQueryOptions: {
json: jsonFlag,
nonInteractive,
offset: 0,
},
});
channelId = id;
channelName = name;
name = (
await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the channel to delete:',
validate: (value: any) => (value ? true : 'Channel name may not be empty.'),
})
).name;
}
if (!nonInteractive) {
const data = await getChannelInfoAsync({ appId: projectId, name });
const channelId = data.app?.byId.updateChannelByName?.id;
if (!channelId) {
throw new Error(`Could not find channel ${name} on ${fullName}`);
}
if (!nonInteractiveFlag) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
`You are about to permamently delete channel: "${name}".\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of channel: "${channelName}".`);
Log.error(`Canceled deletion of channel: "${name}".`);
process.exit(1);
}
}
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
const deletionResult = await deleteChannelOnAppAsync({
channelId,
});
if (jsonFlag) {
printJsonOnlyOutput(deletionResult);
} else {
Log.withTick(`️Deleted channel "${channelName}".`);
Log.withTick(`️Deleted channel "${name}".`);
}
}
}
async function deleteChannelOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: DeleteUpdateChannelMutationVariables
): Promise<DeleteUpdateChannelResult> {
async function getChannelInfoAsync({
appId,
name,
}: GetChannelInfoQueryVariables): Promise<GetChannelInfoQuery> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetChannelInfoQuery, GetChannelInfoQueryVariables>(
gql`
query GetChannelInfo($appId: String!, $name: String!) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $name) {
id
name
}
}
}
}
`,
{
appId,
name,
},
{ additionalTypenames: ['UpdateChannel'] }
)
.toPromise()
);
return data;
}
async function deleteChannelOnAppAsync({
channelId,
}: DeleteUpdateChannelMutationVariables): Promise<DeleteUpdateChannelResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateChannelMutation, DeleteUpdateChannelMutationVariables>(
+111 -59
View File
@@ -2,25 +2,71 @@ import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
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 { withErrorHandlingAsync } from '../../graphql/client';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
GetChannelByNameToEditQuery,
GetChannelByNameToEditQueryVariables,
UpdateBranch,
UpdateChannel,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function updateChannelBranchMappingAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
): Promise<UpdateChannelBranchMappingMutation['updateChannel']['editUpdateChannel']> {
async function getChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameToEditQueryVariables): Promise<
Pick<UpdateChannel, 'id' | 'name'> & {
updateBranches: Pick<UpdateBranch, 'id' | 'name'>[];
}
> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetChannelByNameToEditQuery, GetChannelByNameToEditQueryVariables>(
gql`
query GetChannelByNameToEdit($appId: String!, $channelName: String!) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
updateBranches(offset: 0, limit: 1) {
id
name
}
}
}
}
}
`,
{ appId, channelName },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch'] }
)
.toPromise()
);
const updateChannelByNameResult = data.app?.byId.updateChannelByName;
if (!updateChannelByNameResult) {
throw new Error(`Could not find a channel named ${channelName} on app with id ${appId}`);
}
return updateChannelByNameResult;
}
export async function updateChannelBranchMappingAsync({
channelId,
branchMapping,
}: UpdateChannelBranchMappingMutationVariables): Promise<{
id: string;
name: string;
branchMapping: string;
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
@@ -43,13 +89,13 @@ export async function updateChannelBranchMappingAsync(
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
return data.updateChannel.editUpdateChannel!;
}
export default class ChannelEdit extends EasCommand {
static override description = 'point a channel at a new branch';
static description = 'point a channel at a new branch';
static override args = [
static args = [
{
name: 'name',
required: false,
@@ -57,65 +103,49 @@ export default class ChannelEdit extends EasCommand {
},
];
static override flags = {
static flags = {
branch: Flags.string({
description: 'Name of the branch to point to',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
json: Flags.boolean({
description: 'Print output as a JSON object with the channel ID, name and branch mapping',
default: false,
}),
};
async runAsync(): Promise<void> {
const {
args,
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelEdit);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelEdit, {
nonInteractive,
});
if (json) {
const { args, flags } = await this.parse(ChannelEdit);
if (flags.json) {
enableJsonOutput();
}
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to edit',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const channelName = args.name ?? (await promptForChannelAsync());
const existingChannel = await getChannelByNameForAppAsync({ appId: projectId, channelName });
if (existingChannel.updateBranches.length > 1) {
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
throw new Error(
'There is a rollout in progress. Please manage it with "channel:rollout" instead.'
);
}
const branch = branchFlag
? await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: projectId,
name: branchFlag,
})
: await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: `Which branch would you like ${existingChannel.name} to point at?`,
displayTextForListItem: updateBranch => updateBranch.name,
paginatedQueryOptions: {
json,
nonInteractive,
offset: 0,
},
});
const branchName = flags.branch ?? (await promptForBranchAsync());
const channel = await updateChannelBranchMappingAsync(graphqlClient, {
const branch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: branchName,
});
if (!branch) {
throw new Error(
`Could not find a branch named "${branchName}". Please check what branches exist on this project with ${chalk.bold(
'eas branch:list'
)}.`
);
}
const channel = await updateChannelBranchMappingAsync({
channelId: existingChannel.id,
// todo: move branch mapping logic to utility
branchMapping: JSON.stringify({
@@ -124,7 +154,7 @@ export default class ChannelEdit extends EasCommand {
}),
});
if (json) {
if (flags.json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(
@@ -137,3 +167,25 @@ export default class ChannelEdit extends EasCommand {
}
}
}
async function promptForChannelAsync(): Promise<string> {
Log.addNewLineIfNone();
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please enter the name of the channel to edit:',
validate: value => (value ? true : 'The channel name may not be empty.'),
});
return name;
}
async function promptForBranchAsync(): Promise<string> {
Log.addNewLineIfNone();
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: `To which branch should the channel link?`,
validate: value => (value ? true : 'The branch name may not be empty.'),
});
return name;
}
+98 -28
View File
@@ -1,44 +1,114 @@
import { CHANNELS_LIMIT, listAndRenderChannelsOnAppAsync } from '../../channel/queries';
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
GetAllChannelsForAppQuery,
GetAllChannelsForAppQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { logChannelDetails } from './view';
const CHANNEL_LIMIT = 10_000;
async function getAllUpdateChannelForAppAsync({
appId,
}: {
appId: string;
}): Promise<GetAllChannelsForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
gql`
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
app {
byId(appId: $appId) {
id
updateChannels(offset: $offset, limit: $limit) {
id
name
branchMapping
updateBranches(offset: 0, limit: $limit) {
id
name
updates(offset: 0, limit: 10) {
id
group
message
runtimeVersion
createdAt
platform
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
}
}
}
}
}
}
`,
{ appId, offset: 0, limit: CHANNEL_LIMIT },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
);
}
export default class ChannelList extends EasCommand {
static override description = 'list all channels';
static description = 'list all channels';
static override flags = {
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: CHANNELS_LIMIT }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelList, {
nonInteractive,
});
flags: { json: jsonFlag },
} = await this.parse(ChannelList);
if (jsonFlag) {
enableJsonOutput();
}
await listAndRenderChannelsOnAppAsync(graphqlClient, {
projectId,
paginatedQueryOptions,
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
appId: projectId,
});
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
if (!channels) {
throw new Error(`Could not find channels on project with id ${projectId}`);
}
if (jsonFlag) {
printJsonOnlyOutput(channels);
} else {
for (const channel of channels) {
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
logChannelDetails(channel);
}
}
}
}
+106 -113
View File
@@ -1,18 +1,21 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { BranchMapping, getBranchMapping } from '../../channel/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { UpdateBranch } from '../../graphql/generated';
import { GetChannelByNameForAppQuery, UpdateBranch } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelByNameObject } from '../../graphql/queries/ChannelQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { updateChannelBranchMappingAsync } from './edit';
import { BranchMapping, getBranchMapping } from './view';
async function promptForRolloutPercentAsync({
promptMessage,
@@ -37,7 +40,9 @@ async function promptForRolloutPercentAsync({
return rolloutPercent;
}
function getRolloutInfo(channel: UpdateChannelByNameObject): {
function getRolloutInfo(
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>
): {
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
currentPercent: number;
@@ -55,28 +60,25 @@ function getRolloutInfo(channel: UpdateChannelByNameObject): {
return { newBranch, oldBranch, currentPercent };
}
async function startRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
branchName,
percent,
projectId,
displayName,
currentBranchMapping,
channel,
nonInteractive,
}: {
channelName?: string;
branchName: string;
percent?: number;
projectId: string;
displayName: string;
currentBranchMapping: BranchMapping;
channel: UpdateChannelByNameObject;
nonInteractive: boolean;
}
): Promise<{
async function startRolloutAsync({
channelName,
branchName,
percent,
jsonFlag,
projectId,
fullName,
currentBranchMapping,
channel,
}: {
channelName?: string;
branchName: string;
percent?: number;
jsonFlag: boolean;
projectId: string;
fullName: string;
currentBranchMapping: BranchMapping;
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>;
}): Promise<{
newChannelInfo: {
id: string;
name: string;
@@ -84,10 +86,17 @@ async function startRolloutAsync(
};
logMessage: string;
}> {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
const branch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: branchName,
});
if (!branch) {
throw new Error(
`Could not find a branch named "${branchName}". Please check what branches exist on this project with ${chalk.bold(
'eas branch:list'
)}.`
);
}
const oldBranchId = currentBranchMapping.data[0].branchId;
if (branch.id === oldBranchId) {
@@ -97,9 +106,9 @@ async function startRolloutAsync(
}
if (percent == null) {
if (nonInteractive) {
if (jsonFlag) {
throw new Error(
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive flag.'
'You must specify a percent with the --percent flag when initiating a rollout with the --json flag.'
);
}
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
@@ -120,7 +129,7 @@ async function startRolloutAsync(
currentBranchMapping.data[0],
],
};
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
@@ -128,7 +137,7 @@ async function startRolloutAsync(
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
if (!oldBranch) {
throw new Error(
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
`Branch mapping is missing its only branch for channel "${channelName}" on app "${fullName}"`
);
}
@@ -143,22 +152,19 @@ async function startRolloutAsync(
return { newChannelInfo, logMessage };
}
async function editRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
percent,
nonInteractive,
currentBranchMapping,
channel,
}: {
channelName?: string;
percent?: number;
nonInteractive: boolean;
currentBranchMapping: BranchMapping;
channel: UpdateChannelByNameObject;
}
): Promise<{
async function editRolloutAsync({
channelName,
percent,
jsonFlag,
currentBranchMapping,
channel,
}: {
channelName?: string;
percent?: number;
jsonFlag: boolean;
currentBranchMapping: BranchMapping;
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>;
}): Promise<{
newChannelInfo: {
id: string;
name: string;
@@ -169,7 +175,7 @@ async function editRolloutAsync(
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
if (percent == null) {
if (nonInteractive) {
if (jsonFlag) {
throw new Error(
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
);
@@ -185,7 +191,7 @@ async function editRolloutAsync(
const newBranchMapping = { ...currentBranchMapping };
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
@@ -201,22 +207,19 @@ async function editRolloutAsync(
return { newChannelInfo, logMessage };
}
async function endRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
branchName,
nonInteractive,
projectId,
channel,
}: {
channelName?: string;
branchName?: string;
nonInteractive: boolean;
projectId: string;
channel: UpdateChannelByNameObject;
}
): Promise<{
async function endRolloutAsync({
channelName,
branchName,
jsonFlag,
projectId,
channel,
}: {
channelName?: string;
branchName?: string;
jsonFlag: boolean;
projectId: string;
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>;
}): Promise<{
newChannelInfo: {
id: string;
name: string;
@@ -229,10 +232,17 @@ async function endRolloutAsync(
let endOnNewBranch;
if (branchName) {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
const branch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: branchName,
});
if (!branch) {
throw new Error(
`Could not find a branch named "${branchName}". Please check what branches exist on this project with ${chalk.bold(
'eas branch:list'
)}.`
);
}
switch (branch.id) {
case newBranch.id:
@@ -247,9 +257,9 @@ async function endRolloutAsync(
);
}
} else {
if (nonInteractive) {
if (jsonFlag) {
throw new Error(
'Branch name must be specified with the --branch flag when both the --end and --non-interactive flag are true.'
'Branch name must be specified with the --branch flag when both the --end and --json flag are true.'
);
}
endOnNewBranch = await selectAsync<boolean>(
@@ -280,7 +290,7 @@ async function endRolloutAsync(
],
};
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
const newChannelInfo = await updateChannelBranchMappingAsync({
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
@@ -294,10 +304,10 @@ async function endRolloutAsync(
}
export default class ChannelRollout extends EasCommand {
static override hidden = true;
static override description = 'Rollout a new branch out to a channel incrementally.';
static hidden = true;
static description = 'Rollout a new branch out to a channel incrementally.';
static override args = [
static args = [
{
name: 'channel',
required: true,
@@ -305,7 +315,7 @@ export default class ChannelRollout extends EasCommand {
},
];
static override flags = {
static flags = {
branch: Flags.string({
description: 'branch to rollout',
required: false,
@@ -318,44 +328,33 @@ export default class ChannelRollout extends EasCommand {
description: 'end the rollout',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
json: Flags.boolean({
description: 'print output as a JSON object with the new channel ID, name and branch mapping',
default: false,
}),
};
async runAsync(): Promise<void> {
const {
args: { channel: channelName },
flags: {
json: jsonFlag,
end: endFlag,
branch: branchName,
percent,
'non-interactive': nonInteractive,
},
flags: { json: jsonFlag, end: endFlag, branch: branchName, percent },
} = await this.parse(ChannelRollout);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelRollout, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
const channel = await ChannelQuery.getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName: channelName!,
});
if (!channel) {
throw new Error(
`Could not find a channel named "${channelName}". Check which channels exist on this project with ${chalk.bold(
`Could not find a channel named "${channelName}". Please check what channels exist on this project with ${chalk.bold(
'eas channel:list'
)}.`
);
@@ -394,29 +393,29 @@ export default class ChannelRollout extends EasCommand {
logMessage: string;
};
if (!isRollout) {
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
rolloutMutationResult = await startRolloutAsync({
channelName,
branchName: branchName ?? (await promptForBranchNameAsync(channelName, nonInteractive)),
branchName: branchName ?? (await promptForBranchNameAsync(channelName)),
percent,
nonInteractive,
jsonFlag,
projectId,
displayName: projectDisplayName,
fullName,
currentBranchMapping,
channel,
});
} else if (endFlag) {
rolloutMutationResult = await endRolloutAsync(graphqlClient, {
rolloutMutationResult = await endRolloutAsync({
channelName,
branchName,
nonInteractive,
jsonFlag,
projectId,
channel,
});
} else {
rolloutMutationResult = await editRolloutAsync(graphqlClient, {
rolloutMutationResult = await editRolloutAsync({
channelName,
percent,
nonInteractive,
jsonFlag,
currentBranchMapping,
channel,
});
@@ -433,13 +432,7 @@ export default class ChannelRollout extends EasCommand {
}
}
async function promptForBranchNameAsync(
channelName: string,
nonInteractive: boolean
): Promise<string> {
if (nonInteractive) {
throw new Error('Must supply branch flag in non-interactive mode');
}
async function promptForBranchNameAsync(channelName: string): Promise<string> {
const { name } = await promptAsync({
type: 'text',
name: 'name',
+175 -41
View File
@@ -1,18 +1,146 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import Table from 'cli-table3';
import {
listAndRenderBranchesAndUpdatesOnChannelAsync,
selectChannelOnAppAsync,
} from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import {
FormatUpdateParameter,
UPDATE_COLUMNS,
formatUpdate,
getPlatformsForGroup,
} from '../../update/utils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export function logChannelDetails(channel: {
branchMapping: string;
updateBranches: {
updates: (FormatUpdateParameter & {
runtimeVersion: string;
group: string;
platform: string;
})[];
name: string;
id: string;
}[];
}): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
const table = new Table({
head: ['branch', ...(isRollout ? ['rollout percent'] : []), ...UPDATE_COLUMNS],
wordWrap: true,
});
for (const index in branchMapping.data) {
if (parseInt(index, 10) > 1) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const { branchId } = branchMapping.data[index];
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
if (!branch) {
throw new Error('Branch mapping is pointing at a missing branch.');
}
const update = branch.updates[0];
table.push([
branch.name,
...(isRollout
? [
parseInt(index, 10) === 0
? `${rolloutPercent! * 100}%`
: `${(1 - rolloutPercent!) * 100}%`,
]
: []),
formatUpdate(update),
update?.runtimeVersion ?? 'N/A',
update?.group ?? 'N/A',
getPlatformsForGroup({
updates: branch.updates,
group: branch.updates[0]?.group,
}),
]);
}
Log.log(table.toString());
}
export default class ChannelView extends EasCommand {
static override description = 'view a channel';
static description = 'view a channel';
static override args = [
static args = [
{
name: 'name',
required: false,
@@ -20,55 +148,61 @@ export default class ChannelView extends EasCommand {
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
...EasPaginatedQueryFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
json: Flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
};
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags,
flags: { json: jsonFlag },
} = await this.parse(ChannelView);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelView, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
if (!channelName) {
const validationMessage = 'A channel name is required to view a specific channel.';
if (nonInteractive) {
if (jsonFlag) {
throw new Error(validationMessage);
}
const selectedUpdateChannel = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: jsonFlag,
nonInteractive,
offset: 0,
},
});
channelName = selectedUpdateChannel.name;
assert(channelName, `A channel must be selected.`);
({ name: channelName } = await promptAsync({
type: 'text',
name: 'name',
message: 'Please name the channel:',
validate: value => (value ? true : validationMessage),
}));
}
await listAndRenderBranchesAndUpdatesOnChannelAsync(graphqlClient, {
projectId,
const channel = await ChannelQuery.getUpdateChannelByNameForAppAsync({
appId: projectId,
channelName,
paginatedQueryOptions,
});
if (!channel) {
throw new Error(`Could not find a channel with name: ${channelName}`);
}
if (jsonFlag) {
printJsonOnlyOutput(channel);
} else {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
Log.addNewLineIfNone();
Log.log(chalk`{bold Branches pointed at this channel and their most recent update group:}`);
logChannelDetails(channel);
}
}
}
+18 -25
View File
@@ -1,6 +1,6 @@
import { getProjectConfigDescription } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { EasJsonReader } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -8,40 +8,36 @@ import EasCommand from '../commandUtils/EasCommand';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { appPlatformEmojis } from '../platform';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync } from '../project/projectUtils';
import { selectAsync } from '../prompts';
export default class Config extends EasCommand {
static override description = 'display project configuration (app.json + eas.json)';
static description = 'display project configuration (app.json + eas.json)';
static override flags = {
platform: Flags.enum<Platform>({ char: 'p', options: [Platform.ANDROID, Platform.IOS] }),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
static flags = {
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
profile: Flags.string(),
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
};
protected requiresAuthentication = false;
async runAsync(): Promise<void> {
const { flags } = await this.parse(Config);
const { platform: maybePlatform, profile: maybeProfile } = flags;
const { getDynamicProjectConfigAsync, projectDir } = await this.getContextAsync(Config, {
nonInteractive: false,
});
const { platform: maybePlatform, profile: maybeProfile } = flags as {
platform?: Platform;
profile?: string;
};
const accessor = new EasJsonAccessor(projectDir);
const projectDir = await findProjectRootAsync();
const reader = new EasJsonReader(projectDir);
const profileName =
maybeProfile ??
(await selectAsync(
'Select build profile',
(
await EasJsonUtils.getBuildProfileNamesAsync(accessor)
await reader.getBuildProfileNamesAsync()
).map(profileName => ({
title: profileName,
value: profileName,
@@ -60,11 +56,8 @@ export default class Config extends EasCommand {
},
]));
const profile = await EasJsonUtils.getBuildProfileAsync(accessor, platform, profileName);
const { exp: config } = await getDynamicProjectConfigAsync({
env: profile.env,
isPublicConfig: true,
});
const profile = await reader.getBuildProfileAsync(platform, profileName);
const config = getExpoConfig(projectDir, { env: profile.env, isPublicConfig: true });
Log.addNewLineIfNone();
Log.log(chalk.bold(getProjectConfigDescription(projectDir)));
+2 -26
View File
@@ -1,34 +1,10 @@
import { Flags } from '@oclif/core';
import EasCommand from '../commandUtils/EasCommand';
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
export default class Credentials extends EasCommand {
static override description = 'manage credentials';
static override flags = {
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.OptionalProjectConfig,
};
static description = 'manage credentials';
async runAsync(): Promise<void> {
const { flags } = await this.parse(Credentials);
const {
loggedIn: { actor, graphqlClient },
projectConfig,
} = await this.getContextAsync(Credentials, {
nonInteractive: false,
});
await new SelectPlatform(
actor,
graphqlClient,
projectConfig ?? null,
flags.platform
).runAsync();
await new SelectPlatform().runAsync();
}
}
+4 -19
View File
@@ -2,30 +2,15 @@ import EasCommand from '../../commandUtils/EasCommand';
import AppStoreApi from '../../credentials/ios/appstore/AppStoreApi';
import { createContextAsync } from '../../devices/context';
import DeviceManager from '../../devices/manager';
import { ensureLoggedInAsync } from '../../user/actions';
export default class DeviceCreate extends EasCommand {
static override description = 'register new Apple Devices to use for internal distribution';
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.OptionalProjectConfig,
};
static description = 'register new Apple Devices to use for internal distribution';
async runAsync(): Promise<void> {
// this command is interactive by design
const {
loggedIn: { actor, graphqlClient },
projectConfig,
} = await this.getContextAsync(DeviceCreate, {
nonInteractive: false,
});
const user = await ensureLoggedInAsync();
const ctx = await createContextAsync({
appStore: new AppStoreApi(),
user: actor,
graphqlClient,
projectId: projectConfig?.projectId,
});
const ctx = await createContextAsync({ appStore: new AppStoreApi(), user });
const manager = new DeviceManager(ctx);
await manager.createAsync();
}
+178 -105
View File
@@ -3,164 +3,145 @@ 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 { PaginatedQueryOptions, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { chooseDevicesToDeleteAsync } from '../../credentials/ios/actions/DeviceUtils';
import { AppleDeviceMutation } from '../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import {
AppleDeviceQuery,
AppleDeviceQueryResult,
AppleDevicesByTeamIdentifierQueryResult,
} from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
import { authenticateAsync, getRequestContext } from '../../credentials/ios/appstore/authenticate';
import {
selectAppleDeviceOnAppleTeamAsync,
selectAppleTeamOnAccountAsync,
} from '../../devices/queries';
import formatDevice from '../../devices/utils/formatDevice';
import { AppleDevice, Maybe } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
export default class DeviceDelete extends EasCommand {
static override description = 'remove a registered device from your account';
static description = 'remove a registered device from your account';
static override flags = {
'apple-team-id': Flags.string({ description: 'The Apple team ID on which to find the device' }),
udid: Flags.string({ description: 'The Apple device ID to disable' }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
static flags = {
'apple-team-id': Flags.string(),
udid: Flags.string({ multiple: true }),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(DeviceDelete);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
let { 'apple-team-id': appleTeamIdentifier, udid } = flags;
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(DeviceDelete, {
nonInteractive: paginatedQueryOptions.nonInteractive,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let appleTeamName;
let {
flags: { 'apple-team-id': appleTeamIdentifier, udid: udids },
} = await this.parse(DeviceDelete);
if (paginatedQueryOptions.json) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
if (!appleTeamIdentifier) {
const appleTeam = await selectAppleTeamOnAccountAsync(graphqlClient, {
accountName: account.name,
selectionPromptTitle: `What Apple team would you like to list devices for?`,
paginatedQueryOptions,
});
appleTeamIdentifier = appleTeam.appleTeamIdentifier;
appleTeamName = appleTeam.appleTeamName;
appleTeamIdentifier = await this.askForAppleTeamAsync(accountName);
}
assert(appleTeamIdentifier, 'No team identifier is specified');
const chosenDevice = udid
? await AppleDeviceQuery.getByDeviceIdentifierAsync(graphqlClient, account.name, udid)
: await selectAppleDeviceOnAppleTeamAsync(graphqlClient, {
accountName: account.name,
appleTeamIdentifier,
selectionPromptTitle: `Which device would you like to disable?`,
paginatedQueryOptions,
});
const appleDevicesResult = await this.getDevicesForTeamAsync(accountName, appleTeamIdentifier);
this.logChosenDevice(chosenDevice, appleTeamName, appleTeamIdentifier, paginatedQueryOptions);
if (!(await this.shouldRemoveDeviceFromExpoAsync(paginatedQueryOptions))) {
if (!appleDevicesResult) {
return;
}
await this.removeDeviceFromExpoAsync(graphqlClient, chosenDevice);
const { appleTeamName, appleDevices } = appleDevicesResult;
if (await this.shouldDisableDeviceOnAppleAsync(paginatedQueryOptions)) {
await this.disableDeviceOnAppleAsync(chosenDevice, appleTeamIdentifier);
}
}
const chosenDevices = await this.chooseDevicesToDeleteAsync(appleDevices, udids);
async shouldDisableDeviceOnAppleAsync({
nonInteractive,
}: PaginatedQueryOptions): Promise<boolean> {
if (!nonInteractive) {
if (chosenDevices.length === 0) {
Log.newLine();
return await toggleConfirmAsync({
message: 'Do you want to disable this device on your Apple account as well?',
});
Log.warn('No devices were chosen to be removed.');
return;
}
return true;
this.logChosenDevices(chosenDevices, appleTeamName, appleTeamIdentifier);
const hasRemoved = await this.askAndRemoveFromExpoAsync(chosenDevices);
if (!hasRemoved) {
return;
}
await this.askAndDisableOnAppleAsync(chosenDevices, appleTeamIdentifier);
}
async disableDeviceOnAppleAsync(
device: AppleDevice | AppleDeviceQueryResult,
async askAndDisableOnAppleAsync(
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[],
appleTeamIdentifier: string
): Promise<void> {
Log.newLine();
const deleteOnApple = await toggleConfirmAsync({
message: 'Do you want to disable these devices on your Apple account as well?',
});
if (!deleteOnApple) {
return;
}
const ctx = await authenticateAsync({ teamId: appleTeamIdentifier });
const context = getRequestContext(ctx);
Log.addNewLineIfNone();
const removeAppleSpinner = ora('Disabling device on Apple').start();
const removeAppleSpinner = ora('Disabling devices on Apple').start();
try {
const appleValidatedDevices = await Device.getAsync(context);
const appleValidatedDevice = appleValidatedDevices.find(d => d.id === device.id);
if (appleValidatedDevice) {
await appleValidatedDevice.updateAsync({ status: DeviceStatus.DISABLED });
let realDevices = await Device.getAllIOSProfileDevicesAsync(context);
realDevices = realDevices.filter(d =>
chosenDevices.map(cd => cd.identifier).includes(d.attributes.udid)
);
for (const device of realDevices) {
await device.updateAsync({ status: DeviceStatus.DISABLED });
}
removeAppleSpinner.succeed('Disabled device on Apple');
removeAppleSpinner.succeed('Disabled devices on Apple');
} catch (err) {
removeAppleSpinner.fail();
throw err;
}
}
async shouldRemoveDeviceFromExpoAsync({
nonInteractive,
}: PaginatedQueryOptions): Promise<boolean> {
if (!nonInteractive) {
Log.warn(`You are about to remove the Apple device listed above from your Expo account.`);
Log.newLine();
async askAndRemoveFromExpoAsync(
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[]
): Promise<boolean> {
Log.warn(
`You are about to remove the Apple device${
chosenDevices.length > 1 ? 's' : ''
} listed above from your Expo account.`
);
Log.newLine();
return await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (confirmed) {
const removalSpinner = ora(`Removing Apple devices on Expo`).start();
try {
for (const chosenDevice of chosenDevices) {
await AppleDeviceMutation.deleteAppleDeviceAsync(chosenDevice.id);
}
removalSpinner.succeed('Removed Apple devices from Expo');
} catch (err) {
removalSpinner.fail();
throw err;
}
}
return true;
return confirmed;
}
async removeDeviceFromExpoAsync(
graphqlClient: ExpoGraphqlClient,
chosenDevice: AppleDevice | AppleDeviceQueryResult
): Promise<void> {
const removalSpinner = ora(`Removing Apple device on Expo`).start();
try {
await AppleDeviceMutation.deleteAppleDeviceAsync(graphqlClient, chosenDevice.id);
removalSpinner.succeed('Removed Apple device from Expo');
} catch (err) {
removalSpinner.fail();
throw err;
}
}
logChosenDevice(
device: AppleDevice | AppleDeviceQueryResult,
logChosenDevices(
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[],
appleTeamName: Maybe<string> | undefined,
appleTeamIdentifier: string,
{ json }: PaginatedQueryOptions
appleTeamIdentifier: string
): void {
if (json) {
printJsonOnlyOutput(device);
} else {
Log.addNewLineIfNone();
Log.addNewLineIfNone();
chosenDevices.forEach(device => {
Log.log(
formatDevice(device, {
appleTeamName,
@@ -168,6 +149,98 @@ export default class DeviceDelete extends EasCommand {
})
);
Log.newLine();
});
}
async chooseDevicesToDeleteAsync(
appleDevices: AppleDeviceQueryResult[],
udids: string[]
): Promise<(AppleDevice | AppleDeviceQueryResult)[]> {
let chosenDevices: (AppleDeviceQueryResult | AppleDevice)[] = [];
Log.newLine();
if (udids) {
udids.forEach(udid => {
const foundDevice = appleDevices.find(device => device.identifier === udid);
if (foundDevice) {
chosenDevices.push(foundDevice);
} else {
Log.warn(`No device found with UDID ${udid}.`);
}
});
}
if (chosenDevices.length === 0) {
Log.addNewLineIfNone();
chosenDevices = await chooseDevicesToDeleteAsync(appleDevices);
Log.newLine();
}
return chosenDevices;
}
async getDevicesForTeamAsync(
accountName: string,
appleTeamIdentifier: string
): Promise<AppleDevicesByTeamIdentifierQueryResult | undefined> {
const devicesSpinner = ora().start('Fetching the list of devices for the team…');
try {
const result = await AppleDeviceQuery.getAllForAppleTeamAsync(
accountName,
appleTeamIdentifier
);
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
devicesSpinner.succeed(
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
);
return result;
} else {
devicesSpinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
return;
}
} catch (e) {
devicesSpinner.fail(`Something went wrong and we couldn't fetch the device list`);
throw e;
}
}
async askForAppleTeamAsync(accountName: string): Promise<string | undefined> {
const teamSpinner = ora().start('Fetching the list of teams for the project…');
try {
const teams = await AppleTeamQuery.getAllForAccountAsync(accountName);
if (teams.length > 0) {
teamSpinner.succeed('Fetched the list of teams for the project');
if (teams.length === 1) {
return teams[0].appleTeamIdentifier;
}
const result = await promptAsync({
type: 'select',
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: team.appleTeamName
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
: team.appleTeamIdentifier,
value: team.appleTeamIdentifier,
})),
});
return result.appleTeamIdentifier;
} else {
teamSpinner.fail(`Couldn't find any teams for the account ${accountName}`);
return;
}
} catch (e) {
teamSpinner.fail(`Something went wrong and we couldn't fetch the list of teams`);
throw e;
}
}
}
+79 -51
View File
@@ -1,70 +1,98 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import {
listAndRenderAppleDevicesOnAppleTeamAsync,
selectAppleTeamOnAccountAsync,
} from '../../devices/queries';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput } from '../../utils/json';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
import formatDevice from '../../devices/utils/formatDevice';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
export default class BuildList extends EasCommand {
static override description = 'list all registered devices for your account';
static description = 'list all registered devices for your account';
static override flags = {
static flags = {
'apple-team-id': Flags.string(),
...EasPaginatedQueryFlags,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildList, {
nonInteractive: paginatedQueryOptions.nonInteractive,
});
let appleTeamIdentifier = flags['apple-team-id'];
let appleTeamName;
if (paginatedQueryOptions.json) {
enableJsonOutput();
}
let appleTeamIdentifier = (await this.parse(BuildList)).flags['apple-team-id'];
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
let spinner: Ora;
// if they don't provide a team id, fetch devices on their account
if (!appleTeamIdentifier) {
const selectedAppleTeam = await selectAppleTeamOnAccountAsync(graphqlClient, {
accountName: account.name,
paginatedQueryOptions: {
...paginatedQueryOptions,
offset: 0,
limit: undefined,
},
selectionPromptTitle: 'What Apple team would you like to list devices for?',
});
appleTeamIdentifier = selectedAppleTeam.appleTeamIdentifier;
appleTeamName = selectedAppleTeam.appleTeamName;
spinner = ora().start('Fetching the list of teams for the project…');
try {
const teams = await AppleTeamQuery.getAllForAccountAsync(accountName);
if (teams.length > 0) {
spinner.succeed();
if (teams.length === 1) {
appleTeamIdentifier = teams[0].appleTeamIdentifier;
} else {
const result = await promptAsync({
type: 'select',
name: 'appleTeamIdentifier',
message: 'What Apple Team would you like to list devices for?',
choices: teams.map(team => ({
title: team.appleTeamName
? `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`
: team.appleTeamIdentifier,
value: team.appleTeamIdentifier,
})),
});
appleTeamIdentifier = result.appleTeamIdentifier;
}
} else {
spinner.fail(`Couldn't find any teams for the account ${accountName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the list of teams`);
throw e;
}
}
assert(appleTeamIdentifier, 'No team identifier is specified');
await listAndRenderAppleDevicesOnAppleTeamAsync(graphqlClient, {
accountName: account.name,
appleTeam: {
appleTeamIdentifier,
appleTeamName,
},
paginatedQueryOptions,
});
spinner = ora().start('Fetching the list of devices for the team…');
try {
const result = await AppleDeviceQuery.getAllForAppleTeamAsync(
accountName,
appleTeamIdentifier
);
if (result?.appleDevices.length) {
const { appleTeamName, appleDevices } = result;
spinner.succeed(
`Found ${appleDevices.length} devices for team ${appleTeamName ?? appleTeamIdentifier}`
);
const list = appleDevices
.map(device =>
formatDevice(device, { appleTeamName, appleTeamIdentifier: appleTeamIdentifier! })
)
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
} else {
spinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the device list`);
throw e;
}
}
}
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff. Show more