Compare commits

..
Author SHA1 Message Date
Ville Immonen f00efe7b9a v0.1.0-alpha.0 2020-10-29 16:35:12 +02:00
Ville Immonen a771e94c8e WIP 2020-10-28 17:35:42 +02:00
Ville Immonen 3c83115bf7 WIP 2020-10-27 17:15:00 +02:00
627 changed files with 14485 additions and 87174 deletions

No files matched your search

-2
View File
@@ -2,6 +2,4 @@
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
packages/eas-cli/src/graphql/generated.ts
node_modules
+13 -36
View File
@@ -1,9 +1,15 @@
module.exports = {
root: true,
extends: ['universe/node'],
plugins: ['async-protect', 'node'],
overrides: [
{
files: ['**/__tests__/*'],
},
],
globals: {
jasmine: false,
},
rules: {
'no-console': 'warn',
'no-constant-condition': ['warn', { checkLoops: false }],
'sort-imports': [
'warn',
@@ -11,40 +17,11 @@ module.exports = {
ignoreDeclarationSort: true,
},
],
curly: 'warn',
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
],
'import/no-relative-packages': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'lodash',
message: "Don't use lodash, it's heavy!",
},
],
},
],
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
'async-protect/async-suffix': 'error',
'node/no-sync': 'error',
'flowtype/no-types-missing-file-annotation': 'off',
},
overrides: [
{
files: ['**/__tests__/**/*.ts', '**/__mocks__/**/*.ts'],
rules: {
'async-protect/async-suffix': 'off',
},
settings: {
react: {
version: 'detect',
},
],
},
};
-1
View File
@@ -1 +0,0 @@
**/yarn.lock linguist-generated
-58
View File
@@ -1,58 +0,0 @@
name: "\U0001F41B Bug Report in EAS CLI"
labels: 'needs review'
description: 'You want to report a reproducible bug or regression in EAS CLI.'
body:
- type: markdown
attributes:
value: "We're looking to keep questions on our forums and bug reports on the GitHub repo. For questions and help using EAS CLI, post in our [community forums](https://forums.expo.dev/c/help-expo-application-services) (log in with your Expo developer account)."
- type: markdown
attributes:
value: Found a bug in the Expo SDK? Open a bug report [here](https://github.com/expo/expo/issues/new?assignees=&labels=needs+review&template=bug_report.yml) instead.
- type: markdown
attributes:
value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible.
- type: markdown
attributes:
value: If you leave out sections there is a high likelihood your issue will be closed. If you have a question, not a bug report, please post it on our [forums](https://forums.expo.dev/) instead.
- type: input
attributes:
label: Build/Submit details page URL
description: If you're reporting an issue with EAS Build and/or EAS Submit, please provide the URL to the details page URL. The page can be only viewed by the Expo team members. Providing the URL is optional but it makes debugging the issue easier.
placeholder: https://expo.dev/accounts/ACCOUNT_NAME/projects/PROJECT_NAME/builds/BUILD_ID
- type: textarea
validations:
required: true
attributes:
label: Summary
description: Describe the issue in 1 or 2 sentences
placeholder: Clearly describe what the expected behavior is vs. what is actually happening. This should be as short as possible, while still communicating all the necessary information. If your summary is just 'X library/method isn't working', then you need to continue debugging yourself and provide more information.
- type: input
validations:
required: true
attributes:
label: Managed or bare?
description: Is your project managed or bare? Bare - Android and/or iOS native projects exist in your repo; Managed - you don't have native code (custom or 3rd party) in your project. See the [Workflows](https://docs.expo.dev/introduction/managed-vs-bare/) page for more information.
- type: textarea
validations:
required: true
attributes:
label: Environment
description: Run `npx expo-env-info` and paste the output here.
- type: textarea
validations:
required: false
attributes:
label: Error output
placeholder: If you're running into an error message, please run the same command with `EXPO_DEBUG=true <your command here>` and paste output here.
- type: textarea
validations:
required: true
attributes:
label: Reproducible demo or steps to reproduce from a blank project
description: 'This should include as little code as possible, do not simply link your entire project. Sharing a link to a [Snack](https://snack.expo.dev/) is a GREAT way to provide a reproducible demo :) If a reproducible demo, or a complete list of steps from blank project to bug, are not provided, it is very likely your issue will be closed. Read [here more guidance](https://stackoverflow.com/help/mcve).'
- type: markdown
attributes:
value: Please make sure contributors can follow the steps your provided in order to reproduce the bug.
- type: markdown
attributes:
value: "**Realize that it is up to you to debug your code and be as certain as possible that the bug is with EAS CLI, not with your own app.** [Here's an excellent guide to debugging you can follow](https://github.com/expo/fyi/blob/master/manual-debugging.md#how-to-narrow-down-the-source-of-an-error-a-step-by-step-guide-to-good-old-fashioned-manual-debugging)."
-14
View File
@@ -1,14 +0,0 @@
name: "\U0001F64B Enhancement"
labels: 'enhancement,needs review'
description: 'Want us to add something to EAS CLI?'
body:
- type: markdown
attributes:
value: Thanks for helping us make EAS even better! 😀 Unfortunately, this is not the right place for feature requests. Please find and upvote your feature (or create a new one) at https://expo.canny.io instead.
- type: dropdown
validations:
required: true
attributes:
label: Do you understand that any feature requests opened in this repository will be closed?
options:
- 'Yes'
-17
View File
@@ -1,17 +0,0 @@
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
# Checklist
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary. You can comment this pull request with `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]` and CHANGELOG.md will be updated automatically.
# Why
Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.
# How
How did you build this feature or fix this bug and why?
# Test Plan
Please describe how you tested this change and how a reviewer could reproduce your test, especially if this PR does not include automated tests! If possible, please also provide terminal output and/or screenshots demonstrating your test/reproduction.
-46
View File
@@ -1,46 +0,0 @@
on:
issue_comment:
types: [created]
name: Changelog
jobs:
pr-commented:
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Checkout Pull Request
run: hub pr checkout ${{ github.event.issue.number }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
- name: Add changelog entry
working-directory: ./scripts
run: |
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
if [[ ! "$PARAMS" =~ " " ]]; then
echo "Invalid /changelog-entry command"
exit 1
fi
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
yarn changelog-entry "$CATEGORY" "$MESSAGE"
env:
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
PR_COMMENT_BODY: ${{ github.event.comment.body }}
- name: Commit and push
run: |
# use the last commit author name and email
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git push
-20
View File
@@ -1,20 +0,0 @@
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
+30 -172
View File
@@ -1,197 +1,55 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
push
# push:
# tags:
# - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release workflow
name: Create Release
jobs:
release:
name: Create a release
build:
name: Create Release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14'
- run: yarn install --frozen-lockfile --check-files
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: yarn oclif-dev pack --targets linux-x64,linux-arm,darwin-x64
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
- id: x64
run: echo "::set-output name=filename::$(ls eas-*-x64.tar.gz)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm
run: echo "::set-output name=filename::$(ls eas-*-arm.tar.gz)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload linux-x64 tarball
- name: Upload darwin-x64
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-darwin-x64.tar.gz
asset_name: eas-darwin-x64.tar.gz
asset_content_type: application/gzip
- name: Upload linux-arm tarball
- name: Upload linux-x64
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm.outputs.filename }}
asset_name: ${{ steps.arm.outputs.filename }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-linux-x64.tar.gz
asset_name: eas-linux-x64.tar.gz
asset_content_type: application/gzip
build-mac:
name: Build for macOS
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets darwin-x64
- id: x64
run: echo "::set-output name=filename::$(ls eas-*-x64.tar.gz)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload darwin-x64 tarball
- name: Upload linux-arm
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/eas-linux-arm.tar.gz
asset_name: eas-linux-arm.tar.gz
asset_content_type: application/gzip
build-windows:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: |
yarn pretarball-ci
yarn oclif pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "::set-output name=filename::$(ls eas-*-x64.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- id: x86
run: echo "::set-output name=filename::$(ls eas-*-x86.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- name: Upload x64 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
publish-to-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: [build-linux, build-mac, build-windows]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: '16'
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
run: yarn build
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
needs: publish-to-npm
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Update changelog
working-directory: ./scripts
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
- name: Publish release
run: hub release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push
run: |
git fetch
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git push
- name: Prepare changelog for Slack
run: |
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
npx slackmd /tmp/changelog.md
echo 'SLACK_CHANGELOG<<EOF' >> $GITHUB_ENV
cat /tmp/changelog.slack.md >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
SLACK_ICON: https://github.com/expo-ci.png?size=48
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
SLACK_USERNAME: 'Expo CI'
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_FOOTER: ''
-20
View File
@@ -1,20 +0,0 @@
name: Size
on:
pull_request:
types: [synchronize, opened]
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
strip-hash: "v\\d+.\\d+.\\d+-\\w+-"
clean-script: 'clean'
-24
View File
@@ -1,24 +0,0 @@
name: Close inactive issues
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v4
with:
ascending: false
operations-per-run: 300
days-before-issue-stale: 30
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity. If there is no activity in the next 7 days, the issue will be closed."
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale. Please open a new issue if you believe you are encountering a related problem."
days-before-pr-stale: -1
days-before-pr-close: -1
enable-statistics: true
exempt-issue-labels: "needs review"
exempt-all-assignees: true
+5 -15
View File
@@ -10,26 +10,16 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['12', '14', '16']
include:
- node: '16'
coverage: true
node: ['12', '14']
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Setup node
uses: actions/setup-node@v2
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck
- run: yarn test
if: ${{ !matrix.coverage }}
- run: yarn test --coverage
if: ${{ matrix.coverage }}
- run: yarn lint --max-warnings=0
- run: shellcheck bin/*.sh
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- run: yarn lint
+9 -16
View File
@@ -1,19 +1,12 @@
# Build directories
packages/*/build
packages/eas-cli/dist
packages/eas-cli/tmp
# Code editors
.idea
.history
# macOS
.DS_Store
# Node
node_modules
*-debug.log
*-error.log
# Tests
coverage
**/build/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
dist
/package-lock.json
tmp
node_modules
.history
+1 -1
View File
@@ -2,7 +2,7 @@
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"jsxBracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid"
}
-1
View File
@@ -1 +0,0 @@
save-prefix ""
-1115
View File
File diff suppressed because it is too large. Load diff
-7
View File
@@ -1,7 +0,0 @@
packages/eas-cli/src/build @dsokal @wkozyra95
packages/eas-cli/src/commands/build @dsokal @wkozyra95
packages/eas-cli/src/submit @barthap @dsokal
packages/eas-cli/src/commands/submit.ts @barthap @dsokal
packages/eas-json @dsokal @wkozyra95
-7
View File
@@ -1,7 +0,0 @@
# Community Code of Conduct
The Expo team is committed to fostering an open and welcoming environment.
**Our Community Guidelines can be found here:**
https://expo.dev/guidelines
-4
View File
@@ -1,4 +0,0 @@
## Releasing a new version
1. Run `yarn release` in the repository root folder.
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2020-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-130
View File
@@ -1,130 +0,0 @@
#!/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 +1 @@
../packages/eas-cli/bin/dev
../packages/eas-cli/bin/run
-17
View File
@@ -1,17 +0,0 @@
coverage:
round: up
precision: 2
range: 30..100
status:
patch: false
project:
default:
informational: true
eas-cli:
informational: true
paths:
- packages/eas-cli
eas-json:
informational: true
paths:
- packages/eas-json
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = {
projects: [
require('./packages/eas-cli/jest.config.js'),
require('./packages/eas-json/jest.config.js'),
require('./packages/config/jest.config.js'),
],
testPathIgnorePatterns: ['.*'],
};
+11
View File
@@ -0,0 +1,11 @@
module.exports = function (api) {
api.cache(true);
return {
// Only use this when running tests
env: {
test: {
presets: ['@expo/babel-preset-cli'],
},
},
};
};
+3 -2
View File
@@ -1,7 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$',
transform: {
'^.+\\.[jt]sx?$': ['babel-jest', { configFile: require.resolve('./babel.config.js') }],
},
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.52.0"
"version": "0.1.0-alpha.0"
}
+10 -27
View File
@@ -4,35 +4,18 @@
"packages/*"
],
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint .",
"release": "./scripts/bin/run update-local-plugin && lerna version --exact",
"test": "jest",
"clean": "lerna run clean && rm -rf node_modules coverage"
"lint": "eslint . --ext .ts",
"test": "jest"
},
"devDependencies": {
"@types/jest": "27.4.1",
"@types/node": "16.7.8",
"eslint": "8.12.0",
"eslint-config-universe": "10.0.0",
"eslint-plugin-async-protect": "1.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "27.5.1",
"jest-watch-typeahead": "1.0.0",
"lerna": "4.0.0",
"oclif": "2.6.3",
"prettier": "2.6.1",
"ts-jest": "27.1.4",
"typescript": "4.6.3"
},
"volta": {
"node": "16.10.0",
"yarn": "1.22.15"
"@expo/oclif-dev-cli": "1.24.0-expo",
"@types/jest": "^26.0.13",
"eslint": "^6.6.0",
"eslint-config-universe": "^4.0.0",
"jest": "^26.4.2",
"jest-watch-typeahead": "^0.6.1",
"lerna": "^3.22.1",
"prettier": "^2.0.5"
}
}
+1
View File
@@ -0,0 +1 @@
/build
@@ -1,3 +1,3 @@
# @expo/eas-json
# @eas/config
A library for interacting with the eas.json
+2
View File
@@ -0,0 +1,2 @@
import { fs } from 'memfs';
module.exports = fs;
File renamed without changes.
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@eas/config",
"description": "A library for interacting with the eas.json",
"version": "0.1.0-alpha.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/eas-build-job": "^0.1.1",
"@hapi/joi": "^17.1.1",
"fs-extra": "^9.0.1",
"tslib": "^1"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@expo/babel-preset-cli": "^0.2.17",
"@types/fs-extra": "^9.0.1",
"@types/hapi__joi": "^17.1.4",
"@types/node": "^12",
"babel-jest": "^26.3.0",
"memfs": "^3.2.0",
"ts-node": "^8",
"typescript": "^3.3"
},
"engines": {
"node": ">=8.0.0"
},
"homepage": "https://github.com/expo/eas-cli",
"license": "MIT",
"main": "src/index.ts",
"repository": "expo/eas-cli",
"scripts": {
"build": "tsc",
"test": "jest"
}
}
+57
View File
@@ -0,0 +1,57 @@
// Workflow is representing different value than BuildType from @expo/eas-build-job
// Each workflow has a set of BuildTypes available
// - Generic workflow allows to build 'generic' and 'generic-client'
// - Managed workflow allows to build 'managed' and 'managed-client'
export enum Workflow {
Generic = 'generic',
Managed = 'managed',
}
export enum CredentialsSource {
LOCAL = 'local',
REMOTE = 'remote',
AUTO = 'auto',
}
export interface AndroidManagedBuildProfile {
workflow: Workflow.Managed;
credentialsSource: CredentialsSource;
buildType?: 'apk' | 'app-bundle';
releaseChannel?: undefined;
}
export interface AndroidGenericBuildProfile {
workflow: Workflow.Generic;
credentialsSource: CredentialsSource;
gradleCommand?: string;
releaseChannel?: string;
artifactPath?: string;
withoutCredentials?: boolean;
}
export interface iOSManagedBuildProfile {
workflow: Workflow.Managed;
credentialsSource: CredentialsSource;
buildType?: 'archive' | 'simulator';
releaseChannel?: undefined;
}
export interface iOSGenericBuildProfile {
workflow: Workflow.Generic;
credentialsSource: CredentialsSource;
scheme?: string;
releaseChannel?: string;
artifactPath?: string;
}
export type AndroidBuildProfile = AndroidManagedBuildProfile | AndroidGenericBuildProfile;
export type iOSBuildProfile = iOSManagedBuildProfile | iOSGenericBuildProfile;
export type BuildProfile = AndroidBuildProfile | iOSBuildProfile;
// EasConfig represents eas.json with one specific profile
export interface EasConfig {
builds: {
android?: AndroidManagedBuildProfile | AndroidGenericBuildProfile;
ios?: iOSManagedBuildProfile | iOSGenericBuildProfile;
};
}
+94
View File
@@ -0,0 +1,94 @@
import { Platform } from '@expo/eas-build-job';
import fs from 'fs-extra';
import path from 'path';
import {
AndroidBuildProfile,
BuildProfile,
EasConfig,
Workflow,
iOSBuildProfile,
} from './Config.types';
import { EasJsonSchema, schemaBuildProfileMap } from './EasJsonSchema';
interface EasJson {
builds: {
android?: { [key: string]: AndroidBuildProfile };
ios?: { [key: string]: iOSBuildProfile };
};
}
interface BuildProfilePreValidation {
workflow: Workflow;
}
export class EasJsonReader {
constructor(private projectDir: string, private platform: 'android' | 'ios' | 'all') {}
public async readAsync(buildProfileName: string): Promise<EasConfig> {
const easJson = await this.readFile();
let androidConfig;
if (['android', 'all'].includes(this.platform)) {
androidConfig = this.validateBuildProfile<AndroidBuildProfile>(
Platform.Android,
buildProfileName,
easJson.builds?.android?.[buildProfileName]
);
}
let iosConfig;
if (['ios', 'all'].includes(this.platform)) {
iosConfig = this.validateBuildProfile<iOSBuildProfile>(
Platform.iOS,
buildProfileName,
easJson.builds?.ios?.[buildProfileName]
);
}
return {
builds: {
...(androidConfig ? { android: androidConfig } : {}),
...(iosConfig ? { ios: iosConfig } : {}),
},
};
}
private validateBuildProfile<T extends BuildProfile>(
platform: 'android' | 'ios' | 'all',
buildProfileName: string,
buildProfile?: BuildProfilePreValidation
): T {
if (!buildProfile) {
throw new Error(`There is no profile named ${buildProfileName} for platform ${platform}`);
}
const schema = schemaBuildProfileMap[platform][buildProfile?.workflow];
if (!schema) {
throw new Error('invalid workflow'); // this should be validated earlier
}
const { value, error } = schema.validate(buildProfile, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw new Error(
`Object "${platform}.${buildProfileName}" in eas.json is not valid [${error.toString()}]`
);
}
return value;
}
private async readFile(): Promise<EasJson> {
const rawFile = await fs.readFile(path.join(this.projectDir, 'eas.json'), 'utf-8');
const json = JSON.parse(rawFile);
const { value, error } = EasJsonSchema.validate(json, {
abortEarly: false,
});
if (error) {
throw new Error(`eas.json is not valid [${error.toString()}]`);
}
return value;
}
}
+60
View File
@@ -0,0 +1,60 @@
import Joi from '@hapi/joi';
const AndroidGenericSchema = Joi.object({
workflow: Joi.string().valid('generic').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
gradleCommand: Joi.string(),
releaseChannel: Joi.string(),
artifactPath: Joi.string(),
withoutCredentials: Joi.boolean(),
});
const AndroidManagedSchema = Joi.object({
workflow: Joi.string().valid('managed').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
buildType: Joi.string().valid('apk', 'app-bundle').default('app-bundle'),
});
const iOSGenericSchema = Joi.object({
workflow: Joi.string().valid('generic').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
scheme: Joi.string(),
releaseChannel: Joi.string(),
artifactPath: Joi.string(),
});
const iOSManagedSchema = Joi.object({
workflow: Joi.string().valid('managed').required(),
credentialsSource: Joi.string().valid('local', 'remote', 'auto').default('auto'),
buildType: Joi.string().valid('archive', 'simulator'),
});
const schemaBuildProfileMap: Record<string, Record<string, Joi.Schema>> = {
android: {
generic: AndroidGenericSchema,
managed: AndroidManagedSchema,
},
ios: {
managed: iOSManagedSchema,
generic: iOSGenericSchema,
},
};
const EasJsonSchema = Joi.object({
builds: Joi.object({
android: Joi.object().pattern(
Joi.string(),
Joi.object({
workflow: Joi.string().valid('generic', 'managed').required(),
}).unknown(true) // profile is validated further only if build is for that platform
),
ios: Joi.object().pattern(
Joi.string(),
Joi.object({
workflow: Joi.string().valid('generic', 'managed').required(),
}).unknown(true) // profile is validated further only if build is for that platform
),
}),
});
export { EasJsonSchema, schemaBuildProfileMap };
@@ -0,0 +1,191 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import { EasJsonReader } from '../EasJsonReader';
jest.mock('fs');
beforeEach(async () => {
vol.reset();
await fs.mkdirp('/project');
});
test('minimal valid android eas.json', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const easJson = await reader.readAsync('release');
expect({
builds: {
android: {
workflow: 'generic',
credentialsSource: 'auto',
},
},
}).toEqual(easJson);
});
test('minimal valid ios eas.json', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
ios: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'ios');
const easJson = await reader.readAsync('release');
expect({
builds: {
ios: {
credentialsSource: 'auto',
workflow: 'generic',
},
},
}).toEqual(easJson);
});
test('minimal valid eas.json for both platforms', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'generic' },
},
ios: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'all');
const easJson = await reader.readAsync('release');
expect({
builds: {
android: { workflow: 'generic', credentialsSource: 'auto' },
ios: { workflow: 'generic', credentialsSource: 'auto' },
},
}).toEqual(easJson);
});
test('valid eas.json with both platform, but reading only android', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
ios: {
release: { workflow: 'generic' },
},
android: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const easJson = await reader.readAsync('release');
expect({
builds: {
android: { workflow: 'generic', credentialsSource: 'auto' },
},
}).toEqual(easJson);
});
test('valid eas.json for debug builds', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
ios: {
release: { workflow: 'managed' },
debug: { workflow: 'managed', buildType: 'simulator' },
},
android: {
release: { workflow: 'generic' },
debug: {
workflow: 'generic',
gradleCommand: ':app:assembleDebug',
withoutCredentials: true,
},
},
},
});
const reader = new EasJsonReader('/project', 'all');
const easJson = await reader.readAsync('debug');
expect({
builds: {
android: {
credentialsSource: 'auto',
workflow: 'generic',
gradleCommand: ':app:assembleDebug',
withoutCredentials: true,
},
ios: {
credentialsSource: 'auto',
workflow: 'managed',
buildType: 'simulator',
},
},
}).toEqual(easJson);
});
test('invalid eas.json with missing preset', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'generic' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('debug');
await expect(promise).rejects.toThrowError(
'There is no profile named debug for platform android'
);
});
test('invalid eas.json when using buildType for wrong platform', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { workflow: 'managed', buildType: 'archive' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('release');
await expect(promise).rejects.toThrowError(
'Object "android.release" in eas.json is not valid [ValidationError: "buildType" must be one of [apk, app-bundle]]'
);
});
test('invalid eas.json when missing workflow', async () => {
await fs.writeJson('/project/eas.json', {
builds: {
android: {
release: { buildType: 'apk' },
},
},
});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('release');
await expect(promise).rejects.toThrowError(
'eas.json is not valid [ValidationError: "builds.android.release.workflow" is required]'
);
});
test('empty json', async () => {
await fs.writeJson('/project/eas.json', {});
const reader = new EasJsonReader('/project', 'android');
const promise = reader.readAsync('release');
await expect(promise).rejects.toThrowError(
'There is no profile named release for platform android'
);
});
+12
View File
@@ -0,0 +1,12 @@
export {
Workflow,
CredentialsSource,
AndroidManagedBuildProfile,
AndroidGenericBuildProfile,
AndroidBuildProfile,
iOSManagedBuildProfile,
iOSGenericBuildProfile,
iOSBuildProfile,
EasConfig,
} from './Config.types';
export { EasJsonReader } from './EasJsonReader';
@@ -1,18 +1,15 @@
{
"compilerOptions": {
"declaration": true,
"esModuleInterop": true,
"importHelpers": true,
"module": "commonjs",
"esModuleInterop": true,
"noImplicitReturns": true,
"strict": true,
"target": "ES2019",
"outDir": "build",
"rootDir": "src",
"typeRoots": [
"../ts-declarations",
"./node_modules/@types"
]
"strict": true,
"target": "es2017"
},
"include": ["src/**/*"]
"include": [
"src/**/*"
]
}
-46
View File
@@ -1,46 +0,0 @@
const rootEslintrc = require('../../.eslintrc.js');
module.exports = {
rules: {
'no-restricted-imports': [
'error',
{
paths: [
...rootEslintrc.rules['no-restricted-imports'][1].paths,
{
name: 'ora',
message: 'Import ora from src/ora.ts instead.',
},
],
},
],
'graphql/template-strings': [
'error',
{
env: 'apollo',
schemaJson: require('./graphql.schema.json'),
},
],
'graphql/named-operations': [
'error',
{
schemaJson: require('./graphql.schema.json'),
},
],
'graphql/required-fields': [
'error',
{
env: 'apollo',
schemaJson: require('./graphql.schema.json'),
requiredFields: ['id'],
},
],
'graphql/capitalized-type-name': [
'error',
{
schemaJson: require('./graphql.schema.json'),
},
],
},
plugins: ['graphql'],
};
-2
View File
@@ -1,2 +0,0 @@
graphql.schema.json linguist-generated
src/graphql/generated.ts linguist-generated
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
const getConfig = jest.fn();
export { getConfig };
-12
View File
@@ -1,14 +1,2 @@
import { fs } from 'memfs';
// needed because of a weird bug with tempy (dependency of @expo/config-plugins)
// eslint-disable-next-line node/no-sync
fs.mkdirSync('/tmp');
if (process.env.TMPDIR) {
// eslint-disable-next-line node/no-sync
fs.mkdirSync(process.env.TMPDIR, { recursive: true });
}
// fs-extra@10 is not compatible with memfs, the following line fixes tests
(fs.realpath as any).native = jest.fn();
module.exports = fs;
+6
View File
@@ -0,0 +1,6 @@
export default jest.fn().mockReturnValue({
start: jest.fn().mockReturnValue({
fail: jest.fn(),
succeed: jest.fn(),
}),
});
@@ -1,13 +0,0 @@
#!/usr/bin/env sh
OUTFILE="src/graphql/generated.ts"
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/
*/
"
printf '%s\n%s\n' "$content" "$(cat $OUTFILE)" > $OUTFILE
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env node
const oclif = require('@oclif/core');
const path = require('path');
const project = path.join(__dirname, '..', 'tsconfig.json');
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
-3
View File
@@ -1,3 +0,0 @@
@echo off
node "%~dp0\dev" %*
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
require('@oclif/core')
require('@oclif/command')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
.then(require('@oclif/command/flush'))
.catch(require('@oclif/errors/handle'));
@@ -1,7 +0,0 @@
{
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"strict": true
}
}
-20
View File
@@ -1,20 +0,0 @@
overwrite: true
schema: 'http://localhost:3000/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
config:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- ./annotate-graphql-codegen.sh
./graphql.schema.json:
plugins:
- 'introspection'
File diff suppressed because it is too large. Load diff
-2
View File
@@ -2,6 +2,4 @@ module.exports = {
...require('../../jest/jest.config.js'),
rootDir: __dirname,
roots: ['src', '__mocks__'],
globalSetup: './jest/global-setup.ts',
setupFilesAfterEnv: ['./jest/setup-after-env.ts'],
};
-5
View File
@@ -1,5 +0,0 @@
module.exports = async () => {
process.env.TZ = 'UTC';
process.env.EAS_NO_VCS = '1';
process.env.EAS_PROJECT_ROOT = '/app';
};
-6
View File
@@ -1,6 +0,0 @@
/* eslint-disable no-console */
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
console.error = jest.fn();
});
+76 -153
View File
@@ -1,121 +1,83 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.52.0",
"author": "Expo <support@expo.dev>",
"version": "0.1.0-alpha.0",
"author": "Expo <support@expo.io>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.31",
"@expo/code-signing-certificates": "0.0.2",
"@expo/config": "6.0.21",
"@expo/config-plugins": "4.1.2",
"@expo/config-types": "44.0.0",
"@expo/eas-build-job": "0.2.71",
"@expo/eas-json": "0.52.0",
"@expo/json-file": "8.2.34",
"@expo/multipart-body-parser": "1.1.0",
"@expo/package-manager": "0.0.50",
"@expo/pkcs12": "0.0.7",
"@expo/plist": "0.0.17",
"@expo/plugin-warn-if-update-available": "2.1.0",
"@expo/prebuild-config": "3.1.2",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.6.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.6.4",
"@oclif/plugin-autocomplete": "1.2.0",
"@oclif/plugin-help": "5.1.12",
"@urql/core": "2.4.3",
"@urql/exchange-retry": "0.3.2",
"adm-zip": "0.5.9",
"chalk": "4.1.2",
"cli-progress": "3.10.0",
"cli-table3": "0.6.1",
"dateformat": "4.6.3",
"env-paths": "2.2.0",
"envinfo": "7.8.1",
"fast-deep-equal": "3.1.3",
"fast-glob": "3.2.11",
"figures": "3.2.0",
"form-data": "4.0.0",
"fs-extra": "10.0.1",
"getenv": "1.0.0",
"got": "^11.1.4",
"gradle-to-js": "2.0.0",
"graphql": "16.3.0",
"graphql-tag": "2.12.6",
"https-proxy-agent": "5.0.0",
"ignore": "5.2.0",
"indent-string": "4.0.0",
"jks-js": "1.0.2",
"js-yaml": "^3.13.1",
"joi": "17.6.0",
"keychain": "1.3.0",
"log-symbols": "4.1.0",
"mime": "3.0.0",
"minimatch": "3.0.5",
"nanoid": "3.3.2",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
"nullthrows": "1.1.1",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"prompts": "2.4.2",
"qrcode-terminal": "0.12.0",
"pnp-webpack-plugin": "1.7.0",
"react-dev-utils": "12.0.0",
"resolve-from": "5.0.0",
"semver": "7.3.5",
"slash": "3.0.0",
"strip-ansi": "6.0.0",
"tar": "6.1.11",
"terminal-link": "2.1.1",
"ts-loader": "^9.2.8",
"tslib": "2.3.1",
"turndown": "7.1.1",
"untildify": "4.0.0",
"uuid": "8.3.2",
"webpack": "5.72.0",
"wrap-ansi": "7.0.0"
"@eas/config": "^0.1.0-alpha.0",
"@expo/config": "^3.3.9",
"@expo/eas-build-job": "^0.1.1",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.10",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "^1.5.0",
"@oclif/command": "^1",
"@oclif/config": "^1",
"@oclif/plugin-help": "^3",
"@urql/core": "^1.13.0",
"chalk": "^4.1.0",
"cli-table3": "^0.6.0",
"dateformat": "^3.0.3",
"env-paths": "^2.2.0",
"form-data": "^3.0.0",
"fs-extra": "^9.0.1",
"got": "^11.5.2",
"graphql-tag": "^2.11.0",
"indent-string": "^4.0.0",
"keychain": "^1.3.0",
"lodash": "^4.17.20",
"md5-file": "^5.0.0",
"minimatch": "^3.0.4",
"node-fetch": "^2.6.1",
"node-forge": "^0.10.0",
"ora": "^5.1.0",
"pkg-dir": "^4.2.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"qrcode-terminal": "^0.12.0",
"tar": "^6.0.5",
"tslib": "^1",
"untildify": "^4.0.0",
"uuid": "^8.3.0",
"wordwrap": "^1.0.0"
},
"devDependencies": {
"@graphql-codegen/cli": "2.6.2",
"@graphql-codegen/introspection": "2.1.1",
"@graphql-codegen/typescript": "2.4.8",
"@graphql-codegen/typescript-operations": "2.3.5",
"@types/cli-progress": "3.9.2",
"@types/adm-zip": "0.4.34",
"@types/dateformat": "3.0.1",
"@types/envinfo": "7.8.1",
"@types/fs-extra": "9.0.13",
"@types/getenv": "^1.0.0",
"@types/js-yaml": "^3.12.2",
"@types/mime": "2.0.3",
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@expo/babel-preset-cli": "^0.2.17",
"@types/color-string": "^1.5.0",
"@types/dateformat": "^3.0.1",
"@types/form-data": "^2.5.0",
"@types/fs-extra": "^9.0.1",
"@types/lodash": "^4.14.161",
"@types/node": "^12",
"@types/node-fetch": "^2.5.7",
"@types/node-forge": "1.0.1",
"@types/prompts": "2.0.14",
"@types/semver": "7.3.9",
"@types/tar": "6.1.1",
"@types/tough-cookie": "4.0.1",
"@types/uuid": "8.3.4",
"@types/webpack": "5.28.0",
"@types/wrap-ansi": "3.0.0",
"axios": "0.26.1",
"eslint-plugin-graphql": "4.0.0",
"form-data": "4.0.0",
"memfs": "3.4.1",
"mockdate": "3.0.5",
"nock": "13.2.4",
"tempy": "0.7.0",
"ts-deepmerge": "2.0.1",
"typescript": "4.6.3"
"@types/node-forge": "^0.9.5",
"@types/progress": "^2.0.3",
"@types/prompts": "^2.0.8",
"@types/tar": "^4.0.3",
"@types/uuid": "^8.3.0",
"@types/wordwrap": "^1.0.0",
"@vercel/ncc": "^0.23.0",
"babel-jest": "^26.3.0",
"graphql": "^15.3.0",
"memfs": "^3.2.0",
"tempy": "^0.7.0",
"ts-node": "^8",
"typescript": "^3.9.7",
"wonka": "^4.0.14"
},
"optionalDependencies": {
"@expo/traveling-fastlane-darwin": "1.15.1",
"@expo/traveling-fastlane-linux": "1.15.1"
},
"engines": {
"node": ">=12.0.0"
"node": ">=8.0.0"
},
"files": [
"/bin",
@@ -129,54 +91,25 @@
"cli"
],
"license": "MIT",
"main": "build/index.js",
"oclif": {
"bin": "eas",
"commands": "./build/commands",
"plugins": [
"@oclif/plugin-autocomplete",
"@oclif/plugin-help",
"@expo/plugin-warn-if-update-available"
"@oclif/plugin-help"
],
"topics": {
"account": {
"description": "manage account"
},
"branch": {
"description": "manage update branches"
},
"build": {
"description": "build app binaries"
},
"channel": {
"description": "manage update channels"
},
"device": {
"description": "manage Apple devices for Internal Distribution"
},
"project": {
"description": "manage project"
},
"release": {
"description": "manage update releases"
},
"secret": {
"description": "manage project and account secrets"
},
"update": {
"description": "manage individual updates"
"description": "roll out changes"
},
"webhook": {
"description": "manage webhooks"
}
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"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": "12.13.0"
},
"s3": {
"templates": {
"target": {
@@ -185,25 +118,15 @@
}
}
}
},
"additionalHelpFlags": [
"-h"
],
"additionalVersionFlags": [
"-v"
]
}
},
"repository": "expo/eas-cli",
"scripts": {
"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",
"typecheck": "tsc",
"prepack": "rm -rf build && yarn build && oclif-dev manifest && oclif-dev readme",
"build": "tsc",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"clean": "rm -rf dist build tmp node_modules yarn-error.log"
}
"version": "oclif-dev readme && git add README.md"
},
"types": "build/index.d.ts"
}
-12
View File
@@ -1,12 +0,0 @@
const { promises: fs } = require('fs');
(async () => {
// Patch `oclif readme` path and link generation
let readmeContent = await fs.readFile('README.md', 'utf8');
readmeContent = readmeContent.replace(/\[build\//g, '[src/');
readmeContent = readmeContent.replace(/build\/commands/g, 'packages/eas-cli/src/commands');
await fs.writeFile('README.md', readmeContent);
// eslint-disable-next-line no-console
console.log('Patched README path generation');
})();
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
rm -rf build
yarn build
if [ -z "$CLI_SIZE_CHECK" ]; then
yarn oclif manifest
fi
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
EAS_CLI_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
PACKAGE_JSON_PATH="$EAS_CLI_DIR/package.json"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s/\\\"@expo\/eas-json\\\": \\\".*\\\"/\\\"@expo\/eas-json\\\": \\\"file:..\/..\/..\/eas-json\\\"/g" $PACKAGE_JSON_PATH
else
sed -i "s/\\\"@expo\/eas-json\\\": \\\".*\\\"/\\\"@expo\/eas-json\\\": \\\"file:..\/..\/..\/eas-json\\\"/g" $PACKAGE_JSON_PATH
fi
-9
View File
@@ -1,9 +0,0 @@
export const ora = jest.fn().mockReturnValue({
start: jest.fn().mockReturnValue({
fail: jest.fn(),
succeed: jest.fn(),
}),
stop: jest.fn(),
stopAndPersist: jest.fn(),
succeed: jest.fn(),
});
@@ -1,51 +0,0 @@
import assert from 'assert';
import nock from 'nock';
import { ApiV2Error, api, getExpoApiBaseUrl } from '../api';
describe('apiClient', () => {
it('converts Expo APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl())
.post('/v2/test')
.reply(400, {
errors: [
{
message: 'hellomessage',
code: 'TEST_CODE',
stack: 'line 1: hello',
details: { who: 'world' },
metadata: { an: 'object' },
},
],
});
let error: Error | null = null;
try {
await api.postAsync('test', { body: {} });
} catch (e: any) {
error = e;
}
expect(error).toBeInstanceOf(ApiV2Error);
assert(error instanceof ApiV2Error);
expect(error.message).toEqual('hellomessage');
expect(error.expoApiV2ErrorCode).toEqual('TEST_CODE');
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
});
it('does not convert non-APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl()).post('/v2/test').reply(500, 'Something went wrong');
let error: Error | null = null;
try {
await api.postAsync('test', { body: {} });
} catch (e: any) {
error = e;
}
expect(error).not.toBeNull();
expect(error).not.toBeInstanceOf(ApiV2Error);
});
});
@@ -1,4 +1,4 @@
import { Actor } from '../user/User';
import { User } from '../user/User';
interface MockProject {
projectRoot: string;
@@ -20,13 +20,13 @@ interface AppJSON {
version: string;
slug: string;
sdkVersion: string;
owner?: string;
owner: string;
[key: string]: any;
};
}
export function createTestProject(
user: Actor,
function createTestProject(
user: User,
appJSONExtraData?: Record<string, object | string>
): MockProject {
const projectRoot = '/test-project';
@@ -37,14 +37,13 @@ export function createTestProject(
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),
owner: user.username,
...appJSONExtraData,
},
};
@@ -58,3 +57,5 @@ export function createTestProject(
},
};
}
export { createTestProject };
+3
View File
@@ -0,0 +1,3 @@
export function asMock(fn: any): jest.Mock {
return fn as jest.Mock;
}
-25
View File
@@ -1,25 +0,0 @@
import { Analytics, Event } from './events';
export type TrackingContext = Record<string, string | number | boolean>;
export async function withAnalyticsAsync<Result>(
fn: () => Promise<Result>,
analytics: {
attemptEvent: Event;
successEvent: Event;
failureEvent: Event;
trackingCtx: TrackingContext;
}
): Promise<Result> {
try {
const result = await fn();
Analytics.logEvent(analytics.successEvent, analytics.trackingCtx);
return result;
} catch (error: any) {
Analytics.logEvent(analytics.failureEvent, {
...analytics.trackingCtx,
reason: error.message,
});
throw error;
}
}
-52
View File
@@ -1,52 +0,0 @@
import { logEvent } from './rudderstackClient';
export type Event = BuildEvent | SubmissionEvent;
export enum SubmissionEvent {
SUBMIT_COMMAND = 'submit cli submit command',
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
SUBMIT_COMMAND_SUCCESS = 'submit cli success',
SUBMIT_COMMAND_FAIL = 'submit cli fail',
GATHER_CREDENTIALS_ATTEMPT = 'submit cli gather credentials attempt',
GATHER_CREDENTIALS_SUCCESS = 'submit cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'submit cli gather credentials fail',
GATHER_ARCHIVE_ATTEMPT = 'submit cli gather archive attempt',
GATHER_ARCHIVE_SUCCESS = 'submit cli gather archive success',
GATHER_ARCHIVE_FAIL = 'submit cli gather archive fail',
SUBMIT_REQUEST_ATTEMPT = 'submit cli request attempt',
SUBMIT_REQUEST_SUCCESS = 'submit cli request success',
SUBMIT_REQUEST_FAIL = 'submit cli request fail',
}
export enum BuildEvent {
BUILD_COMMAND = 'build cli build command',
PROJECT_UPLOAD_ATTEMPT = 'build cli project upload attempt',
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
GATHER_CREDENTIALS_ATTEMPT = 'build cli gather credentials attempt',
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
CONFIGURE_PROJECT_ATTEMPT = 'build cli configure project attempt',
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
BUILD_STATUS_COMMAND = 'build cli build status',
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_ATTEMPT = 'build cli credentials sync update local attempt',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_ATTEMPT = 'build cli credentials sync update remote attempt',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',
}
export class Analytics {
static logEvent(name: Event, properties: Record<string, any>): void {
logEvent(name, properties);
}
}
@@ -1,129 +0,0 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
import os from 'os';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import UserSettings from '../user/UserSettings';
import { easCliVersion } from '../utils/easCli';
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
darwin: 'Mac',
win32: 'Windows',
linux: 'Linux',
};
let rudderstackClient: RudderAnalytics | null = null;
let userIdentified = false;
let identifyData: {
userId: string;
deviceId: string;
traits: Record<string, any>;
} | null = null;
export async function initAsync(): Promise<void> {
// TODO: remove after some time
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', null);
if (amplitudeEnabled !== null) {
await UserSettings.setAsync('analyticsEnabled', amplitudeEnabled);
await UserSettings.deleteKeyAsync('amplitudeEnabled');
}
const amplitudeDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
if (amplitudeDeviceId !== null) {
await UserSettings.setAsync('analyticsDeviceId', amplitudeDeviceId);
await UserSettings.deleteKeyAsync('amplitudeDeviceId');
}
// TODO: cut here
if (process.env.DISABLE_EAS_ANALYTICS) {
await UserSettings.setAsync('analyticsEnabled', false);
}
const analyticsEnabled = await UserSettings.getAsync('analyticsEnabled', true);
if (analyticsEnabled) {
const config =
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
? {
// staging environment
rudderstackWriteKey: '1wpX20Da4ltFGSXbPFYUL00Chb7',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
}
: {
// prod environment
rudderstackWriteKey: '1wpXLFxmujq86etH6G6cc90hPcC',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
};
rudderstackClient = new RudderAnalytics(
config.rudderstackWriteKey,
new URL('/v1/batch', config.rudderstackDataPlaneURL).toString(),
{
flushInterval: 300,
}
);
}
}
export async function setUserDataAsync(userId: string, traits: Record<string, any>): Promise<void> {
const savedDeviceId = await UserSettings.getAsync('analyticsDeviceId', null);
const deviceId = savedDeviceId ?? uuidv4();
if (!savedDeviceId) {
await UserSettings.setAsync('analyticsDeviceId', deviceId);
}
identifyData = {
userId,
deviceId,
traits,
};
ensureUserIdentified();
}
export async function flushAsync(): Promise<void> {
if (rudderstackClient) {
await rudderstackClient.flush();
}
}
export function logEvent(name: string, properties: Record<string, any> = {}): void {
if (!rudderstackClient) {
return;
}
ensureUserIdentified();
const { userId, deviceId } = identifyData ?? {};
const commonEventProperties = { source_version: easCliVersion, source: 'eas cli' };
const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() };
rudderstackClient.track({
event: name,
properties: { ...properties, ...commonEventProperties },
...identity,
context: getRudderStackContext(),
});
}
function ensureUserIdentified(): void {
if (!rudderstackClient || userIdentified || !identifyData) {
return;
}
rudderstackClient.identify({
userId: identifyData.userId,
anonymousId: identifyData.deviceId,
traits: identifyData.traits,
});
userIdentified = true;
}
function getRudderStackContext(): Record<string, any> {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
return {
os: { name: platform, version: os.release() },
device: { type: platform, model: platform },
app: { name: 'eas cli', version: easCliVersion ?? undefined },
};
}
export enum AnalyticsEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
+23 -102
View File
@@ -1,121 +1,42 @@
import { JSONValue } from '@expo/json-file';
import got, { NormalizedOptions } from 'got';
import fetch, { RequestError, RequestInit } from './fetch';
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
import { getAccessToken, getSessionSecret } from './user/User';
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 const api = {
async putAsync(path: string, options: RequestOptions): Promise<any> {
return await 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(),
export const apiClient = got.extend({
prefixUrl: getExpoApiBaseUrl() + '/--/api/v2/',
hooks: {
beforeRequest: [
(options: NormalizedOptions) => {
const token = getAccessToken();
if (token) {
options.headers.authorization = `Bearer ${token}`;
return;
}
const sessionSecret = getSessionSecret();
if (sessionSecret) {
options.headers['expo-session'] = sessionSecret;
}
},
});
return await response.json();
} catch (err) {
await handleApiErrorAsync(err);
}
}
async function 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 {
if (process.env.EXPO_STAGING) {
return `https://staging-api.expo.dev`;
return `https://staging.exp.host`;
} else if (process.env.EXPO_LOCAL) {
return `http://127.0.0.1:3000`;
} else {
return `https://api.expo.dev`;
return `https://exp.host`;
}
}
export function getExpoWebsiteBaseUrl(): string {
if (process.env.EXPO_STAGING) {
return `https://staging.expo.dev`;
return `https://staging.expo.io`;
} else if (process.env.EXPO_LOCAL) {
return `http://expo.test`;
} else {
return `https://expo.dev`;
}
}
export function getEASUpdateURL(projectId: string): string {
if (process.env.EXPO_STAGING) {
return new URL(projectId, `https://staging-u.expo.dev`).href;
} else if (process.env.EXPO_LOCAL) {
return new URL(`expo-updates/${projectId}`, `http://127.0.0.1:3000`).href;
} else {
return new URL(projectId, `https://u.expo.dev`).href;
return `https://expo.io`;
}
}
@@ -0,0 +1,293 @@
import { CredentialsSource, Workflow } from '@eas/config';
import prompts from 'prompts';
import { asMock } from '../../__tests__/utils';
import { CredentialsProvider } from '../../credentials/CredentialsProvider';
import { ensureCredentialsAsync } from '../credentials';
jest.mock('prompts');
function createMockCredentialsProvider({
hasRemote,
hasLocal,
isLocalSynced,
}: any): CredentialsProvider {
return {
platform: 'android',
hasRemoteAsync: jest.fn().mockImplementation(() => hasRemote || false),
hasLocalAsync: jest.fn().mockImplementation(() => hasLocal || false),
isLocalSyncedAsync: jest.fn().mockImplementation(() => isLocalSynced || false),
} as CredentialsProvider;
}
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.log = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
});
beforeEach(() => {
asMock(prompts).mockReset();
asMock(prompts).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
});
describe(ensureCredentialsAsync, () => {
describe('for generic builds', () => {
it("should use credentials.json if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(src).toBe('local');
});
it("should use credentials from server if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(src).toBe('remote');
});
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
it('should ask which credentials to use when local and remote crednitals have different values', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { select: 'local' };
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('local');
});
it('should ask when local and remote are not the same (select remote)', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { select: 'remote' };
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('remote');
});
it('should should throw an error when local and remote are not the same (in non interactive mode)', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
'Contents of your local credentials.json for Android are not the same as credentials on Expo servers'
);
}
expect(prompts).toHaveBeenCalledTimes(0);
});
it('should ask when no credentials are present and return remote if confirm=true', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { value: true }; //confirm
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(1);
expect(src).toBe('remote');
});
it('should ask when no credentials are present and abort if confimr=false', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementationOnce(() => {
return { value: false }; //confirm
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, false);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch(
'Aborting build process, credentials are not configured for Android'
);
}
expect(prompts).toHaveBeenCalledTimes(1);
});
it('should throw an error when no credentials are present in non interactive mode', async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: false,
isLocalSynced: false,
});
asMock(prompts).mockImplementation(() => {
return { value: true }; // confirm
});
try {
await ensureCredentialsAsync(provider, Workflow.Generic, CredentialsSource.AUTO, true);
throw new Error('ensureCredentialsAsync should throw an Error');
} catch (e) {
expect(e.message).toMatch('Credentials for this app are not configured');
}
expect(prompts).toHaveBeenCalledTimes(0);
});
});
describe('for managed builds', () => {
it("should use credentials.json if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: false,
hasLocal: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(src).toBe('local');
});
it("should use credentials from server if it's the only available credentials", async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(src).toBe('remote');
});
it('should use credentials.json if credentials are the same in credentials.json and on server', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: true,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
it('should use credentials.json even if credentials on server are different', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Managed,
CredentialsSource.AUTO,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
});
describe('generic builds with credentialsSource set to local', () => {
it('should use credentials.json without asking even if credentials on server are different', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.LOCAL,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('local');
});
});
describe('generic builds with credentialsSource set to remote', () => {
it('should use credentials from server even if credentials.json exists and have different values', async () => {
const provider = createMockCredentialsProvider({
hasRemote: true,
hasLocal: true,
isLocalSynced: false,
});
const src = await ensureCredentialsAsync(
provider,
Workflow.Generic,
CredentialsSource.REMOTE,
false
);
expect(prompts).toHaveBeenCalledTimes(0);
expect(src).toBe('remote');
});
});
});
@@ -1,280 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import assert from 'assert';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import path from 'path';
import { getAppBuildGradleAsync, resolveConfigValue } from '../../../project/android/gradleUtils';
import {
BumpStrategy,
bumpVersionAsync,
bumpVersionInAppJsonAsync,
maybeResolveVersionsAsync,
} from '../version';
const fsReal = jest.requireActual('fs').promises as typeof fs;
jest.mock('fs');
afterAll(async () => {
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
await fs.remove(os.tmpdir());
});
beforeEach(async () => {
vol.reset();
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
await fs.mkdirp(os.tmpdir());
});
// bare workflow
describe(bumpVersionAsync, () => {
it('throws an informative error when multiple flavors are detected in the android project', async () => {
await initProjectWithGradleFileAsync(
/*buildGradlePath*/ path.join(
__dirname,
'../../../project/android/__tests__/fixtures/multiflavor-build.gradle'
)
);
await expect(
bumpVersionAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/multiflavor',
exp: {} as any,
})
).rejects.toThrow(
'Automatic version bumping is not supported for multi-flavor Android projects.'
);
});
it('throws an informative error when multiple flavor dimensions are defined in an android project', async () => {
await initProjectWithGradleFileAsync(
/*buildGradlePath*/ path.join(
__dirname,
'../../../project/android/__tests__/fixtures/multiflavor-with-dimensions-build.gradle'
)
);
await expect(
bumpVersionAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/multiflavor',
exp: {} as any,
})
).rejects.toThrow(
'Automatic version bumping is not supported for multi-flavor Android projects.'
);
});
it('bumps expo.android.versionCode and buildGradle versionCode when strategy = BumpStrategy.VERSION_CODE', async () => {
const nativeVersionCode = 100; // this should be overwritten by bumpVersionAsync
const fakeExp = initBareWorkflowProject({ versionCode: nativeVersionCode });
await bumpVersionAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/repo',
exp: fakeExp,
});
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('/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.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: '/repo',
exp: fakeExp,
});
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('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.1');
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initBareWorkflowProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
});
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('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
});
});
// managed workflow
describe(bumpVersionInAppJsonAsync, () => {
it('bumps expo.android.versionCode when strategy = BumpStrategy.VERSION_CODE', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/repo',
exp: fakeExp,
});
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');
expect(appJSON.expo.android.versionCode).toBe(127);
});
it('bumps expo.version when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
exp: fakeExp,
});
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');
expect(appJSON.expo.android.versionCode).toBe(126);
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
});
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');
expect(appJSON.expo.android.versionCode).toBe(126);
});
});
describe(maybeResolveVersionsAsync, () => {
describe('bare project', () => {
it('reads the versions from native code', async () => {
const exp = initBareWorkflowProject();
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/repo',
exp,
{} as BuildProfile<Platform.ANDROID>
);
expect(appVersion).toBe('3.0.0');
expect(appBuildVersion).toBe('123');
});
});
describe('managed project', () => {
it('reads the versions from expo config', async () => {
const exp = initManagedProject();
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/repo',
exp,
{} as BuildProfile<Platform.ANDROID>
);
expect(appVersion).toBe('5.0.0');
expect(appBuildVersion).toBe('126');
});
});
});
function initBareWorkflowProject({
versionCode = 123,
versionName = '3.0.0',
}: {
versionCode?: number;
versionName?: string;
} = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '3.0.0',
android: {
versionCode: 123,
},
};
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: fakeExp,
}),
'./android/app/build.gradle': `android {
defaultConfig {
applicationId "com.expo.testapp"
versionCode ${versionCode}
versionName "${versionName}"
}
}`,
'./android/app/src/main/AndroidManifest.xml': 'fake',
},
'/repo'
);
return fakeExp;
}
function initManagedProject(): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '5.0.0',
android: {
versionCode: 126,
},
};
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: fakeExp,
}),
},
'/repo'
);
return fakeExp;
}
async function initProjectWithGradleFileAsync(gradleFilePath: string): Promise<void> {
vol.fromJSON(
{
'android/app/build.gradle': await fsReal.readFile(gradleFilePath, 'utf-8'),
'./app.json': JSON.stringify({
expo: {} as any,
}),
},
'/multiflavor'
);
}
+42 -111
View File
@@ -1,132 +1,63 @@
import { Android, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { EasConfig, Workflow } from '@eas/config';
import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
getApplicationIdAsync,
} from '../../project/android/applicationId';
import { resolveGradleBuildContextAsync } from '../../project/android/gradle';
import { toggleConfirmAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import {
BuildRequestSender,
CredentialsResult,
JobData,
prepareBuildRequestForPlatformAsync,
} from '../build';
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
import { transformMetadata } from '../graphql';
import { logCredentialsSource } from '../utils/credentials';
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
import { transformJob } from './graphql';
import { createCredentialsContextAsync } from '../../credentials/context';
import { CredentialsResult, startBuildForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
export async function createAndroidContextAsync(
ctx: CommonContext<Platform.ANDROID>
): Promise<AndroidBuildContext> {
const { buildProfile } = ctx;
export async function startAndroidBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig,
projectId: string
): Promise<string> {
const buildCtx = createBuildContext<Platform.Android>({
commandCtx,
platform: Platform.Android,
easConfig,
projectId,
});
if (buildProfile.distribution === 'internal' && buildProfile.gradleCommand?.match(/bundle/)) {
Log.addNewLineIfNone();
Log.warn(
`You're building your Android app for internal distribution. However, we've detected that the Gradle command you defined (${chalk.underline(
buildProfile.gradleCommand
)}) includes string 'bundle'.
This means that it will most likely produce an AAB and you will not be able to install it on your Android devices straight from the Expo website.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
if (!confirmed) {
Log.error('Please update eas.json and come back again.');
process.exit(1);
}
}
checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);
if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
}
const applicationId = await getApplicationIdAsync(ctx.projectDir, ctx.exp, gradleContext);
return { applicationId, gradleContext };
}
export async function prepareAndroidBuildAsync(
ctx: BuildContext<Platform.ANDROID>
): Promise<BuildRequestSender> {
return await prepareBuildRequestForPlatformAsync({
ctx,
ensureCredentialsAsync: async (ctx: BuildContext<Platform.ANDROID>) => {
return await ensureAndroidCredentialsAsync(ctx);
},
syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
buildProfile: ctx.buildProfile,
});
},
prepareJobAsync: async (
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData<AndroidCredentials>
): Promise<Job> => {
return await prepareJobAsync(ctx, jobData);
},
sendBuildRequestAsync: async (
appId: string,
job: Android.Job,
metadata: Metadata
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createAndroidBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
});
},
return await startBuildForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {},
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
ensureProjectConfiguredAsync: async () => {},
prepareJobAsync,
});
}
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
return !ctx.buildProfile.withoutCredentials;
function shouldProvideCredentials(ctx: BuildContext<Platform.Android>): boolean {
return (
ctx.buildProfile.workflow === Workflow.Managed ||
(ctx.buildProfile.workflow === Workflow.Generic && !ctx.buildProfile.withoutCredentials)
);
}
async function ensureAndroidCredentialsAsync(
ctx: BuildContext<Platform.ANDROID>
ctx: BuildContext<Platform.Android>
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
}
const androidApplicationIdentifier = await getApplicationIdAsync(
ctx.projectDir,
ctx.exp,
ctx.android.gradleContext
);
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
app: {
account: nullthrows(
findAccountByName(ctx.user.accounts, ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
androidApplicationIdentifier,
const provider = new AndroidCredentialsProvider(
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {}),
{
projectName: ctx.commandCtx.projectName,
accountName: ctx.commandCtx.accountName,
},
});
const { credentialsSource } = ctx.buildProfile;
logCredentialsSource(credentialsSource, Platform.ANDROID);
{ nonInteractive: ctx.commandCtx.nonInteractive }
);
const credentialsSource = await ensureCredentialsAsync(
provider,
ctx.buildProfile.workflow,
ctx.buildProfile.credentialsSource,
ctx.commandCtx.nonInteractive
);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
@@ -1,31 +0,0 @@
import { Android } from '@expo/eas-build-job';
import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated';
import { transformProjectArchive, transformWorkflow } from '../graphql';
export function transformJob(job: Android.Job): AndroidJobInput {
return {
type: transformWorkflow(job.type),
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
secrets: job.secrets,
builderEnvironment: job.builderEnvironment,
cache: job.cache,
gradleCommand: job.gradleCommand,
artifactPath: job.artifactPath,
username: job.username,
buildType: job.buildType && transformBuildType(job.buildType),
developmentClient: job.developmentClient,
experimental: job.experimental,
};
}
function transformBuildType(buildType: Android.BuildType): AndroidBuildType {
if (buildType === Android.BuildType.APK) {
return AndroidBuildType.Apk;
} else {
return AndroidBuildType.AppBundle;
}
}
@@ -1,34 +1,50 @@
import { Android, ArchiveSource, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { AndroidGenericBuildProfile, AndroidManagedBuildProfile, Workflow } from '@eas/config';
import { Android, BuildType, Job, sanitizeJob } from '@expo/eas-build-job';
import path from 'path';
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 { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface JobData {
projectArchive: ArchiveSource;
archiveUrl: string;
credentials?: AndroidCredentials;
}
const cacheDefaults = {
disabled: false,
customPaths: [],
cacheDefaultPaths: true,
};
export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
ctx: BuildContext<Platform.Android>,
jobData: JobData
): Promise<Job> {
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const { credentials } = jobData;
if (ctx.buildProfile.workflow === Workflow.Generic) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
throw new Error("Unknown workflow. Shouldn't happen");
}
}
interface CommonJobProperties {
platform: Platform.Android;
projectUrl: string;
secrets: {
buildCredentials?: {
keystore: Android.Keystore;
};
secretEnvs?: Record<string, string>;
};
}
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const credentials = jobData.credentials;
const buildCredentials = credentials
? {
buildCredentials: {
@@ -42,43 +58,40 @@ export async function prepareJobAsync(
}
: {};
let buildType = ctx.buildProfile.buildType;
if (!buildType && !buildProfile.gradleCommand && ctx.buildProfile.distribution === 'internal') {
buildType = Android.BuildType.APK;
}
const job: Android.Job = {
type: ctx.workflow,
platform: Platform.ANDROID,
projectRootDirectory,
projectArchive: jobData.projectArchive,
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
yarn: buildProfile.yarn,
ndk: buildProfile.ndk,
expoCli: buildProfile.expoCli,
env: buildProfile.env,
},
cache: {
...cacheDefaults,
...buildProfile.cache,
clear: ctx.clearCache,
},
return {
platform: Platform.Android,
projectUrl: jobData.archiveUrl,
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...buildCredentials,
},
releaseChannel: ctx.buildProfile.releaseChannel,
updates: { channel: ctx.buildProfile.channel },
developmentClient: buildProfile.developmentClient,
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData,
buildProfile: AndroidGenericBuildProfile
): Promise<Partial<Android.GenericJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: BuildType.Generic,
gradleCommand: buildProfile.gradleCommand,
artifactPath: buildProfile.artifactPath,
buildType,
username,
experimental: {
prebuildCommand: ctx.buildProfile.prebuildCommand,
},
projectRootDirectory,
};
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.Android>,
jobData: JobData,
buildProfile: AndroidManagedBuildProfile
): Promise<Partial<Android.ManagedJob>> {
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: BuildType.Managed,
packageJson: { example: 'packageJson' },
manifest: { example: 'manifest' },
};
return sanitizeJob(job);
}
@@ -1,68 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { AndroidVersionAutoIncrement, BuildProfile } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
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({
projectDir,
exp,
buildProfile,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: BuildProfile<Platform.ANDROID>;
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const { autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
await cleanUpOldEasBuildGradleScriptAsync(projectDir);
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: AndroidVersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.VERSION_CODE;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'versionCode') {
return BumpStrategy.VERSION_CODE;
} else {
return BumpStrategy.APP_VERSION;
}
}
// TODO: remove this after a few months
export async function cleanUpOldEasBuildGradleScriptAsync(projectDir: string): Promise<void> {
const easBuildGradlePath = path.join(projectDir, 'android', 'app', 'eas-build.gradle');
if (await fs.pathExists(easBuildGradlePath)) {
Log.withTick(`Removing ${chalk.bold('eas-build.gradle')} as it's not longer necessary`);
await fs.remove(easBuildGradlePath);
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf-8');
const buildGradleContentsWithoutApply = buildGradleContents.replace(
/apply from: ["'].\/eas-build.gradle["']\n/,
''
);
if (buildGradleContentsWithoutApply !== buildGradleContents) {
await fs.writeFile(buildGradlePath, buildGradleContentsWithoutApply);
}
}
}
@@ -1,153 +0,0 @@
import { ExpoConfig } from '@expo/config';
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 Log from '../../log';
import {
getAppBuildGradleAsync,
parseGradleCommand,
resolveConfigValue,
} from '../../project/android/gradleUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
export enum BumpStrategy {
APP_VERSION,
VERSION_CODE,
NOOP,
}
export async function bumpVersionAsync({
bumpStrategy,
projectDir,
exp,
}: {
projectDir: string;
exp: ExpoConfig;
bumpStrategy: BumpStrategy;
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
const buildGradle = await getAppBuildGradleAsync(projectDir);
const isMultiFlavor =
buildGradle.android?.productFlavors || buildGradle.android?.flavorDimensions;
if (isMultiFlavor) {
throw new Error(
'Automatic version bumping is not supported for multi-flavor Android projects.'
);
}
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
Log.log('Updated versions in app.json');
await writeVersionsToBuildGradleAsync({
projectDir,
exp,
});
Log.log('Synchronized versions with build gradle');
}
export async function bumpVersionInAppJsonAsync({
bumpStrategy,
projectDir,
exp,
}: {
bumpStrategy: BumpStrategy;
projectDir: string;
exp: ExpoConfig;
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
Log.addNewLineIfNone();
if (bumpStrategy === BumpStrategy.APP_VERSION) {
const appVersion = AndroidConfig.Version.getVersionName(exp) ?? '1.0.0';
await bumpAppVersionAsync({ appVersion, projectDir, exp });
} else {
const versionCode = AndroidConfig.Version.getVersionCode(exp);
const bumpedVersionCode = versionCode + 1;
Log.log(
`Bumping ${chalk.bold('expo.android.versionCode')} from ${chalk.bold(
versionCode
)} to ${chalk.bold(bumpedVersionCode)}`
);
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.android = { ...config.android, versionCode: bumpedVersionCode };
});
}
}
export async function maybeResolveVersionsAsync(
projectDir: string,
exp: ExpoConfig,
buildProfile: BuildProfile<Platform.ANDROID>
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
if (workflow === Workflow.GENERIC) {
const buildGradle = await getAppBuildGradleAsync(projectDir);
try {
const parsedGradleCommand = buildProfile.gradleCommand
? parseGradleCommand(buildProfile.gradleCommand, buildGradle)
: undefined;
return {
appVersion:
resolveConfigValue(buildGradle, 'versionName', parsedGradleCommand?.flavor) ?? '1.0.0',
appBuildVersion:
resolveConfigValue(buildGradle, 'versionCode', parsedGradleCommand?.flavor) ?? '1',
};
} catch (err: any) {
return {};
}
} else {
return {
appBuildVersion: String(AndroidConfig.Version.getVersionCode(exp)),
appVersion: exp.version,
};
}
}
async function writeVersionsToBuildGradleAsync({
projectDir,
exp,
}: {
projectDir: string;
exp: ExpoConfig;
}): Promise<string> {
const buildGradle = await readBuildGradleAsync(projectDir);
if (!buildGradle) {
throw new Error('This project is missing a build.gradle file.');
}
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> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
if (!(await fs.pathExists(buildGradlePath))) {
return undefined;
}
return await fs.readFile(buildGradlePath, 'utf8');
}
async function writeBuildGradleAsync({
projectDir,
buildGradle,
}: {
projectDir: string;
buildGradle: string;
}): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
await fs.writeFile(buildGradlePath, buildGradle);
}
+90 -400
View File
@@ -1,153 +1,103 @@
import { ArchiveSource, ArchiveSourceType, Job, Metadata, Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import cliProgress from 'cli-progress';
import { CredentialsSource } from '@eas/config';
import { Job } from '@expo/eas-build-job';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import { withAnalyticsAsync } from '../analytics/common';
import { BuildEvent } from '../analytics/events';
import { getExpoWebsiteBaseUrl } from '../api';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
UploadSessionType,
} from '../graphql/generated';
import { BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore } from '../log';
import { Ora, ora } from '../ora';
import {
appPlatformDisplayNames,
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../platform';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { apiClient } from '../api';
import log from '../log';
import { UploadType, uploadAsync } from '../uploads';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
import { getVcsClient } from '../vcs';
import { platformDisplayNames } from './constants';
import { BuildContext } from './context';
import { runLocalBuildAsync } from './local';
import { collectMetadataAsync } from './metadata';
import { collectMetadata } from './metadata';
import { AnalyticsEvent, Platform, TrackingContext } from './types';
import Analytics from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import { makeProjectTarballAsync, reviewAndCommitChangesAsync } from './utils/repository';
import { makeProjectTarballAsync } from './utils/repository';
export interface CredentialsResult<Credentials> {
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
credentials: Credentials;
}
export interface JobData<Credentials> {
credentials?: Credentials;
projectArchive: ArchiveSource;
}
interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration> {
ctx: BuildContext<TPlatform>;
projectConfiguration: ProjectConfiguration;
ensureCredentialsAsync(
ctx: BuildContext<TPlatform>
): Promise<CredentialsResult<Credentials> | undefined>;
syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(
ctx: BuildContext<TPlatform>,
jobData: {
archiveUrl: string;
credentials?: Credentials;
projectConfiguration?: ProjectConfiguration;
}
): Promise<Job>;
}
export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
export async function prepareBuildRequestForPlatformAsync<
export async function startBuildForPlatformAsync<
TPlatform extends Platform,
Credentials,
TJob extends Job
>(builder: Builder<TPlatform, Credentials, TJob>): Promise<BuildRequestSender> {
const { ctx } = builder;
ProjectConfiguration
>(builder: Builder<TPlatform, Credentials, ProjectConfiguration>): Promise<string> {
const credentialsResult = await withAnalyticsAsync(
async () => await builder.ensureCredentialsAsync(ctx),
async () => await builder.ensureCredentialsAsync(builder.ctx),
{
attemptEvent: BuildEvent.GATHER_CREDENTIALS_ATTEMPT,
successEvent: BuildEvent.GATHER_CREDENTIALS_SUCCESS,
failureEvent: BuildEvent.GATHER_CREDENTIALS_FAIL,
trackingCtx: ctx.trackingCtx,
successEvent: AnalyticsEvent.GATHER_CREDENTIALS_SUCCESS,
failureEvent: AnalyticsEvent.GATHER_CREDENTIALS_FAIL,
trackingCtx: builder.ctx.trackingCtx,
}
);
await withAnalyticsAsync(async () => await builder.syncProjectConfigurationAsync(ctx), {
attemptEvent: BuildEvent.CONFIGURE_PROJECT_ATTEMPT,
successEvent: BuildEvent.CONFIGURE_PROJECT_SUCCESS,
failureEvent: BuildEvent.CONFIGURE_PROJECT_FAIL,
trackingCtx: ctx.trackingCtx,
});
if (await getVcsClient().isCommitRequiredAsync()) {
Log.addNewLineIfNone();
await reviewAndCommitChangesAsync(
`[EAS Build] Run EAS Build for ${requestedPlatformDisplayNames[ctx.platform as Platform]}`,
{ nonInteractive: ctx.nonInteractive }
);
if (!builder.ctx.commandCtx.skipProjectConfiguration) {
await withAnalyticsAsync(async () => await builder.ensureProjectConfiguredAsync(builder.ctx), {
successEvent: AnalyticsEvent.CONFIGURE_PROJECT_SUCCESS,
failureEvent: AnalyticsEvent.CONFIGURE_PROJECT_FAIL,
trackingCtx: builder.ctx.trackingCtx,
});
}
const projectArchive = ctx.localBuildOptions.enable
? ({
type: ArchiveSourceType.PATH,
path: (await makeProjectTarballAsync()).path,
} as const)
: ({
type: ArchiveSourceType.S3,
bucketKey: await uploadProjectAsync(ctx),
} as const);
const archiveUrl = await uploadProjectAsync(builder.ctx);
const metadata = await collectMetadataAsync(ctx);
const job = await builder.prepareJobAsync(ctx, {
projectArchive,
const metadata = await collectMetadata(builder.ctx, {
credentialsSource: credentialsResult?.source,
});
const job = await builder.prepareJobAsync(builder.ctx, {
archiveUrl,
credentials: credentialsResult?.credentials,
projectConfiguration: builder.projectConfiguration,
});
return async () => {
if (ctx.localBuildOptions.enable) {
await runLocalBuildAsync(job, ctx.localBuildOptions);
return undefined;
} else {
try {
return await sendBuildRequestAsync(builder, job, metadata);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
try {
return await withAnalyticsAsync(
async () => {
log(`Starting ${platformDisplayNames[job.platform]} build`);
const {
data: { buildId, deprecationInfo },
} = await apiClient
.post(`projects/${builder.ctx.projectId}/builds`, {
json: {
job,
metadata,
},
})
.json();
printDeprecationWarnings(deprecationInfo);
return buildId;
},
{
successEvent: AnalyticsEvent.BUILD_REQUEST_SUCCESS,
failureEvent: AnalyticsEvent.BUILD_REQUEST_FAIL,
trackingCtx: builder.ctx.trackingCtx,
}
);
} catch (error) {
if (error.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
log.error('EAS Build API changed, upgrade to latest expo-cli');
}
};
}
function handleBuildRequestError(error: any, platform: Platform): never {
if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
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, 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, 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_TOO_MANY_PENDING_BUILDS'
) {
Log.error(
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
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, please report the issue.'
);
throw error;
}
throw error;
}
async function uploadProjectAsync<TPlatform extends Platform>(
@@ -160,27 +110,16 @@ async function uploadProjectAsync<TPlatform extends Platform>(
const projectTarball = await makeProjectTarballAsync();
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadAsync(
UploadSessionType.EasBuildProjectSources,
log('Uploading project to AWS S3');
return await uploadAsync(
UploadType.TURTLE_PROJECT_SOURCES,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: ratio =>
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: (duration: string) =>
`Uploaded to EAS ${chalk.dim(duration)} ${learnMore(
'https://expo.fyi/eas-build-archive'
)}`,
})
createProgressTracker(projectTarball.size)
);
return bucketKey;
},
{
attemptEvent: BuildEvent.PROJECT_UPLOAD_ATTEMPT,
successEvent: BuildEvent.PROJECT_UPLOAD_SUCCESS,
failureEvent: BuildEvent.PROJECT_UPLOAD_FAIL,
successEvent: AnalyticsEvent.PROJECT_UPLOAD_SUCCESS,
failureEvent: AnalyticsEvent.PROJECT_UPLOAD_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
@@ -191,272 +130,23 @@ async function uploadProjectAsync<TPlatform extends Platform>(
}
}
async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
builder: Builder<TPlatform, Credentials, TJob>,
job: TJob,
metadata: Metadata
): Promise<BuildFragment> {
const { ctx } = builder;
return await withAnalyticsAsync(
async () => {
if (Log.isDebug) {
Log.log(`Starting ${requestedPlatformDisplayNames[job.platform]} build`);
}
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.projectId,
job,
metadata
);
printDeprecationWarnings(deprecationInfo);
return build;
},
{
attemptEvent: BuildEvent.BUILD_REQUEST_ATTEMPT,
successEvent: BuildEvent.BUILD_REQUEST_SUCCESS,
failureEvent: BuildEvent.BUILD_REQUEST_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
}
type MaybeBuildFragment = BuildFragment | null;
export async function waitForBuildEndAsync(
{ buildIds, accountName }: { buildIds: string[]; accountName: string },
{
// 2 hours (max build time limit) + 10 minutes (possible queue time)
timeoutSec = 2 * 60 * 60 + 10 * 60,
intervalSec = 10,
} = {}
): Promise<MaybeBuildFragment[]> {
const b = `build${buildIds.length > 1 ? 's' : ''}`;
Log.log(`Waiting for ${b} to complete. You can press Ctrl+C to exit.`);
const spinner = ora(`Waiting for ${b} to complete.`).start();
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 })
: await handleMultipleBuildsProgressAsync({ builds }, { spinner });
if (!refetch) {
return builds;
}
await sleepAsync(intervalSec * 1000);
async function withAnalyticsAsync<Result>(
fn: () => Promise<Result>,
analytics: {
successEvent: AnalyticsEvent;
failureEvent: AnalyticsEvent;
trackingCtx: TrackingContext;
}
spinner.fail('Timed out');
throw new Error(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
async function getBuildsSafelyAsync(buildIds: string[]): Promise<MaybeBuildFragment[]> {
const promises = buildIds.map(async buildId => {
try {
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
Log.debug('Failed to fetch the build status', err);
return null;
}
});
return await Promise.all(promises);
}
interface BuildProgressResult {
refetch: boolean;
}
let queueProgressBarStarted = false;
const queueProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}| {estimatedWaitTime}' },
cliProgress.Presets.rect
);
async function handleSingleBuildProgressAsync(
{ build, accountName }: { build: MaybeBuildFragment; accountName: string },
{ spinner }: { spinner: Ora }
): Promise<BuildProgressResult> {
if (build === null) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
return { refetch: true };
}
if (queueProgressBarStarted && build?.status && build.status !== BuildStatus.InQueue) {
if (build.status === BuildStatus.InProgress) {
queueProgressBar.update(queueProgressBar.getTotal(), {
estimatedWaitTime: '',
});
}
queueProgressBar.stop();
Log.newLine();
queueProgressBarStarted = false;
spinner.start('Build is about to start');
}
switch (build.status) {
case BuildStatus.Finished:
spinner.succeed('Build finished');
return { refetch: false };
case BuildStatus.New:
spinner.text = `Build is waiting to enter the queue. Check your concurrency limit at ${chalk.underline(
formatAccountSubscriptionsUrl(accountName)
)}.`;
break;
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
const progressBarPayload =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
: { estimatedWaitTime: '' };
if (
!queueProgressBarStarted &&
typeof build.initialQueuePosition === 'number' &&
typeof build.queuePosition === 'number'
) {
spinner.stopAndPersist();
if (build.priority !== BuildPriority.High) {
Log.newLine();
Log.log('Start builds sooner in the priority queue.');
Log.log(
`Sign up for EAS Production or Enterprise at ${chalk.underline(
formatAccountSubscriptionsUrl(accountName)
)}`
);
}
Log.newLine();
Log.log('Waiting in queue');
queueProgressBar.start(
build.initialQueuePosition + 1,
build.initialQueuePosition - build.queuePosition + 1,
progressBarPayload
);
queueProgressBarStarted = true;
}
if (typeof build.queuePosition === 'number') {
queueProgressBar.update(build.queuePosition, progressBarPayload);
}
break;
}
case BuildStatus.Canceled:
spinner.fail('Build canceled');
return { refetch: false };
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
break;
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
return { refetch: false };
} else {
throw new Error('Standalone build failed!');
}
default:
spinner.warn('Unknown status');
throw new Error(`Unknown build status: ${build.status} - aborting!`);
}
return { refetch: true };
}
const statusToDisplayName: Record<BuildStatus, string> = {
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
[BuildStatus.InQueue]: 'in queue',
[BuildStatus.InProgress]: 'in progress',
[BuildStatus.Canceled]: 'canceled',
[BuildStatus.Finished]: 'finished',
[BuildStatus.Errored]: 'failed',
};
const platforms = [AppPlatform.Android, AppPlatform.Ios];
async function handleMultipleBuildsProgressAsync(
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
{ spinner }: { spinner: Ora }
): Promise<BuildProgressResult> {
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
const allFinished =
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
const allSettled =
builds.filter(build =>
[BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(build.status)
).length === buildCount;
if (allSettled) {
if (allFinished) {
spinner.succeed(formatSettledBuildsText(builds));
} else {
spinner.fail(formatSettledBuildsText(builds));
}
return { refetch: false };
} else {
spinner.text = formatPendingBuildsText(builds);
return { refetch: true };
): Promise<Result> {
try {
const result = await fn();
Analytics.logEvent(analytics.successEvent, analytics.trackingCtx);
return result;
} catch (error) {
Analytics.logEvent(analytics.failureEvent, {
...analytics.trackingCtx,
reason: error.message,
});
throw error;
}
}
function formatSettledBuildsText(builds: BuildFragment[]): string {
return platforms
.map(platform => {
const build = nullthrows(
builds.find(build => build.platform === platform),
`Build for platform ${platform} must be defined in this context`
);
return `${appPlatformEmojis[platform]} ${
appPlatformDisplayNames[platform]
} build - status: ${chalk.bold(statusToDisplayName[build.status])}`;
})
.join('\n ');
}
function formatPendingBuildsText(builds: BuildFragment[]): string {
return platforms
.map(platform => {
const build = builds.find(build => build.platform === platform);
const status = build ? statusToDisplayName[build.status] : 'unknown';
let extraInfo = '';
if (
build?.status === BuildStatus.InQueue &&
typeof build.initialQueuePosition === 'number' &&
typeof build.queuePosition === 'number'
) {
const percent = Math.floor(
((build.initialQueuePosition - build.queuePosition + 1) /
(build.initialQueuePosition + 1)) *
100
);
const estimatedWaitTime =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? ` - ${formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds)}`
: '';
extraInfo = ` - queue progress: ${chalk.bold(`${percent}%`)}${estimatedWaitTime}`;
}
return `${appPlatformEmojis[platform]} ${
appPlatformDisplayNames[platform]
} build - status: ${chalk.bold(status)}${extraInfo}`;
})
.join('\n ');
}
function isBuildFragment(maybeBuild: MaybeBuildFragment): maybeBuild is BuildFragment {
return maybeBuild !== null;
}
function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
if (estimatedWaitTimeLeftSeconds < 5 * 60) {
return 'starting soon...';
} else {
const n = Math.floor(estimatedWaitTimeLeftSeconds / (10 * 60)) + 1;
return `starting in about ${n}0 minutes...`;
}
}
function formatAccountSubscriptionsUrl(accountName: string): string {
return new URL(
`/accounts/${accountName}/settings/subscriptions`,
getExpoWebsiteBaseUrl()
).toString();
}
-106
View File
@@ -1,106 +0,0 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import Log from '../log';
import { resolveWorkflowAsync } from '../project/workflow';
import { easCliVersion } from '../utils/easCli';
import { getVcsClient } from '../vcs';
import { maybeBailOnRepoStatusAsync, reviewAndCommitChangesAsync } from './utils/repository';
interface ConfigureParams {
projectDir: string;
nonInteractive: boolean;
}
/**
* Creates eas.json if it does not exist.
*
* Returns:
* - false - if eas.json already exists
* - true - if eas.json was created by the function
*/
export async function ensureProjectConfiguredAsync(
configureParams: ConfigureParams
): Promise<boolean> {
if (await fs.pathExists(EasJsonReader.formatEasJsonPath(configureParams.projectDir))) {
return false;
}
await configureAsync(configureParams);
return true;
}
async function configureAsync({ projectDir, nonInteractive }: ConfigureParams): Promise<void> {
await maybeBailOnRepoStatusAsync();
await createEasJsonAsync(projectDir);
if (await getVcsClient().isCommitRequiredAsync()) {
Log.newLine();
await reviewAndCommitChangesAsync('Configure EAS Build', {
nonInteractive,
});
}
}
const EAS_JSON_MANAGED_DEFAULT: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
},
preview: {
distribution: 'internal',
},
production: {},
},
submit: {
production: {},
},
};
const EAS_JSON_BARE_DEFAULT: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
},
build: {
development: {
distribution: 'internal',
android: {
gradleCommand: ':app:assembleDebug',
},
ios: {
buildConfiguration: 'Debug',
},
},
preview: {
distribution: 'internal',
},
production: {},
},
submit: {
production: {},
},
};
async function createEasJsonAsync(projectDir: string): Promise<void> {
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
const hasAndroidNativeProject =
(await resolveWorkflowAsync(projectDir, Platform.ANDROID)) === Workflow.GENERIC;
const hasIosNativeProject =
(await resolveWorkflowAsync(projectDir, Platform.IOS)) === Workflow.GENERIC;
const easJson =
hasAndroidNativeProject || hasIosNativeProject
? EAS_JSON_BARE_DEFAULT
: EAS_JSON_MANAGED_DEFAULT;
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await getVcsClient().trackFileAsync(easJsonPath);
Log.withTick(`Generated ${chalk.bold('eas.json')}`);
}
+6
View File
@@ -0,0 +1,6 @@
import { Platform } from '@expo/eas-build-job';
export const platformDisplayNames = {
[Platform.iOS]: 'iOS',
[Platform.Android]: 'Android',
};
+93 -38
View File
@@ -1,47 +1,102 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@eas/config';
import { ExpoConfig, getConfig } from '@expo/config';
import { TrackingContext } from '../analytics/common';
import { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types';
import { GradleBuildContext } from '../project/android/gradle';
import { XcodeBuildContext } from '../project/ios/scheme';
import { Actor } from '../user/User';
import { LocalBuildOptions } from './local';
import { getProjectAccountNameAsync } from '../project/projectUtils';
import { User } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { BuildCommandPlatform, Platform, TrackingContext } from './types';
export type CommonContext<T extends Platform> = Omit<BuildContext<T>, 'android' | 'ios'>;
export interface AndroidBuildContext {
applicationId: string;
gradleContext?: GradleBuildContext;
export interface CommandContext {
requestedPlatform: BuildCommandPlatform;
profile: string;
projectDir: string;
user: User;
accountName: string;
projectName: string;
exp: ExpoConfig;
trackingCtx: TrackingContext;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}
export interface IosBuildContext {
bundleIdentifier: string;
applicationTargetBuildSettings: XCBuildConfiguration['buildSettings'];
applicationTarget: Target;
targets: Target[];
xcodeBuildContext: XcodeBuildContext;
export async function createCommandContextAsync({
requestedPlatform,
profile,
projectDir,
trackingCtx,
nonInteractive = false,
skipCredentialsCheck = false,
skipProjectConfiguration = false,
waitForBuildEnd,
}: {
requestedPlatform: BuildCommandPlatform;
profile: string;
projectDir: string;
trackingCtx: TrackingContext;
nonInteractive: boolean;
skipCredentialsCheck: boolean;
skipProjectConfiguration: boolean;
waitForBuildEnd: boolean;
}): Promise<CommandContext> {
const user = await ensureLoggedInAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
const accountName = await getProjectAccountNameAsync(projectDir);
const projectName = exp.slug;
return {
requestedPlatform,
profile,
projectDir,
user,
accountName,
projectName,
exp,
trackingCtx,
nonInteractive,
skipCredentialsCheck,
skipProjectConfiguration,
waitForBuildEnd,
};
}
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
? AndroidBuildProfile
: iOSBuildProfile;
export interface BuildContext<T extends Platform> {
accountName: string;
buildProfile: BuildProfile<T>;
buildProfileName: string;
clearCache: boolean;
credentialsCtx: CredentialsContext;
exp: ExpoConfig;
localBuildOptions: LocalBuildOptions;
nonInteractive: boolean;
platform: T;
projectDir: string;
projectId: string;
projectName: string;
commandCtx: CommandContext;
trackingCtx: TrackingContext;
user: Actor;
workflow: Workflow;
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
ios: T extends Platform.IOS ? IosBuildContext : undefined;
platform: T;
projectId: string;
buildProfile: PlatformBuildProfile<T>;
}
export function createBuildContext<T extends Platform>({
platform,
easConfig,
commandCtx,
projectId,
}: {
platform: T;
easConfig: EasConfig;
commandCtx: CommandContext;
projectId: string;
}): BuildContext<T> {
const buildProfile = easConfig.builds[platform] as PlatformBuildProfile<T> | undefined;
if (!buildProfile) {
throw new Error(`${platform} build profile does not exist`);
}
const builderTrackingCtx = {
...commandCtx.trackingCtx,
platform,
};
return {
commandCtx,
trackingCtx: builderTrackingCtx,
platform,
buildProfile,
projectId,
};
}
+147
View File
@@ -0,0 +1,147 @@
import { EasJsonReader } from '@eas/config';
import chalk from 'chalk';
import ora from 'ora';
import { apiClient } from '../api';
import log from '../log';
import { ensureProjectExistsAsync } from '../project/ensureProjectExists';
import { sleep } from '../utils/promise';
import { startAndroidBuildAsync } from './android/build';
import { CommandContext } from './context';
import { startIosBuildAsync } from './ios/build';
import { Build, BuildCommandPlatform, BuildStatus } from './types';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureGitRepoExistsAsync, ensureGitStatusIsCleanAsync } from './utils/repository';
export async function buildAsync(commandCtx: CommandContext): Promise<void> {
await ensureGitRepoExistsAsync();
await ensureGitStatusIsCleanAsync();
const projectId = await ensureProjectExistsAsync({
accountName: commandCtx.accountName,
projectName: commandCtx.projectName,
privacy: commandCtx.exp.privacy,
});
const scheduledBuilds = await startBuildsAsync(commandCtx, projectId);
log.newLine();
await printLogsUrls(commandCtx.accountName, scheduledBuilds);
log.newLine();
if (commandCtx.waitForBuildEnd) {
const builds = await waitForBuildEndAsync(
commandCtx,
projectId,
scheduledBuilds.map(i => i.buildId)
);
printBuildResults(builds);
}
}
async function startBuildsAsync(
commandCtx: CommandContext,
projectId: string
): Promise<
{ platform: BuildCommandPlatform.ANDROID | BuildCommandPlatform.IOS; buildId: string }[]
> {
const shouldBuildAndroid = [BuildCommandPlatform.ANDROID, BuildCommandPlatform.ALL].includes(
commandCtx.requestedPlatform
);
const shouldBuildiOS = [BuildCommandPlatform.IOS, BuildCommandPlatform.ALL].includes(
commandCtx.requestedPlatform
);
const easConfig = await new EasJsonReader(
commandCtx.projectDir,
commandCtx.requestedPlatform
).readAsync(commandCtx.profile);
const scheduledBuilds: {
platform: BuildCommandPlatform.ANDROID | BuildCommandPlatform.IOS;
buildId: string;
}[] = [];
if (shouldBuildAndroid) {
const buildId = await startAndroidBuildAsync(commandCtx, easConfig, projectId);
scheduledBuilds.push({ platform: BuildCommandPlatform.ANDROID, buildId });
}
if (shouldBuildiOS) {
const buildId = await startIosBuildAsync(commandCtx, easConfig, projectId);
scheduledBuilds.push({ platform: BuildCommandPlatform.IOS, buildId });
}
return scheduledBuilds;
}
async function waitForBuildEndAsync(
commandCtx: CommandContext,
projectId: string,
buildIds: string[],
{ timeoutSec = 1800, intervalSec = 30 } = {}
): Promise<(Build | null)[]> {
log('Waiting for build to complete. You can press Ctrl+C to exit.');
const spinner = ora().start();
let time = new Date().getTime();
const endTime = time + timeoutSec * 1000;
while (time <= endTime) {
const builds: (Build | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
const { data } = await apiClient.get(`projects/${projectId}/builds/${buildId}`).json();
return data;
} catch (err) {
return null;
}
})
);
if (builds.length === 1) {
switch (builds[0]?.status) {
case BuildStatus.FINISHED:
spinner.succeed('Build finished.');
return builds;
case BuildStatus.IN_QUEUE:
spinner.text = 'Build queued...';
break;
case BuildStatus.IN_PROGRESS:
spinner.text = 'Build in progress...';
break;
case BuildStatus.ERRORED:
spinner.fail('Build failed.');
throw new Error(`Standalone build failed!`);
default:
spinner.warn('Unknown status.');
throw new Error(`Unknown status: ${builds} - aborting!`);
}
} else {
if (builds.filter(build => build?.status === BuildStatus.FINISHED).length === builds.length) {
spinner.succeed('All build have finished.');
return builds;
} else if (
builds.filter(build =>
build?.status ? [BuildStatus.FINISHED, BuildStatus.ERRORED].includes(build.status) : false
).length === builds.length
) {
spinner.fail('Some of the builds failed.');
return builds;
} else {
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
const unknownState = builds.length - inQueue - inProgress - errored - finished;
spinner.text = [
inQueue && `Builds in queue: ${inQueue}`,
inProgress && `Builds in progress: ${inProgress}`,
errored && chalk.red(`Builds failed: ${errored}`),
finished && chalk.green(`Builds finished: ${finished}`),
unknownState && chalk.red(`Builds in unknown state: ${unknownState}`),
]
.filter(i => i)
.join('\t');
}
}
time = new Date().getTime();
await sleep(intervalSec * 1000);
}
spinner.warn('Timed out.');
throw new Error(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
-136
View File
@@ -1,136 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
import { TrackingContext } from '../analytics/common';
import { Analytics, BuildEvent } from '../analytics/events';
import { CredentialsContext } from '../credentials/context';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
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';
import { LocalBuildOptions } from './local';
export async function createBuildContextAsync<T extends Platform>({
buildProfileName,
buildProfile,
clearCache = false,
localBuildOptions,
nonInteractive = false,
platform,
projectDir,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
clearCache: boolean;
localBuildOptions: LocalBuildOptions;
nonInteractive: boolean;
platform: T;
projectDir: string;
}): Promise<BuildContext<T>> {
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
const user = await ensureLoggedInAsync();
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
const projectId = await getProjectIdAsync(exp, { env: buildProfile.env });
const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = findAccountByName(user.accounts, accountName)?.id;
const credentialsCtx = new CredentialsContext({
exp,
nonInteractive,
projectDir,
user,
env: buildProfile.env,
});
const devClientProperties = getDevClientEventProperties({
platform,
projectDir,
buildProfile,
});
const trackingCtx = {
tracking_id: uuidv4(),
platform,
...(accountId && { account_id: accountId }),
project_id: projectId,
project_type: workflow,
...devClientProperties,
};
Analytics.logEvent(BuildEvent.BUILD_COMMAND, trackingCtx);
const commonContext: CommonContext<T> = {
accountName,
buildProfile,
buildProfileName,
clearCache,
credentialsCtx,
exp,
localBuildOptions,
nonInteractive,
platform,
projectDir,
projectId,
projectName,
trackingCtx,
user,
workflow,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
return {
...common,
android: await createAndroidContextAsync(common),
} as BuildContext<T>;
} else {
const common = commonContext as CommonContext<Platform.IOS>;
return {
...common,
ios: await createIosContextAsync(common),
} as BuildContext<T>;
}
}
function getDevClientEventProperties({
platform,
projectDir,
buildProfile,
}: {
platform: Platform;
projectDir: string;
buildProfile: BuildProfile;
}): Partial<TrackingContext> {
let includesDevClient;
const version = tryGetDevClientVersion(projectDir);
if (platform === Platform.ANDROID && 'gradleCommand' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.gradleCommand?.includes('Debug'));
} else if (platform === Platform.IOS && 'buildConfiguration' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.buildConfiguration === 'Debug');
} else if (buildProfile.developmentClient) {
includesDevClient = true;
} else {
includesDevClient = false;
}
if (version) {
return { dev_client: includesDevClient, dev_client_version: version };
} else {
return { dev_client: includesDevClient };
}
}
function tryGetDevClientVersion(projectDir: string): string | null {
try {
const pkg = JsonFile.read(resolveFrom(projectDir, 'expo-dev-client/package.json'));
return pkg.version?.toString() ?? null;
} catch {
return null;
}
}
+100
View File
@@ -0,0 +1,100 @@
import { CredentialsSource, Workflow } from '@eas/config';
import chalk from 'chalk';
import { CredentialsProvider } from '../credentials/CredentialsProvider';
import log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
import { platformDisplayNames } from './constants';
const USING_CREDENTIALS_JSON_MSG = 'Using credentials from the local credentials.json file';
const USING_REMOTE_CREDENTIALS_MSG = 'Using credentials stored on the Expo servers';
async function ensureCredentialsAutoAsync(
provider: CredentialsProvider,
workflow: Workflow,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
const platform = platformDisplayNames[provider.platform];
switch (workflow) {
case Workflow.Managed:
if (await provider.hasLocalAsync()) {
return CredentialsSource.LOCAL;
} else {
return CredentialsSource.REMOTE;
}
case Workflow.Generic: {
const hasLocal = await provider.hasLocalAsync();
const hasRemote = await provider.hasRemoteAsync();
if (hasRemote && hasLocal) {
if (!(await provider.isLocalSyncedAsync())) {
if (nonInteractive) {
throw new Error(
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers. To use the desired credentials, set the "builds.${platform}.{profile}.credentialsSource" field in the credentials.json file to one of the following: "local", "remote".`
);
} else {
log(
`Contents of your local credentials.json for ${platform} are not the same as credentials on Expo servers`
);
}
const { select } = await promptAsync({
type: 'select',
name: 'select',
message: 'Which credentials you want to use for this build?',
choices: [
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
{ title: 'Credentials stored on Expo servers.', value: CredentialsSource.REMOTE },
],
});
return select;
} else {
return CredentialsSource.LOCAL;
}
} else if (hasLocal) {
log(chalk.bold(USING_CREDENTIALS_JSON_MSG));
return CredentialsSource.LOCAL;
} else if (hasRemote) {
log(chalk.bold(USING_REMOTE_CREDENTIALS_MSG));
return CredentialsSource.REMOTE;
} else {
if (nonInteractive) {
throw new Error(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}. Either configure credentials.json, or launch the build without "--non-interactive" flag to get a prompt to generate credentials automatically.`
);
} else {
log.warn(
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
);
}
const confirm = await confirmAsync({
message: 'Do you want to generate new credentials?',
});
if (confirm) {
return CredentialsSource.REMOTE;
} else {
throw new Error(`Aborting build process, credentials are not configured for ${platform}`);
}
}
}
}
}
export async function ensureCredentialsAsync(
provider: CredentialsProvider,
workflow: Workflow,
src: CredentialsSource,
nonInteractive: boolean
): Promise<CredentialsSource.LOCAL | CredentialsSource.REMOTE> {
switch (src) {
case CredentialsSource.LOCAL:
log(chalk.bold(USING_CREDENTIALS_JSON_MSG));
return CredentialsSource.LOCAL;
case CredentialsSource.REMOTE:
log(chalk.bold(USING_REMOTE_CREDENTIALS_MSG));
return CredentialsSource.REMOTE;
case CredentialsSource.AUTO:
log('Resolving credentials source (auto mode)');
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
}
}
-78
View File
@@ -1,78 +0,0 @@
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
import {
BuildCredentialsSource,
BuildIosEnterpriseProvisioning,
BuildMetadataInput,
BuildWorkflow,
DistributionType,
ProjectArchiveSourceInput,
ProjectArchiveSourceType,
} from '../graphql/generated';
export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput {
if (archiveSource.type === ArchiveSourceType.S3) {
return {
type: ProjectArchiveSourceType.S3,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.URL) {
return {
type: ProjectArchiveSourceType.Url,
url: archiveSource.url,
};
} else {
throw new Error(`Unsupported project archive source type: '${archiveSource.type}'`);
}
}
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
return {
...metadata,
credentialsSource:
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
distribution: metadata.distribution && transformDistribution(metadata.distribution),
workflow: metadata.workflow && transformWorkflow(metadata.workflow),
iosEnterpriseProvisioning:
metadata.iosEnterpriseProvisioning &&
transformIosEnterpriseProvisioning(metadata.iosEnterpriseProvisioning),
};
}
function transformCredentialsSource(
credentialsSource: Metadata['credentialsSource']
): BuildCredentialsSource {
if (credentialsSource === 'local') {
return BuildCredentialsSource.Local;
} else {
return BuildCredentialsSource.Remote;
}
}
function transformDistribution(distribution: Metadata['distribution']): DistributionType {
if (distribution === 'internal') {
return DistributionType.Internal;
} else if (distribution === 'simulator') {
return DistributionType.Simulator;
} else {
return DistributionType.Store;
}
}
export function transformWorkflow(workflow: Workflow): BuildWorkflow {
if (workflow === Workflow.GENERIC) {
return BuildWorkflow.Generic;
} else {
return BuildWorkflow.Managed;
}
}
export function transformIosEnterpriseProvisioning(
enterpriseProvisioning: Metadata['iosEnterpriseProvisioning']
): BuildIosEnterpriseProvisioning {
if (enterpriseProvisioning === 'adhoc') {
return BuildIosEnterpriseProvisioning.Adhoc;
} else {
return BuildIosEnterpriseProvisioning.Universal;
}
}
@@ -1,344 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { readPlistAsync } from '../../../utils/plist';
import {
BumpStrategy,
bumpVersionAsync,
bumpVersionInAppJsonAsync,
evaluateTemplateString,
getInfoPlistPath,
readBuildNumberAsync,
readShortVersionAsync,
} from '../version';
jest.mock('fs');
afterAll(async () => {
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
await fs.remove(os.tmpdir());
});
beforeEach(async () => {
vol.reset();
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
await fs.mkdirp(os.tmpdir());
});
describe(evaluateTemplateString, () => {
it('evaluates the template string when value is a number', () => {
expect(evaluateTemplateString('$(BLAH_BLAH)', { BLAH_BLAH: 123 })).toBe('123');
});
it('evaluates the template string when value is a string', () => {
expect(evaluateTemplateString('$(BLAH_BLAH)', { BLAH_BLAH: '123' })).toBe('123');
});
it('evaluates the template string when template is not the only element', () => {
expect(evaluateTemplateString('before$(BLAH_BLAH)after', { BLAH_BLAH: '123' })).toBe(
'before123after'
);
});
it('evaluates the template string when template value is double quoted', () => {
expect(evaluateTemplateString('before$(BLAH_BLAH)after', { BLAH_BLAH: '"123"' })).toBe(
'before123after'
);
});
});
// bare workflow
describe(bumpVersionAsync, () => {
it('bumps expo.ios.buildNumber and CFBundleVersion when strategy = BumpStrategy.BUILD_NUMBER', async () => {
const fakeExp = initBareWorkflowProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
expect(infoPlist['CFBundleVersion']).toBe('2');
});
it('bumps expo.ios.buildNumber and CFBundleVersion for non default Info.plist location', async () => {
const fakeExp = initBareWorkflowProject({ infoPlistName: 'Info2.plist' });
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
},
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info2.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
expect(infoPlist['CFBundleVersion']).toBe('2');
});
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initBareWorkflowProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
exp: fakeExp,
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1');
expect(appJSON.expo.ios.buildNumber).toBe('1');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.1');
expect(infoPlist['CFBundleVersion']).toBe('1');
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initBareWorkflowProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('1');
expect(infoPlist['CFBundleShortVersionString']).toBe('1.0.0');
expect(infoPlist['CFBundleVersion']).toBe('1');
});
});
// managed workflow
describe(bumpVersionInAppJsonAsync, () => {
it('bumps expo.ios.buildNumber when strategy = BumpStrategy.BUILD_NUMBER', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('2');
});
it('bumps expo.version when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1');
expect(appJSON.expo.ios.buildNumber).toBe('1');
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initManagedProject();
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0');
expect(appJSON.expo.ios.buildNumber).toBe('1');
});
});
describe(readBuildNumberAsync, () => {
describe('bare project', () => {
it('reads the build number from native code', async () => {
const exp = initBareWorkflowProject();
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
expect(buildNumber).toBe('1');
});
});
describe('managed project', () => {
it('reads the build number from expo config', async () => {
const exp = initManagedProject();
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
expect(buildNumber).toBe('1');
});
});
});
describe(readShortVersionAsync, () => {
describe('bare project', () => {
it('reads the short version from native code', async () => {
const exp = initBareWorkflowProject();
const appVersion = await readShortVersionAsync('/repo', exp, {});
expect(appVersion).toBe('1.0.0');
});
it('evaluates interpolated build number', async () => {
const exp = initBareWorkflowProject({
appVersion: '$(CURRENT_PROJECT_VERSION)',
});
const buildNumber = await readShortVersionAsync('/repo', exp, {
CURRENT_PROJECT_VERSION: '1.0.0',
});
expect(buildNumber).toBe('1.0.0');
});
});
describe('managed project', () => {
it('reads the version from app config', async () => {
const exp = initBareWorkflowProject();
const appVersion = await readShortVersionAsync('/repo', exp, {});
expect(appVersion).toBe('1.0.0');
});
});
});
describe(getInfoPlistPath, () => {
it('returns default path if INFOPLIST_FILE is not specified', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
);
const plistPath = getInfoPlistPath('/repo', {});
expect(plistPath).toBe('/repo/ios/testapp/Info.plist');
});
it('returns INFOPLIST_FILE if specified', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
);
const plistPath = getInfoPlistPath('/repo', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
});
it('evaluates SRCROOT in Info.plist', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
);
const plistPath = getInfoPlistPath('/repo', {
INFOPLIST_FILE: '$(SRCROOT)/qwert/NotInfo.plist',
});
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
});
it('evaluates BuildSettings in Info.plist', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
);
const plistPath = getInfoPlistPath('/repo', {
INFOPLIST_FILE: '$(SRCROOT)/qwert/$(TARGET_NAME).plist',
TARGET_NAME: 'NotInfo',
});
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
});
});
function initBareWorkflowProject({
appVersion = '1.0.0',
version = '1',
infoPlistName = 'Info.plist',
}: { appVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '1.0.0',
ios: {
buildNumber: '1',
},
};
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: fakeExp,
}),
[`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>${appVersion}</string>
<key>CFBundleVersion</key>
<string>${version}</string>
</dict>
</plist>`,
},
'/repo'
);
return fakeExp;
}
function initManagedProject(): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '1.0.0',
ios: {
buildNumber: '1',
},
};
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: fakeExp,
}),
},
'/repo'
);
return fakeExp;
}
+103 -100
View File
@@ -1,111 +1,114 @@
import { IOSConfig } from '@expo/config-plugins';
import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import type { XCBuildConfiguration } from 'xcode';
import { EasConfig, Workflow } from '@eas/config';
import { IOSConfig } from '@expo/config';
import assert from 'assert';
import chalk from 'chalk';
import sortBy from 'lodash/sortBy';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommonContext, IosBuildContext } from '../context';
import { transformMetadata } from '../graphql';
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
import { ensureIosCredentialsAsync } from './credentials';
import { transformJob } from './graphql';
import { createCredentialsContextAsync } from '../../credentials/context';
import IosCredentialsProvider, {
IosCredentials,
} from '../../credentials/ios/IosCredentialsProvider';
import log from '../../log';
import { promptAsync } from '../../prompts';
import { CredentialsResult, startBuildForPlatformAsync } from '../build';
import { BuildContext, CommandContext, createBuildContext } from '../context';
import { ensureCredentialsAsync } from '../credentials';
import { Platform } from '../types';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
export async function createIosContextAsync(
ctx: CommonContext<Platform.IOS>
): Promise<IosBuildContext> {
const { buildProfile } = ctx;
export async function startIosBuildAsync(
commandCtx: CommandContext,
easConfig: EasConfig,
projectId: string
): Promise<string> {
const buildCtx = createBuildContext<Platform.iOS>({
commandCtx,
platform: Platform.iOS,
easConfig,
projectId,
});
if (ctx.workflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
let iosNativeProjectScheme: string | undefined;
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
iosNativeProjectScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
}
return await startBuildForPlatformAsync({
ctx: buildCtx,
projectConfiguration: {
iosNativeProjectScheme,
},
ensureCredentialsAsync: ensureIosCredentialsAsync,
ensureProjectConfiguredAsync: async () => {},
prepareJobAsync,
});
}
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
return (
(ctx.buildProfile.workflow === Workflow.Managed &&
ctx.buildProfile.buildType !== 'simulator') ||
ctx.buildProfile.workflow === Workflow.Generic
);
}
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
const schemes = IOSConfig.Scheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
if (schemes.length === 1) {
return schemes[0];
}
checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{
projectDir: ctx.projectDir,
nonInteractive: ctx.nonInteractive,
exp: ctx.exp,
},
buildProfile
const sortedSchemes = sortBy(schemes);
log.newLine();
log(
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
);
const targets = await resolveTargetsAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
xcodeBuildContext,
env: buildProfile.env,
});
const applicationTarget = findApplicationTarget(targets);
const applicationTargetBuildSettings = resolveBuildSettings(ctx, applicationTarget);
log(
`You can specify the scheme you want to build at ${chalk.bold(
'builds.ios.PROFILE_NAME.scheme'
)} in eas.json.`
);
if (ctx.commandCtx.nonInteractive) {
const withoutTvOS = sortedSchemes.filter(i => !i.includes('tvOS'));
const scheme = withoutTvOS.length > 0 ? withoutTvOS[0] : sortedSchemes[0];
log(`You've run Expo CLI in non-interactive mode, choosing the ${chalk.bold(scheme)} scheme.`);
log.newLine();
return scheme;
} else {
const { selectedScheme } = await promptAsync({
type: 'select',
name: 'selectedScheme',
message: 'Which scheme would you like to build now?',
choices: sortedSchemes.map(scheme => ({ title: scheme, value: scheme })),
});
log.newLine();
return selectedScheme as string;
}
}
async function ensureIosCredentialsAsync(
ctx: BuildContext<Platform.iOS>
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
}
assert(ctx.commandCtx.exp?.ios?.bundleIdentifier, 'ios.bundleIdentifier is required');
const provider = new IosCredentialsProvider(
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {}),
{
projectName: ctx.commandCtx.projectName,
accountName: ctx.commandCtx.accountName,
bundleIdentifier: ctx.commandCtx.exp?.ios?.bundleIdentifier,
},
{ nonInteractive: ctx.commandCtx.nonInteractive }
);
const credentialsSource = await ensureCredentialsAsync(
provider,
ctx.buildProfile.workflow,
ctx.buildProfile.credentialsSource,
ctx.commandCtx.nonInteractive
);
return {
bundleIdentifier: applicationTarget.bundleIdentifier,
applicationTarget,
applicationTargetBuildSettings,
targets,
xcodeBuildContext,
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
export async function prepareIosBuildAsync(
ctx: BuildContext<Platform.IOS>
): Promise<BuildRequestSender> {
return await prepareBuildRequestForPlatformAsync({
ctx,
ensureCredentialsAsync: async (ctx: BuildContext<Platform.IOS>) => {
return ensureIosCredentialsAsync(ctx, ctx.ios.targets);
},
syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
buildProfile: ctx.buildProfile,
buildSettings: ctx.ios.applicationTargetBuildSettings,
});
},
prepareJobAsync: async (
ctx: BuildContext<Platform.IOS>,
jobData: JobData<IosCredentials>
): Promise<Job> => {
return await prepareJobAsync(ctx, {
...jobData,
buildScheme: ctx.ios.xcodeBuildContext.buildScheme,
});
},
sendBuildRequestAsync: async (
appId: string,
job: Ios.Job,
metadata: Metadata
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createIosBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
});
},
});
}
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,36 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
import { logCredentialsSource } from '../utils/credentials';
export async function ensureIosCredentialsAsync(
buildCtx: BuildContext<Platform.IOS>,
targets: Target[]
): Promise<CredentialsResult<IosCredentials> | undefined> {
if (!shouldProvideCredentials(buildCtx)) {
return;
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: getAppFromContext(buildCtx.credentialsCtx),
targets,
distribution: buildCtx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
});
const { credentialsSource } = buildCtx.buildProfile;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
return !buildCtx.buildProfile.simulator;
}
-47
View File
@@ -1,47 +0,0 @@
import { Env, Ios } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated';
import { transformProjectArchive, transformWorkflow } from '../graphql';
export function transformJob(job: Ios.Job): IosJobInput {
return {
type: transformWorkflow(job.type),
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
scheme: job.scheme,
buildConfiguration: job.buildConfiguration,
artifactPath: job.artifactPath,
username: job.username,
developmentClient: job.developmentClient,
simulator: job.simulator,
experimental: job.experimental,
};
}
function transformIosSecrets(secrets: {
buildCredentials?: Ios.BuildCredentials;
env?: Env;
}): IosJobSecretsInput {
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
for (const targetName of Object.keys(secrets.buildCredentials ?? {})) {
buildCredentials.push({
targetName,
provisioningProfileBase64: nullthrows(secrets.buildCredentials?.[targetName])
.provisioningProfileBase64,
distributionCertificate: nullthrows(secrets.buildCredentials?.[targetName])
.distributionCertificate,
});
}
return {
buildCredentials,
environmentSecrets: secrets.env,
};
}
+84 -66
View File
@@ -1,84 +1,102 @@
import { ArchiveSource, Ios, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
import { Workflow, iOSGenericBuildProfile, iOSManagedBuildProfile } from '@eas/config';
import { BuildType, Job, iOS, sanitizeJob } from '@expo/eas-build-job';
import path from 'path';
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 { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
import { IosCredentials } from '../../credentials/ios/IosCredentialsProvider';
import { gitRootDirectoryAsync } from '../../utils/git';
import { BuildContext } from '../context';
import { Platform } from '../types';
interface CommonJobProperties {
platform: Platform.iOS;
projectUrl: string;
secrets: {
buildCredentials?: {
provisioningProfileBase64: string;
distributionCertificate: {
dataBase64: string;
password: string;
};
};
secretEnvs?: Record<string, string>;
};
}
interface JobData {
projectArchive: ArchiveSource;
archiveUrl: string;
credentials?: IosCredentials;
buildScheme: string;
projectConfiguration: {
iosNativeProjectScheme?: string;
};
}
const cacheDefaults = {
disabled: false,
customPaths: [],
cacheDefaultPaths: true,
};
export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
ctx: BuildContext<Platform.iOS>,
jobData: JobData
): Promise<Job> {
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
if (jobData.credentials) {
const targetNames = Object.keys(jobData.credentials);
for (const targetName of targetNames) {
buildCredentials[targetName] = prepareTargetCredentials(jobData.credentials[targetName]);
}
if (ctx.buildProfile.workflow === Workflow.Generic) {
const partialJob = await prepareGenericJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else if (ctx.buildProfile.workflow === Workflow.Managed) {
const partialJob = await prepareManagedJobAsync(ctx, jobData, ctx.buildProfile);
return sanitizeJob(partialJob);
} else {
throw new Error("Unknown workflow. Shouldn't happen");
}
const job: Ios.Job = {
type: ctx.workflow,
platform: Platform.IOS,
projectArchive: jobData.projectArchive,
projectRootDirectory,
builderEnvironment: {
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
yarn: ctx.buildProfile.yarn,
bundler: ctx.buildProfile.bundler,
cocoapods: ctx.buildProfile.cocoapods,
fastlane: ctx.buildProfile.fastlane,
expoCli: ctx.buildProfile.expoCli,
env: ctx.buildProfile.env,
},
cache: {
...cacheDefaults,
...ctx.buildProfile.cache,
clear: ctx.clearCache,
},
secrets: {
buildCredentials,
},
releaseChannel: ctx.buildProfile.releaseChannel,
updates: { channel: ctx.buildProfile.channel },
developmentClient: ctx.buildProfile.developmentClient,
simulator: ctx.buildProfile.simulator,
scheme: jobData.buildScheme,
buildConfiguration: ctx.buildProfile.buildConfiguration,
artifactPath: ctx.buildProfile.artifactPath,
username,
experimental: {
prebuildCommand: ctx.buildProfile.prebuildCommand,
},
};
return sanitizeJob(job);
}
function prepareTargetCredentials(targetCredentials: TargetCredentials): Ios.TargetCredentials {
async function prepareJobCommonAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData
): Promise<Partial<CommonJobProperties>> {
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
const buildCredentials = jobData.credentials
? {
buildCredentials: {
provisioningProfileBase64: jobData.credentials.provisioningProfile,
distributionCertificate: {
dataBase64: jobData.credentials.distributionCertificate.certP12,
password: jobData.credentials.distributionCertificate.certPassword,
},
},
}
: {};
return {
provisioningProfileBase64: targetCredentials.provisioningProfile,
distributionCertificate: {
dataBase64: targetCredentials.distributionCertificate.certificateP12,
password: targetCredentials.distributionCertificate.certificatePassword,
platform: Platform.iOS,
projectUrl: jobData.archiveUrl,
secrets: {
...(secretEnvs ? { secretEnvs } : {}),
...buildCredentials,
},
};
}
async function prepareGenericJobAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData,
buildProfile: iOSGenericBuildProfile
): Promise<Partial<iOS.GenericJob>> {
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: BuildType.Generic,
scheme: jobData.projectConfiguration.iosNativeProjectScheme,
artifactPath: buildProfile.artifactPath,
projectRootDirectory,
};
}
async function prepareManagedJobAsync(
ctx: BuildContext<Platform.iOS>,
jobData: JobData,
_buildProfile: iOSManagedBuildProfile
): Promise<Partial<iOS.ManagedJob>> {
return {
...(await prepareJobCommonAsync(ctx, jobData)),
type: BuildType.Managed,
packageJson: { example: 'packageJson' },
manifest: { example: 'manifest' },
};
}
@@ -1,46 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, IosVersionAutoIncrement } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
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({
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 { autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, buildSettings });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: IosVersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.BUILD_NUMBER;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'buildNumber') {
return BumpStrategy.BUILD_NUMBER;
} else {
return BumpStrategy.APP_VERSION;
}
}
-218
View File
@@ -1,218 +0,0 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import chalk from 'chalk';
import path from 'path';
import type { XCBuildConfiguration } from 'xcode';
import Log from '../../log';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { readPlistAsync, writePlistAsync } from '../../utils/plist';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync } from '../utils/version';
export enum BumpStrategy {
APP_VERSION,
BUILD_NUMBER,
NOOP,
}
export async function bumpVersionAsync({
bumpStrategy,
projectDir,
exp,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
bumpStrategy: BumpStrategy;
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 writeVersionsToInfoPlistAsync({ projectDir, exp, infoPlist, buildSettings });
Log.log('Synchronized versions with Info.plist');
}
export async function bumpVersionInAppJsonAsync({
bumpStrategy,
projectDir,
exp,
}: {
bumpStrategy: BumpStrategy;
projectDir: string;
exp: ExpoConfig;
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
Log.addNewLineIfNone();
if (bumpStrategy === BumpStrategy.APP_VERSION) {
const appVersion = IOSConfig.Version.getVersion(exp);
await bumpAppVersionAsync({ appVersion, projectDir, exp });
} else {
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
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
)} to ${chalk.bold(bumpedBuildNumber)}`
);
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, buildNumber: String(bumpedBuildNumber) };
});
} else {
Log.log(`${chalk.bold('expo.ios.buildNumber')} = ${chalk.bold(buildNumber)} is not a number`);
const { bumpedBuildNumber } = await promptAsync({
type: 'text',
name: 'bumpedBuildNumber',
message: 'What is the next build number?',
});
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
config.ios = { ...config.ios, buildNumber: String(bumpedBuildNumber) };
});
}
}
}
export async function readShortVersionAsync(
projectDir: string,
exp: ExpoConfig,
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<string | undefined> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) {
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
return (
infoPlist.CFBundleShortVersionString &&
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings)
);
} else {
return exp.version;
}
}
export async function readBuildNumberAsync(
projectDir: string,
exp: ExpoConfig,
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<string | undefined> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) {
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
return (
infoPlist.CFBundleVersion && evaluateTemplateString(infoPlist.CFBundleVersion, buildSettings)
);
} else {
return IOSConfig.Version.getBuildNumber(exp);
}
}
export async function maybeResolveVersionsAsync(
projectDir: string,
exp: ExpoConfig,
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
try {
return {
appBuildVersion: await readBuildNumberAsync(projectDir, exp, buildSettings),
appVersion: await readShortVersionAsync(projectDir, exp, buildSettings),
};
} catch (err: any) {
Log.warn('Failed to read app versions.');
Log.debug(err);
Log.warn(err.message);
Log.warn('Proceeding anyway...');
return {};
}
}
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']
): string {
if (buildSettings.INFOPLIST_FILE) {
const infoPlistFile = buildSettings.INFOPLIST_FILE.startsWith('"')
? buildSettings.INFOPLIST_FILE.slice(1, -1)
: buildSettings.INFOPLIST_FILE;
const iosDir = path.join(projectDir, 'ios');
const plistPath = evaluateTemplateString(infoPlistFile, {
...buildSettings,
SRCROOT: iosDir,
});
return path.isAbsolute(plistPath) ? plistPath : path.resolve(iosDir, plistPath);
} else {
return IOSConfig.Paths.getInfoPlistPath(projectDir);
}
}
async function readInfoPlistAsync(
projectDir: string,
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<IOSConfig.InfoPlist> {
const infoPlistPath = getInfoPlistPath(projectDir, buildSettings);
return ((await readPlistAsync(infoPlistPath)) ?? {}) as IOSConfig.InfoPlist;
}
async function writeInfoPlistAsync({
projectDir,
infoPlist,
buildSettings,
}: {
projectDir: string;
infoPlist: IOSConfig.InfoPlist;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
const infoPlistPath = getInfoPlistPath(projectDir, buildSettings);
await writePlistAsync(infoPlistPath, infoPlist);
}
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(
s: string,
buildSettings: XCBuildConfiguration['buildSettings']
): string {
// necessary because XCBuildConfiguration['buildSettings'] is not a plain object
const vars = { ...buildSettings };
return s.replace(/\$\((\w+)\)/g, (match, key) => {
if (vars.hasOwnProperty(key)) {
const value = String(vars[key]);
return value.startsWith('"') ? value.slice(1, -1) : value;
} else {
return match;
}
});
}
-80
View File
@@ -1,80 +0,0 @@
import { Job } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process';
import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '0.0.78';
export interface LocalBuildOptions {
enable: boolean;
skipCleanup?: boolean;
skipNativeBuild?: boolean;
artifactsDir?: string;
artifactPath?: string;
workingdir?: string;
verbose?: boolean;
}
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');
}
let childProcess: ChildProcess | undefined;
const interruptHandler = (): void => {
if (childProcess) {
childProcess.kill();
}
};
process.on('SIGINT', interruptHandler);
try {
const spawnPromise = spawnAsync(command, args, {
stdio: options.verbose ? 'inherit' : 'pipe',
env: {
...process.env,
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
...(options.skipCleanup || options.skipNativeBuild
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
: {}),
...(options.skipNativeBuild ? { EAS_LOCAL_BUILD_SKIP_NATIVE_BUILD: '1' } : {}),
...(options.artifactsDir ? { EAS_LOCAL_BUILD_ARTIFACTS_DIR: options.artifactsDir } : {}),
...(options.artifactPath ? { EAS_LOCAL_BUILD_ARTIFACT_PATH: options.artifactPath } : {}),
},
});
childProcess = spawnPromise.child;
await spawnPromise;
} finally {
process.removeListener('SIGINT', interruptHandler);
spinner?.stop();
}
}
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: [jobBase64],
};
} else {
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
args.unshift('-y');
}
return {
command: 'npx',
args,
};
}
}
async function isAtLeastNpm7Async(): Promise<boolean> {
const version = (await spawnAsync('npm', ['--version'])).stdout.trim();
return semver.gte(version, '7.0.0');
}
+66 -145
View File
@@ -1,151 +1,72 @@
import { Updates } from '@expo/config-plugins';
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
import { CredentialsSource, Workflow } from '@eas/config';
import Log from '../log';
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
import {
readChannelSafelyAsync as readAndroidChannelSafelyAsync,
readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync,
} from '../update/android/UpdatesModule';
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';
import { BuildContext } from './context';
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
import { Platform, TrackingContext } from './types';
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<Metadata> {
const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
const distribution =
('simulator' in ctx.buildProfile && ctx.buildProfile.simulator
? 'simulator'
: ctx.buildProfile.distribution) ?? 'store';
const metadata = {
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../../../../package.json');
export type BuildMetadata = {
/**
* Application version (the expo.version key in app.json/app.config.js)
*/
appVersion: string;
/**
* EAS CLI version
*/
cliVersion: string;
/**
* Build workflow
* It's either 'generic' or 'managed'
*/
workflow: Workflow;
/**
* Credentials source
* Credentials could be obtained either from credential.json or Expo servers.
*/
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
/**
* Expo SDK version
* It's determined by the expo package version in package.json.
* It's undefined if the expo package is not installed for the project.
*/
sdkVersion?: string;
/**
* Release channel (for expo-updates)
* It's undefined if the expo-updates package is not installed for the project.
*/
releaseChannel?: string;
/**
* Tracking context
* It's used to track build process across different Expo services and tools.
*/
trackingContext: TrackingContext;
};
export function collectMetadata<T extends Platform>(
ctx: BuildContext<T>,
{
credentialsSource,
}: {
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
}
): BuildMetadata {
return {
appVersion: ctx.commandCtx.exp.version!,
cliVersion: packageJSON.version,
workflow: ctx.buildProfile.workflow,
credentialsSource,
sdkVersion: ctx.commandCtx.exp.sdkVersion,
trackingContext: ctx.trackingCtx,
...(await maybeResolveVersionsAsync(ctx)),
cliVersion: easCliVersion,
workflow: ctx.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
sdkVersion: ctx.exp.sdkVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(ctx.exp, ctx.platform) ?? undefined,
reactNativeVersion: await getReactNativeVersionAsync(ctx),
...channelOrReleaseChannel,
distribution,
appName: ctx.exp.name,
appIdentifier: resolveAppIdentifier(ctx),
buildProfile: ctx.buildProfileName,
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>
),
}),
};
return sanitizeMetadata(metadata);
}
async function maybeResolveVersionsAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<{ appBuildVersion?: string; appVersion?: string }> {
if (ctx.platform === Platform.IOS) {
const iosContext = ctx as BuildContext<Platform.IOS>;
return await maybeResolveIosVersionsAsync(
ctx.projectDir,
ctx.exp,
iosContext.ios.applicationTargetBuildSettings
);
} else if (ctx.platform === Platform.ANDROID) {
const androidCtx = ctx as BuildContext<Platform.ANDROID>;
return await maybeResolveAndroidVersionsAsync(ctx.projectDir, ctx.exp, androidCtx.buildProfile);
} else {
throw new Error(`Unsupported platform ${ctx.platform}`);
}
}
function resolveAppIdentifier<T extends Platform>(ctx: BuildContext<T>): string {
if (ctx.platform === Platform.IOS) {
return (ctx as BuildContext<Platform.IOS>).ios.bundleIdentifier;
} else {
return (ctx as BuildContext<Platform.ANDROID>).android.applicationId;
}
}
async function resolveChannelOrReleaseChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<{ channel: string } | { releaseChannel: string } | null> {
if (!isExpoUpdatesInstalled(ctx.projectDir)) {
return null;
}
if (ctx.buildProfile.channel) {
return { channel: ctx.buildProfile.channel };
}
if (ctx.buildProfile.releaseChannel) {
return { releaseChannel: ctx.buildProfile.releaseChannel };
}
const channel = await getNativeChannelAsync(ctx);
if (channel) {
return { channel };
}
const releaseChannel = await getNativeReleaseChannelAsync(ctx);
return { releaseChannel };
}
async function getNativeReleaseChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<string> {
switch (ctx.platform) {
case Platform.ANDROID: {
return (await readAndroidReleaseChannelSafelyAsync(ctx.projectDir)) ?? 'default';
}
case Platform.IOS: {
return (await readIosReleaseChannelSafelyAsync(ctx.projectDir)) ?? 'default';
}
default:
return 'default';
}
}
async function getNativeChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<string | undefined> {
switch (ctx.platform) {
case Platform.ANDROID: {
return (await readAndroidChannelSafelyAsync(ctx.projectDir)) ?? undefined;
}
case Platform.IOS: {
return (await readIosChannelSafelyAsync(ctx.projectDir)) ?? undefined;
}
}
return undefined;
}
async function getReactNativeVersionAsync(
ctx: BuildContext<Platform>
): Promise<string | undefined> {
try {
const reactNativePackageJsonPath = resolveFrom(ctx.projectDir, 'react-native/package.json');
return (await fs.readJson(reactNativePackageJsonPath)).version;
} catch (err) {
Log.debug('Failed to resolve react-native version:');
Log.debug(err);
return undefined;
}
}
function resolveIosEnterpriseProvisioning(
ctx: BuildContext<Platform.IOS>
): IosEnterpriseProvisioning | undefined {
return ctx.buildProfile.enterpriseProvisioning;
}
@@ -1,266 +0,0 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, SubmitProfile } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { AppPlatform, BuildFragment, BuildStatus, SubmissionFragment } from '../graphql/generated';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import {
RequestedPlatform,
appPlatformDisplayNames,
appPlatformEmojis,
toPlatforms,
} from '../platform';
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
import { createSubmissionContextAsync } from '../submit/context';
import {
submitAsync,
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../submit/submit';
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles';
import { getVcsClient } from '../vcs';
import { prepareAndroidBuildAsync } from './android/build';
import { BuildRequestSender, waitForBuildEndAsync } from './build';
import { ensureProjectConfiguredAsync } from './configure';
import { BuildContext } from './context';
import { createBuildContextAsync } from './createContext';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildOptions } from './local';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
let metroConfigValidated = false;
let sdkVersionChecked = false;
export interface BuildFlags {
requestedPlatform: RequestedPlatform;
profile?: string;
nonInteractive: boolean;
wait: boolean;
clearCache: boolean;
json: boolean;
autoSubmit: boolean;
submitProfile?: string;
localBuildOptions: LocalBuildOptions;
}
export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFlags): Promise<void> {
await getVcsClient().ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(flags.nonInteractive);
await ensureProjectConfiguredAsync({
projectDir,
nonInteractive: flags.nonInteractive,
});
const platforms = toPlatforms(flags.requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
projectDir,
platforms,
profileName: flags.profile ?? undefined,
});
await ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir,
nonInteractive: flags.nonInteractive,
buildProfiles,
});
const startedBuilds: {
build: BuildFragment;
buildProfile: ProfileData<'build'>;
}[] = [];
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
for (const buildProfile of buildProfiles) {
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
projectDir,
flags,
moreBuilds: platforms.length > 1,
buildProfile,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
}
buildCtxByPlatform[toAppPlatform(buildProfile.platform)] = buildCtx;
}
if (flags.localBuildOptions.enable) {
return;
}
Log.newLine();
printLogsUrls(startedBuilds.map(startedBuild => startedBuild.build));
Log.newLine();
const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) {
const submitProfiles = await getProfilesAsync({
projectDir,
platforms,
profileName: flags.submitProfile,
type: 'submit',
});
for (const startedBuild of startedBuilds) {
const submitProfile = nullthrows(
submitProfiles.find(
({ platform }) => toAppPlatform(platform) === startedBuild.build.platform
)
).profile;
const submission = await prepareAndStartSubmissionAsync({
build: startedBuild.build,
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
moreBuilds: startedBuilds.length > 1,
projectDir,
buildProfile: startedBuild.buildProfile.profile,
submitProfile,
nonInteractive: flags.nonInteractive,
});
submissions.push(submission);
}
Log.newLine();
printSubmissionDetailsUrls(submissions);
Log.newLine();
}
if (!flags.wait) {
if (flags.json) {
printJsonOnlyOutput(startedBuilds.map(buildInfo => buildInfo.build));
}
return;
}
const { accountName } = Object.values(buildCtxByPlatform)[0];
const builds = await waitForBuildEndAsync({
buildIds: startedBuilds.map(({ build }) => build.id),
accountName,
});
printBuildResults(builds, flags.json);
const haveAllBuildsFailedOrCanceled = builds.every(
build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
);
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
} else {
// the following function also exits with non zero code if any of the submissions failed
await waitForSubmissionsToCompleteAsync(submissions);
}
}
async function prepareAndStartBuildAsync({
projectDir,
flags,
moreBuilds,
buildProfile,
}: {
projectDir: string;
flags: BuildFlags;
moreBuilds: boolean;
buildProfile: ProfileData<'build'>;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
clearCache: flags.clearCache,
buildProfile: buildProfile.profile,
nonInteractive: flags.nonInteractive,
platform: buildProfile.platform,
projectDir,
localBuildOptions: flags.localBuildOptions,
});
if (moreBuilds) {
Log.newLine();
const appPlatform = toAppPlatform(buildProfile.platform);
Log.log(
`${appPlatformEmojis[appPlatform]} ${chalk.bold(
`${appPlatformDisplayNames[appPlatform]} build`
)}`
);
}
if (buildCtx.workflow === Workflow.MANAGED) {
if (!sdkVersionChecked) {
await checkExpoSdkIsSupportedAsync(buildCtx);
sdkVersionChecked = true;
}
if (!metroConfigValidated) {
await validateMetroConfigForManagedWorkflowAsync(buildCtx);
metroConfigValidated = true;
}
}
const build = await startBuildAsync(buildCtx);
return {
build,
buildCtx,
};
}
async function startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragment | undefined> {
let sendBuildRequestAsync: BuildRequestSender;
if (ctx.platform === Platform.ANDROID) {
sendBuildRequestAsync = await prepareAndroidBuildAsync(ctx as BuildContext<Platform.ANDROID>);
} else {
sendBuildRequestAsync = await prepareIosBuildAsync(ctx as BuildContext<Platform.IOS>);
}
return await sendBuildRequestAsync();
}
async function prepareAndStartSubmissionAsync({
build,
buildCtx,
moreBuilds,
projectDir,
buildProfile,
submitProfile,
nonInteractive,
}: {
build: BuildFragment;
buildCtx: BuildContext<Platform>;
moreBuilds: boolean;
projectDir: string;
buildProfile: BuildProfile;
submitProfile: SubmitProfile;
nonInteractive: boolean;
}): Promise<SubmissionFragment> {
const platform = toPlatform(build.platform);
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,
});
if (moreBuilds) {
Log.newLine();
Log.log(
`${appPlatformEmojis[build.platform]} ${chalk.bold(
`${appPlatformDisplayNames[build.platform]} submission`
)}`
);
}
return await submitAsync(submissionCtx);
}
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
i => i.status === BuildStatus.Errored
);
if (failedBuilds.length > 0) {
process.exit(1);
}
}
+48 -6
View File
@@ -1,14 +1,56 @@
import { AndroidBuildProfile, iOSBuildProfile } from '@eas/config';
import { Platform } from '@expo/eas-build-job';
export enum BuildCommandPlatform {
ANDROID = 'android',
IOS = 'ios',
ALL = 'all',
}
export { Platform };
export enum BuildStatus {
NEW = 'new',
IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress',
ERRORED = 'errored',
FINISHED = 'finished',
CANCELED = 'canceled',
}
export enum BuildDistributionType {
STORE = 'store',
INTERNAL = 'internal',
SIMULATOR = 'simulator',
export type TrackingContext = Record<string, string | number>;
export interface Build {
id: string;
status: BuildStatus;
platform: Platform;
createdAt: string;
artifacts?: BuildArtifacts;
}
interface BuildArtifacts {
buildUrl?: string;
logsUrl: string;
}
export type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
? AndroidBuildProfile
: iOSBuildProfile;
export enum AnalyticsEvent {
BUILD_COMMAND = 'builds cli build command',
PROJECT_UPLOAD_SUCCESS = 'builds cli project upload success',
PROJECT_UPLOAD_FAIL = 'builds cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'builds cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'builds cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'builds cli configure project success',
CONFIGURE_PROJECT_FAIL = 'builds cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'builds cli build request fail',
BUILD_STATUS_COMMAND = 'builds cli build status',
CREDENTIALS_SYNC_COMMAND = 'builds cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'builds cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'builds cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'builds cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'builds cli credentials sync update remote fail',
}
@@ -0,0 +1,3 @@
export default {
logEvent: (a: any, b: any) => {},
};
@@ -1,33 +0,0 @@
import * as ExpoConfig from '@expo/config';
import JsonFile from '@expo/json-file';
import assert from 'assert';
export async function updateAppJsonConfigAsync(
{
projectDir,
exp,
}: {
projectDir: string;
exp: ExpoConfig.ExpoConfig;
},
modifyConfig: (config: any) => void
): Promise<void> {
const paths = ExpoConfig.getConfigFilePaths(projectDir);
assert(paths.staticConfigPath, "can't update dynamic config");
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = rawStaticConfig.expo ?? {};
modifyConfig(rawStaticConfig.expo);
await JsonFile.writeAsync(paths.staticConfigPath, rawStaticConfig, { json5: false });
modifyConfig(exp);
}
// TODO: remove this once @expo/config exports getStaticConfig
export function readAppJson(appJsonPath: string): any {
const config = JsonFile.read(appJsonPath, { json5: true });
if (config) {
return config as any;
}
throw new Error(`Failed to read app.json`);
}
@@ -1,19 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import Log from '../../log';
import { requestedPlatformDisplayNames } from '../../platform';
export function logCredentialsSource(
credentialsSource: CredentialsSource,
platform: Platform
): void {
let message = `Using ${credentialsSource} ${requestedPlatformDisplayNames[platform]} credentials`;
if (credentialsSource === CredentialsSource.LOCAL) {
message += ` ${chalk.dim('(credentials.json)')}`;
} else if (credentialsSource === CredentialsSource.REMOTE) {
message += ` ${chalk.dim('(Expo server)')}`;
}
Log.succeed(message);
}
@@ -1,115 +0,0 @@
import { Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import resolveFrom from 'resolve-from';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log, { learnMore } from '../../log';
import { appPlatformDisplayNames } from '../../platform';
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync } from '../../prompts';
import { expoCommandAsync } from '../../utils/expoCli';
import { ProfileData } from '../../utils/profiles';
import { getVcsClient } from '../../vcs';
import { reviewAndCommitChangesAsync } from './repository';
export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir,
nonInteractive = false,
buildProfiles = [],
}: {
projectDir: string;
nonInteractive?: boolean;
buildProfiles?: ProfileData<'build'>[];
}): Promise<void> {
if (await isExpoDevClientInstalledAsync(projectDir)) {
return;
}
const buildProfilesWithDevelopmentClientRequired = buildProfiles.filter(
buildProfile => buildProfile.profile.developmentClient
);
const isDevelopmentClientRequired = buildProfilesWithDevelopmentClientRequired.some(Boolean);
if (!isDevelopmentClientRequired) {
return;
}
const platformsToCheck = buildProfilesWithDevelopmentClientRequired.map(
({ platform }) => platform
);
const workflowPerPlatformList = await Promise.all(
platformsToCheck.map(platform => resolveWorkflowAsync(projectDir, platform))
);
Log.newLine();
Log.error(
`You want to build a development client build for platforms: ${platformsToCheck
.map(i => chalk.bold(appPlatformDisplayNames[toAppPlatform(i)]))
.join(', ')}`
);
Log.error(
`However, we detected that you don't have ${chalk.bold(
'expo-dev-client'
)} installed for your project.`
);
const areAllManaged = workflowPerPlatformList.every(i => i === Workflow.MANAGED);
if (areAllManaged) {
const install = await confirmAsync({
message: 'Do you want EAS CLI to install expo-dev-client for you?',
instructions: 'The command will abort unless you agree.',
});
if (install) {
await installExpoDevClientAsync(projectDir, { nonInteractive });
} else {
Errors.error(`Install ${chalk.bold('expo-dev-client')} manually and come back later.`, {
exit: 1,
});
}
} else {
Log.warn(`You'll need to install ${chalk.bold('expo-dev-client')} manually.`);
Log.warn(
learnMore('https://docs.expo.dev/clients/installation/', {
learnMoreMessage: 'See installation instructions on how to do it.',
dim: false,
})
);
Log.warn('If you proceed anyway, you might not get the build you want.');
Log.newLine();
const shouldContinue = await confirmAsync({
message: 'Do you want to proceed anyway?',
initial: false,
});
if (!shouldContinue) {
Errors.error('Come back later', { exit: 1 });
}
}
}
async function isExpoDevClientInstalledAsync(projectDir: string): Promise<boolean> {
try {
resolveFrom(projectDir, 'expo-dev-client/package.json');
return true;
} catch (err: any) {
Log.debug(err);
return false;
}
}
async function installExpoDevClientAsync(
projectDir: string,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<void> {
Log.newLine();
Log.log(`Running ${chalk.bold('expo install expo-dev-client')}`);
Log.newLine();
await expoCommandAsync(projectDir, ['install', 'expo-dev-client']);
Log.newLine();
if (await getVcsClient().isCommitRequiredAsync()) {
await reviewAndCommitChangesAsync('Install expo-dev-client', {
nonInteractive,
});
}
}
@@ -1,124 +0,0 @@
import chalk from 'chalk';
import {
AppPlatform,
BuildFragment,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { appPlatformDisplayNames } from '../../platform';
import formatFields from '../../utils/formatFields';
import { getBuildLogsUrl } from './url';
export function formatGraphQLBuild(build: BuildFragment): string {
const actor = getActorName(build);
const fields: { label: string; value?: string | null }[] = [
{ label: 'ID', value: build.id },
{
label: 'Platform',
value: appPlatformDisplayNames[build.platform],
},
{
label: 'Status',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
return chalk.blue('new');
case GraphQLBuildStatus.InQueue:
return chalk.blue('in queue');
case GraphQLBuildStatus.InProgress:
return chalk.blue('in progress');
case GraphQLBuildStatus.Canceled:
return chalk.gray('canceled');
case GraphQLBuildStatus.Finished:
return chalk.green('finished');
case GraphQLBuildStatus.Errored:
return chalk.red('errored');
default:
return 'unknown';
}
},
},
{
label: 'Distribution',
value: build.distribution?.toLowerCase(),
},
{
label: 'Enterprise Provisioning',
value: build.iosEnterpriseProvisioning?.toLowerCase(),
},
{
label: 'Release Channel',
value: build.releaseChannel,
},
{
label: 'Channel',
value: build.channel,
},
{
label: 'SDK Version',
value: build.sdkVersion,
},
{
label: 'Runtime Version',
value: build.runtimeVersion,
},
{
label: 'Version',
value: build.appVersion,
},
{
label: build.platform === AppPlatform.Android ? 'Version code' : 'Build number',
value: build.appBuildVersion,
},
{
label: 'Commit',
value: build.gitCommitHash,
},
{
label: 'Logs',
value: getBuildLogsUrl(build),
},
{
label: 'Artifact',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored:
return null;
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
default:
return null;
}
},
},
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
{
label: 'Finished at',
value: [
GraphQLBuildStatus.New,
GraphQLBuildStatus.InQueue,
GraphQLBuildStatus.InProgress,
].includes(build.status)
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
{ label: 'Started by', value: actor ?? 'unknown' },
];
const filteredFields = fields.filter(({ value }) => value !== undefined && value !== null) as {
label: string;
value: string;
}[];
return formatFields(filteredFields);
}
const getActorName = (build: BuildFragment): string => {
return build.initiatingActor?.displayName || 'unknown';
};
@@ -1,119 +1,67 @@
import assert from 'assert';
import chalk from 'chalk';
import indentString from 'indent-string';
import qrcodeTerminal from 'qrcode-terminal';
import log from '../../log';
import { platformDisplayNames } from '../constants';
import { Build } from '../types';
import { getBuildLogsUrl } from './url';
import {
AppPlatform,
BuildError,
BuildFragment,
BuildStatus,
DistributionType,
EasBuildDeprecationInfo,
EasBuildDeprecationInfoType,
} from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform';
import { printJsonOnlyOutput } from '../../utils/json';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
export interface DeprecationInfo {
type: 'user-facing' | 'internal';
message: string;
}
export function printLogsUrls(builds: BuildFragment[]): void {
export function printLogsUrls(
accountName: string,
builds: { platform: 'android' | 'ios'; buildId: string }[]
): void {
if (builds.length === 1) {
Log.log(`Build details: ${chalk.underline(getBuildLogsUrl(builds[0]))}`);
const { buildId } = builds[0];
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
log(`Logs url: ${logsUrl}`);
} else {
builds.forEach(build => {
const logsUrl = getBuildLogsUrl(build);
Log.log(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build details: ${chalk.underline(logsUrl)}`
);
builds.forEach(({ buildId, platform }) => {
const logsUrl = getBuildLogsUrl({
buildId,
account: accountName,
});
log(`Platform: ${platformDisplayNames[platform]}, Logs url: ${logsUrl}`);
});
}
}
export function printBuildResults(builds: (BuildFragment | null)[], json: boolean): void {
if (json) {
printJsonOnlyOutput(builds);
export function printBuildResults(builds: (Build | null)[]): void {
if (builds.length === 1) {
log(`Artifact url: ${builds[0]?.artifacts?.buildUrl ?? ''}`);
} else {
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));
}
(builds.filter(i => i) as Build[])
.filter(build => build.status === 'finished')
.forEach(build => {
log(
`Platform: ${platformDisplayNames[build.platform]}, Artifact url: ${
build.artifacts?.buildUrl ?? ''
}`
);
});
}
}
function printBuildResult(build: BuildFragment): void {
Log.addNewLineIfNone();
if (build.status === BuildStatus.Errored) {
const userError = build.error;
Log.error(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build failed${userError ? ':' : ''}`
);
if (userError) {
printUserError(userError);
}
return;
}
if (build.status === BuildStatus.Canceled) {
Log.error(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build was canceled`
);
return;
}
if (build.distribution === DistributionType.Internal) {
const logsUrl = getBuildLogsUrl(build);
// It's tricky to install the .apk file directly on Android so let's fallback
// to the build details page and let people press the button to download there
const qrcodeUrl =
build.platform === AppPlatform.Ios ? getInternalDistributionInstallUrl(build) : logsUrl;
qrcodeTerminal.generate(qrcodeUrl, { small: true }, code =>
Log.log(`${indentString(code, 2)}\n`)
);
Log.log(
`${appPlatformEmojis[build.platform]} Open this link on your ${
appPlatformDisplayNames[build.platform]
} devices (or scan the QR code) to install the app:`
);
Log.log(`${chalk.underline(logsUrl)}`);
} else {
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
// we should account for this case better if it is possible
const url = build.artifacts?.buildUrl ?? '';
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
Log.log(`${chalk.underline(url)}`);
}
}
export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationInfo | null): void {
export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): void {
if (!deprecationInfo) {
return;
}
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
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);
if (deprecationInfo.type === 'internal') {
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
log.warn("Changes won't affect your project confiuration.");
log.warn(deprecationInfo.message);
} else if (deprecationInfo.type === 'user-facing') {
log.warn('This command is using API that soon will be deprecated, please update expo-cli.');
log.warn(
'There might be some changes necessary to your project configuration, latest expo-cli will provide more specific error messages.'
);
log.warn(deprecationInfo.message);
} else {
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
Log.warn(deprecationInfo);
}
}
export function printUserError(error: BuildError): void {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
log.warn('An unexpected warning was encountered. Please report it as a bug:');
log.warn(deprecationInfo);
}
}
+137 -201
View File
@@ -1,237 +1,173 @@
import admzip from 'adm-zip';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import figures from 'figures';
import fs from 'fs-extra';
import ora from 'ora';
import path from 'path';
import tar from 'tar';
import { v4 as uuidv4 } from 'uuid';
import Log from '../../log';
import { ora } from '../../ora';
import log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import {
doesGitRepoExistAsync,
gitDiffAsync,
gitRootDirectoryAsync,
gitStatusAsync,
isGitInstalledAsync,
} from '../../utils/git';
import { getTmpDirectory } from '../../utils/paths';
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
import { getVcsClient } from '../../vcs';
export async function maybeBailOnRepoStatusAsync(): Promise<void> {
if (!(await getVcsClient().isCommitRequiredAsync())) {
async function ensureGitRepoExistsAsync(): Promise<void> {
if (!(await isGitInstalledAsync())) {
throw new Error('git command not found, install it before proceeding');
}
if (await doesGitRepoExistAsync()) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
Log.log(
`It's recommended to ${chalk.bold(
'commit all your changes before proceeding'
)}, so you can revert the changes made by this command if necessary.`
);
const answer = await confirmAsync({
message: `Would you like to proceed?`,
log.warn("It looks like you haven't initialized the git repository yet.");
log.warn('EAS Build requires you to use a git repository for your project.');
const confirmInit = await confirmAsync({
message: `Would you like to run 'git init' in the current directory?`,
});
if (!confirmInit) {
throw new Error(
'A git repository is required for building your project. Initialize it and run this command again.'
);
}
await spawnAsync('git', ['init']);
if (!answer) {
throw new Error('Please commit all changes. Aborting...');
}
}
log("We're going to make an initial commit for you repository.");
export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<void> {
if (!(await getVcsClient().isCommitRequiredAsync())) {
return;
}
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, please ${chalk.bold(
'commit all your changes before proceeding'
)}.`
);
if (nonInteractive) {
throw new Error('Please commit all changes. Aborting...');
}
const answer = await confirmAsync({
message: `Commit changes to git?`,
});
if (answer) {
await commitPromptAsync({ commitAllFiles: true });
} else {
throw new Error('Please commit all changes. Aborting...');
}
}
export async function commitPromptAsync({
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}): Promise<void> {
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: initialCommitMessage,
initial: 'Initial commit',
validate: (input: string) => input !== '',
});
await getVcsClient().commitAsync({ commitAllFiles, commitMessage: message });
await spawnAsync('git', ['add', '-A']);
await spawnAsync('git', ['commit', '-m', message]);
}
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
const spinner = ora('Compressing project files');
await fs.mkdirp(getTmpDirectory());
const shallowClonePath = path.join(getTmpDirectory(), `${uuidv4()}-shallow-clone`);
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
// If the compression takes longer then a second, show the spinner.
// This can happen when the user has a lot of resources or doesn't ignore their CocoaPods.
// A basic project on a Mac can compress in roughly ~40ms.
// A fairly complex project without CocoaPods ignored can take up to 30s.
const timer = setTimeout(
() => {
spinner.start();
},
Log.isDebug ? 1 : 1000
);
// TODO: Possibly warn after more time about unoptimized assets.
const compressTimerLabel = 'makeProjectTarballAsync';
startTimer(compressTimerLabel);
try {
await getVcsClient().makeShallowCopyAsync(shallowClonePath);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
} catch (err) {
clearTimeout(timer);
if (spinner.isSpinning) {
spinner.fail();
}
throw err;
} finally {
await fs.remove(shallowClonePath);
}
clearTimeout(timer);
const { size } = await fs.stat(tarPath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
async function ensureGitStatusIsCleanAsync(): Promise<void> {
const changes = await gitStatusAsync();
if (changes.length > 0) {
throw new DirtyGitTreeError(
'Please commit all changes before building your project. Aborting...'
);
}
}
class DirtyGitTreeError extends Error {}
async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
const spinner = ora('Making project tarball').start();
await fs.mkdirp(getTmpDirectory());
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
await spawnAsync(
'git',
['archive', '--format=tar.gz', '--prefix', 'project/', '-o', tarPath, 'HEAD'],
{ cwd: await gitRootDirectoryAsync() }
);
spinner.succeed('Project tarball created.');
const { size } = await fs.stat(tarPath);
return { size, path: tarPath };
}
export async function makeZipAsync(pathToZip: string): Promise<{ path: string; size: number }> {
const spinner = ora('Compressing project files');
await fs.mkdirp(getTmpDirectory());
const shallowClonePath = path.join(getTmpDirectory(), `${uuidv4()}-shallow-clone`);
try {
process.chdir(pathToZip);
await getVcsClient().makeShallowCopyAsync(shallowClonePath);
process.chdir('..');
const zipPath = path.join(getTmpDirectory(), `${uuidv4()}.zip`);
// If the compression takes longer then a second, show the spinner.
// This can happen when the user has a lot of resources or doesn't ignore their CocoaPods.
// A basic project on a Mac can compress in roughly ~40ms.
// A fairly complex project without CocoaPods ignored can take up to 30s.
const timer = setTimeout(
() => {
spinner.start();
},
Log.isDebug ? 1 : 1000
);
// TODO: Possibly warn after more time about unoptimized assets.
const compressTimerLabel = 'makeProjectZipAsync';
startTimer(compressTimerLabel);
const zip = new admzip();
zip.addLocalFolder(shallowClonePath);
await zipAsync(zip, zipPath);
clearTimeout(timer);
const { size } = await fs.stat(zipPath);
const duration = endTimer(compressTimerLabel);
if (spinner.isSpinning) {
const prettyTime = formatMilliseconds(duration);
spinner.succeed(
`Compressed project files ${chalk.dim(`${prettyTime} (${formatBytes(size)})`)}`
);
}
return { size, path: zipPath };
} catch (err) {
if (spinner.isSpinning) {
spinner.fail();
}
throw err;
} finally {
await fs.remove(shallowClonePath);
}
}
async function zipAsync(zip: admzip, targetFileName?: string): Promise<void> {
return new Promise((resolve, reject) => {
zip.writeZip(targetFileName, (error: Error | null) => (error ? reject(error) : resolve()));
});
}
enum ShouldCommitChanges {
Yes,
ShowDiffFirst,
Abort,
}
export async function reviewAndCommitChangesAsync(
initialCommitMessage: string,
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
async function reviewAndCommitChangesAsync(
commitMessage: string,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) {
await getVcsClient().commitAsync({
commitMessage: initialCommitMessage,
commitAllFiles: false,
nonInteractive,
});
Log.withTick('Committed changes.');
return;
}
if (nonInteractive) {
throw new Error(
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode or set EAS_BUILD_AUTOCOMMIT=1 in your environment.'
'Cannot commit changes when --non-interactive is specified. Run the command in interactive mode to review and commit changes.'
);
}
const { selected } = await promptAsync({
type: 'select',
name: 'selected',
message: 'Can we commit these changes to git for you?',
choices: [
{ title: 'Yes', value: ShouldCommitChanges.Yes },
...(askedFirstTime
? [{ title: 'Show the diff and ask me again', value: ShouldCommitChanges.ShowDiffFirst }]
: []),
{
title: 'Abort build process',
value: ShouldCommitChanges.Abort,
},
],
log('Please review the following changes and pass the message to make the commit.');
log.newLine();
await gitDiffAsync();
log.newLine();
const confirm = await confirmAsync({
message: 'Can we commit these changes for you?',
});
if (selected === ShouldCommitChanges.Abort) {
throw new Error(
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
);
} else if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await getVcsClient().showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive,
askedFirstTime: false,
});
if (!confirm) {
throw new Error('Aborting commit. Please review and commit the changes manually.');
}
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: commitMessage,
validate: (input: string) => input !== '',
});
// Add changed files only
await spawnAsync('git', ['add', '-u']);
await spawnAsync('git', ['commit', '-m', message]);
}
async function modifyAndCommitAsync(
callback: () => Promise<void>,
{
startMessage,
successMessage,
commitMessage,
commitSuccessMessage,
nonInteractive,
}: {
startMessage: string;
successMessage: string;
commitMessage: string;
commitSuccessMessage: string;
nonInteractive: boolean;
}
) {
const spinner = ora(startMessage);
try {
await callback();
await ensureGitStatusIsCleanAsync();
spinner.succeed();
} catch (err) {
if (err instanceof DirtyGitTreeError) {
spinner.succeed(successMessage);
log.newLine();
try {
await reviewAndCommitChangesAsync(commitMessage, {
nonInteractive,
});
log(`${chalk.green(figures.tick)} ${commitSuccessMessage}.`);
} catch (e) {
throw new Error(
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
);
}
} else {
spinner.fail();
throw err;
}
}
}
export {
DirtyGitTreeError,
ensureGitRepoExistsAsync,
ensureGitStatusIsCleanAsync,
makeProjectTarballAsync,
reviewAndCommitChangesAsync,
modifyAndCommitAsync,
};
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff. Show more