mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31020047ca | ||
|
|
26778ec52b | ||
|
|
b27590f4f5 | ||
|
|
ea2af5af04 | ||
|
|
3f1a7a0da0 | ||
|
|
8184a13bf3 | ||
|
|
2f7be56bef | ||
|
|
5c166bb3b2 | ||
|
|
4e76526843 | ||
|
|
0e6fae0632 | ||
|
|
62a8fd1b02 | ||
|
|
5a8e252790 | ||
|
|
78613cd7c0 | ||
|
|
8f6f7f7e1c | ||
|
|
f46ad6b898 | ||
|
|
f153b08a1e | ||
|
|
6b57f00d7d | ||
|
|
de65ab702b | ||
|
|
a87b735832 | ||
|
|
afc6022529 | ||
|
|
2d3bb21f60 | ||
|
|
c26f478b45 | ||
|
|
f958a0db3e | ||
|
|
6df1130140 | ||
|
|
260a771070 | ||
|
|
6659ed97ca | ||
|
|
4eae49fe58 | ||
|
|
41fef5c0e4 | ||
|
|
8c4b45a439 | ||
|
|
e35b5e78d2 | ||
|
|
b04c1a2c20 | ||
|
|
25d75b6f94 | ||
|
|
a2adacf5a3 | ||
|
|
ca718d8da9 | ||
|
|
3e7b849ad1 | ||
|
|
2ec646659d | ||
|
|
c33e3d8a4c | ||
|
|
d65c180746 | ||
|
|
754af79e0c | ||
|
|
3733cc4cf8 | ||
|
|
9e5c1740b4 | ||
|
|
58919d37ab | ||
|
|
2ec4279ac7 | ||
|
|
3052276c3b | ||
|
|
65a9238d48 | ||
|
|
5a1bb94563 | ||
|
|
1094d3f9f7 | ||
|
|
d8452ce6f8 | ||
|
|
a5be039fd2 | ||
|
|
6178a54292 | ||
|
|
3a2e9c42ab | ||
|
|
8bfb97aa1c | ||
|
|
4c32b9bdec | ||
|
|
6dcb29caa9 | ||
|
|
917a46eea5 | ||
|
|
2be9d92268 | ||
|
|
29e644ab1e | ||
|
|
843a2fd10a | ||
|
|
4d8e6cc53a | ||
|
|
a52e443a44 | ||
|
|
70eb4a1b64 | ||
|
|
19b5cb9b86 | ||
|
|
5d026ea462 | ||
|
|
9c972430bb | ||
|
|
cff9a7a1d0 | ||
|
|
3cbc670d90 | ||
|
|
5b39620619 | ||
|
|
8498547377 | ||
|
|
175103ed32 | ||
|
|
e177b94ca1 | ||
|
|
193da3cf46 | ||
|
|
f29dd0b89a | ||
|
|
14cd1835dd | ||
|
|
01f82bb873 | ||
|
|
844eddf127 | ||
|
|
bdb7bf8475 | ||
|
|
04b687a050 | ||
|
|
a67bf6877a | ||
|
|
ea8fd7d4ff | ||
|
|
bc69ef34ff | ||
|
|
38a0ef8b1e | ||
|
|
fd21811e85 | ||
|
|
17d4fbbf45 | ||
|
|
8f455237c7 | ||
|
|
fd340c2bb5 | ||
|
|
6e2accf00a | ||
|
|
26e0ac7318 | ||
|
|
f58dc5db08 | ||
|
|
aba6b31bbd | ||
|
|
5db237afd3 | ||
|
|
45815e2dc6 | ||
|
|
d827a1dad9 | ||
|
|
5a94367073 | ||
|
|
eab7c24d53 | ||
|
|
24b4b9b59f | ||
|
|
f79d9b2f56 | ||
|
|
0237c51f48 | ||
|
|
c49f6cce18 | ||
|
|
61a3c01e26 | ||
|
|
625e7b588a | ||
|
|
0817cb36b8 | ||
|
|
46220028ab | ||
|
|
b546716582 | ||
|
|
46342c8cb1 | ||
|
|
a281b500d4 | ||
|
|
101fda5ee3 | ||
|
|
67fed5bcc1 | ||
|
|
aec4de430d | ||
|
|
e8ac985b89 | ||
|
|
c937987772 | ||
|
|
03f4ab3e8b | ||
|
|
afeb8df13e | ||
|
|
bc73db7656 | ||
|
|
511b3f3e4a | ||
|
|
0633cfefd0 | ||
|
|
77ea5a60a5 | ||
|
|
8a8c4b4382 | ||
|
|
75e3563e75 | ||
|
|
119dd2180c | ||
|
|
12e98beec7 | ||
|
|
b829e00b5b | ||
|
|
fd56a19156 | ||
|
|
2607430fa8 | ||
|
|
a449413a3e | ||
|
|
15bb9f9b24 | ||
|
|
dd2ef27115 | ||
|
|
250b257507 | ||
|
|
b32955136c | ||
|
|
8b73152765 | ||
|
|
4c8181fa82 | ||
|
|
aa54eca4ee | ||
|
|
8a0a5a249e | ||
|
|
77e6f42161 | ||
|
|
7c8603138b | ||
|
|
ebade7523e | ||
|
|
406605b509 | ||
|
|
77f881af67 | ||
|
|
4714ddc8d6 | ||
|
|
a94b187da5 | ||
|
|
636a1f110a | ||
|
|
e6e8bbcaf6 | ||
|
|
4f49135c1f | ||
|
|
10cc68357e | ||
|
|
932f641ae7 | ||
|
|
a139571ef5 | ||
|
|
bf067f99a9 | ||
|
|
a6d6593cd3 | ||
|
|
f485e4b6ef | ||
|
|
df80eb1bd9 | ||
|
|
2a67e0206b | ||
|
|
2b5d91b622 | ||
|
|
1c5a6aae76 | ||
|
|
2ae64e70ad | ||
|
|
a302978636 | ||
|
|
1ebf16cf08 | ||
|
|
9a7e8953c7 | ||
|
|
047b8e278e | ||
|
|
9878c07c09 | ||
|
|
acb07aa72a | ||
|
|
e889347710 | ||
|
|
3ab974856f | ||
|
|
f431feacf8 | ||
|
|
791e80b9fc | ||
|
|
12138832a0 | ||
|
|
302a94a9d8 | ||
|
|
03e754e0f6 | ||
|
|
925cb44819 | ||
|
|
ed798057a3 | ||
|
|
f08e1727bd | ||
|
|
c8c5cb589a | ||
|
|
fe1b34559e | ||
|
|
4df45dd8c2 | ||
|
|
fa6e894bfb | ||
|
|
9ed15d2b5d | ||
|
|
07d78b31f4 | ||
|
|
f342c87cf0 | ||
|
|
ed8d7229d0 | ||
|
|
2f9d388543 | ||
|
|
20a3324aab | ||
|
|
0d3008e8de | ||
|
|
78e0732440 | ||
|
|
91e87afa74 | ||
|
|
3461815e74 | ||
|
|
9b807bb2da | ||
|
|
8b53ca7d2f | ||
|
|
943eb7087f | ||
|
|
9a14346b0b | ||
|
|
56fb45f242 |
No files matched your search
+6
-1
@@ -1,2 +1,7 @@
|
||||
build
|
||||
**/build/**
|
||||
!packages/eas-cli/src/commands/build/**
|
||||
!packages/eas-cli/src/build/**
|
||||
|
||||
packages/eas-cli/src/graphql/generated.ts
|
||||
|
||||
node_modules
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
- run: eas --version
|
||||
@@ -0,0 +1,115 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
|
||||
name: Release workflow
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
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@v1
|
||||
with:
|
||||
node-version: '14'
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: yarn oclif-dev pack --targets linux-x64,linux-arm
|
||||
- name: Upload linux-x64 tarball
|
||||
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/eas-linux-x64.tar.gz
|
||||
asset_name: eas-linux-x64.tar.gz
|
||||
asset_content_type: application/gzip
|
||||
- name: Upload linux-arm tarball
|
||||
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/eas-linux-arm.tar.gz
|
||||
asset_name: eas-linux-arm.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@v1
|
||||
with:
|
||||
node-version: '14'
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: yarn oclif-dev pack --targets darwin-x64
|
||||
- name: Upload darwin-x64 tarball
|
||||
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/eas-darwin-x64.tar.gz
|
||||
asset_name: eas-darwin-x64.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@v1
|
||||
with:
|
||||
node-version: '14'
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: sudo apt-get install nsis
|
||||
- run: yarn build
|
||||
- run: yarn oclif-dev 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/win
|
||||
- id: x86
|
||||
run: echo "::set-output name=filename::$(ls eas-*-x86.exe)"
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
|
||||
- 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/win/${{ 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/win/${{ steps.x86.outputs.filename }}
|
||||
asset_name: ${{ steps.x86.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Size
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened]
|
||||
jobs:
|
||||
compare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '14'
|
||||
- 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'
|
||||
@@ -11,15 +11,25 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['12', '14']
|
||||
include:
|
||||
- node: '14'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn lerna run build
|
||||
- run: yarn test
|
||||
- run: yarn lint
|
||||
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 }}
|
||||
+6
-1
@@ -1,8 +1,13 @@
|
||||
*-debug.log
|
||||
*-error.log
|
||||
|
||||
**/build/**
|
||||
!packages/eas-cli/src/commands/build/**
|
||||
!packages/eas-cli/src/build/**
|
||||
|
||||
dist
|
||||
build
|
||||
/package-lock.json
|
||||
tmp
|
||||
node_modules
|
||||
coverage
|
||||
.history
|
||||
@@ -0,0 +1,8 @@
|
||||
## Releasing a new version
|
||||
|
||||
1. Add release notes to `CHANGELOG.md`.
|
||||
1. Run `yarn release` in the repository root folder. This will create a new tag
|
||||
like `v1.0.0`.
|
||||
1. Wait until the `release` GitHub workflow has finished.
|
||||
1. Once the release ready to go, you can go to
|
||||
https://github.com/expo/eas-cli/releases and mark it as not draft.
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#!/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="https://github.com/expo/eas-cli/releases/latest/download/eas-$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."
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
comment: false
|
||||
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
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
projects: [
|
||||
require('./packages/eas-cli/jest.config.js'),
|
||||
require('./packages/config/jest.config.js'),
|
||||
require('./packages/eas-json/jest.config.js'),
|
||||
],
|
||||
testPathIgnorePatterns: ['.*'],
|
||||
};
|
||||
@@ -1,11 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
// Only use this when running tests
|
||||
env: {
|
||||
test: {
|
||||
presets: ['@expo/babel-preset-cli'],
|
||||
},
|
||||
},
|
||||
presets: ['@expo/babel-preset-cli'],
|
||||
};
|
||||
};
|
||||
@@ -5,4 +5,5 @@ module.exports = {
|
||||
'^.+\\.[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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.0.0"
|
||||
"version": "0.2.1"
|
||||
}
|
||||
@@ -4,14 +4,19 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"build:tarballs:linux": "yarn build && yarn workspace eas-cli oclif-dev pack --targets linux-x64",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"release": "lerna version",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/oclif-dev-cli": "1.24.0-expo",
|
||||
"@types/jest": "^26.0.13",
|
||||
"eslint": "^6.6.0",
|
||||
"eslint-config-universe": "^4.0.0",
|
||||
"eslint-plugin-graphql": "^4.0.0",
|
||||
"jest": "^26.4.2",
|
||||
"jest-watch-typeahead": "^0.6.1",
|
||||
"lerna": "^3.22.1",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
|
||||
],
|
||||
'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'],
|
||||
};
|
||||
+209
-63
@@ -8,68 +8,193 @@ EAS command line tool
|
||||
[](https://github.com/expo/eas-cli/blob/main/package.json)
|
||||
|
||||
<!-- toc -->
|
||||
|
||||
- [eas-cli](#eas-cli)
|
||||
- [Usage](#usage)
|
||||
- [Commands](#commands)
|
||||
* [eas-cli](#eas-cli)
|
||||
* [Usage](#usage)
|
||||
* [Commands](#commands)
|
||||
<!-- tocstop -->
|
||||
|
||||
# Usage
|
||||
|
||||
<!-- usage -->
|
||||
|
||||
```sh-session
|
||||
$ npm install -g eas-cli
|
||||
$ eas COMMAND
|
||||
running command...
|
||||
$ eas (-v|--version|version)
|
||||
eas-cli/0.0.0 darwin-x64 node-v12.13.0
|
||||
eas-cli/0.2.1 darwin-x64 node-v12.18.4
|
||||
$ eas --help [COMMAND]
|
||||
USAGE
|
||||
$ eas COMMAND
|
||||
...
|
||||
```
|
||||
|
||||
<!-- usagestop -->
|
||||
|
||||
# Commands
|
||||
|
||||
<!-- commands -->
|
||||
* [`eas account:login`](#eas-accountlogin)
|
||||
* [`eas account:logout`](#eas-accountlogout)
|
||||
* [`eas account:view`](#eas-accountview)
|
||||
* [`eas build`](#eas-build)
|
||||
* [`eas build:configure`](#eas-buildconfigure)
|
||||
* [`eas build:list`](#eas-buildlist)
|
||||
* [`eas build:view [BUILDID]`](#eas-buildview-buildid)
|
||||
* [`eas credentials`](#eas-credentials)
|
||||
* [`eas device:create`](#eas-devicecreate)
|
||||
* [`eas device:list`](#eas-devicelist)
|
||||
* [`eas device:view [UDID]`](#eas-deviceview-udid)
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
|
||||
|
||||
- [`eas build`](#eas-build)
|
||||
- [`eas build:status`](#eas-buildstatus)
|
||||
- [`eas help [COMMAND]`](#eas-help-command)
|
||||
- [`eas login`](#eas-login)
|
||||
- [`eas logout`](#eas-logout)
|
||||
- [`eas update`](#eas-update)
|
||||
- [`eas update:show`](#eas-updateshow)
|
||||
- [`eas whoami`](#eas-whoami)
|
||||
## `eas account:login`
|
||||
|
||||
log in with your Expo account
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas account:login
|
||||
|
||||
ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
log out
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas account:logout
|
||||
|
||||
ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
show the username you are logged in as
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas account:view
|
||||
|
||||
ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/account/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
build an app binary for your project
|
||||
Start a build
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build
|
||||
|
||||
OPTIONS
|
||||
-p, --platform=(android|ios|all)
|
||||
--non-interactive Run command in --non-interactive mode
|
||||
--profile=profile [default: release] Name of the build profile from eas.json
|
||||
--skip-credentials-check Skip validation of build credentials
|
||||
--skip-project-configuration Skip project configuration
|
||||
--wait Wait for build(s) to complete
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/build/index.ts)_
|
||||
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/index.ts)_
|
||||
|
||||
## `eas build:status`
|
||||
## `eas build:configure`
|
||||
|
||||
get the status of the latest builds for your project
|
||||
Configure the project to support EAS Build.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:status
|
||||
$ eas build:configure
|
||||
|
||||
OPTIONS
|
||||
-p, --platform=(android|ios|all) Platform to configure
|
||||
--allow-experimental Enable experimental configuration steps.
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
list all builds for your project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:list
|
||||
|
||||
OPTIONS
|
||||
--limit=limit
|
||||
--platform=(all|android|ios)
|
||||
--status=(in-queue|in-progress|errored|finished)
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/status.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/build/status.ts)_
|
||||
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/list.ts)_
|
||||
|
||||
## `eas build:view [BUILDID]`
|
||||
|
||||
view a build for your project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:view [BUILDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/view.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
Manage your credentials
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas credentials
|
||||
```
|
||||
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
register new Apple Devices to use for internal distribution
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas device:create
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/device/create.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
list all registered devices for your account
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas device:list
|
||||
|
||||
OPTIONS
|
||||
--apple-team-id=apple-team-id
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/device/list.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
view a device for your project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas device:view [UDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/device/view.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -88,62 +213,83 @@ OPTIONS
|
||||
|
||||
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
|
||||
|
||||
## `eas login`
|
||||
## `eas submit --platform=(android|ios)`
|
||||
|
||||
log in with your EAS account
|
||||
Submits build artifact to app store
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas login
|
||||
```
|
||||
$ eas submit --platform=(android|ios)
|
||||
|
||||
_See code: [src/commands/login.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/login.ts)_
|
||||
OPTIONS
|
||||
-p, --platform=(android|ios) For which platform you want to submit a build
|
||||
|
||||
## `eas logout`
|
||||
Android specific options=android-package Android package name (default: expo.android.package
|
||||
from app config)
|
||||
|
||||
log out
|
||||
iOS specific options=app-name The name of your app as it will appear on the App
|
||||
Store (default: expo.name from app config)
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas logout
|
||||
```
|
||||
iOS specific options=apple-id Your Apple ID username (you can also set
|
||||
EXPO_APPLE_ID env variable)
|
||||
|
||||
_See code: [src/commands/logout.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/logout.ts)_
|
||||
iOS specific options=apple-team-id Your Apple Developer Team ID
|
||||
|
||||
## `eas update`
|
||||
iOS specific options=asc-app-id App Store Connect unique application Apple ID
|
||||
number. Providing this param results in skipping
|
||||
app creation step. Learn more:
|
||||
https://expo.fyi/asc-app-id
|
||||
|
||||
create a revision for given channel
|
||||
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
|
||||
expo.ios.bundleIdentifier from app config)
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update
|
||||
iOS specific options=company-name The name of your company, needed only for the first
|
||||
upload of any app to App Store
|
||||
|
||||
--id=id ID of the build to submit
|
||||
|
||||
Android specific options=key Path to the JSON key used to authenticate with
|
||||
Google Play
|
||||
|
||||
iOS specific options=language [default: en-US] Primary language (e.g. English,
|
||||
German, ...)
|
||||
|
||||
--latest Submit the latest build for specified platform
|
||||
|
||||
--path=path Path to the .apk/.aab file
|
||||
|
||||
Android specific options=(completed|draft|halted|inProgress) [default: completed] Release status (used when
|
||||
uploading new APKs/AABs)
|
||||
|
||||
iOS specific options=sku An unique ID for your app that is not visible on
|
||||
the App Store, will be generated unless provided
|
||||
|
||||
Android specific options=(production|beta|alpha|internal|rollout) [default: internal] The track of the application to
|
||||
use
|
||||
|
||||
Android specific options=(apk|aab) Android archive type
|
||||
|
||||
--url=url App archive url
|
||||
|
||||
--verbose Always print logs from Submission Service
|
||||
|
||||
ALIASES
|
||||
$ eas update:publish
|
||||
$ eas build:submit
|
||||
|
||||
EXAMPLES
|
||||
$ eas submit --platform=ios
|
||||
- Fully interactive iOS submission
|
||||
|
||||
$ eas submit --platform=android
|
||||
- Fully interactive Android submission
|
||||
|
||||
$ eas submit -p android --latest --key=/path/to/google-services.json
|
||||
- Minimal non-interactive Android submission, however it can ask you for other params if not specified
|
||||
|
||||
$ EXPO_APPLE_APP_SPECIFIC_PASSWORD=xxx eas submit -p ios --latest [email protected] --asc-app-id=1234567890,
|
||||
- Minimal non-interactive iOS submission, assuming you already have an app in App Store Connect
|
||||
and provide its App ID
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:show`
|
||||
|
||||
details about a particular revision
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:show
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/show.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/update/show.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
show the username you are logged in as
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [src/commands/whoami.ts](https://github.com/expo/eas-cli/blob/v0.0.0/src/commands/whoami.ts)_
|
||||
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/submit.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -1,3 +1,3 @@
|
||||
const getConfig = jest.fn();
|
||||
const getConfig = jest.fn(() => ({ exp: {} }));
|
||||
|
||||
export { getConfig };
|
||||
@@ -0,0 +1,7 @@
|
||||
export default jest.fn().mockReturnValue({
|
||||
start: jest.fn().mockReturnValue({
|
||||
fail: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
}),
|
||||
succeed: jest.fn(),
|
||||
});
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
overwrite: true
|
||||
schema: 'https://staging.exp.host/--/graphql'
|
||||
documents:
|
||||
- 'src/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
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,4 +2,5 @@ module.exports = {
|
||||
...require('../../jest/jest.config.js'),
|
||||
rootDir: __dirname,
|
||||
roots: ['src', '__mocks__'],
|
||||
globalSetup: './global-setup.ts',
|
||||
};
|
||||
@@ -1,74 +1,100 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@eas/config": "0.0.1",
|
||||
"@expo/config": "^3.3.9",
|
||||
"@expo/apple-utils": "0.0.0-alpha.17",
|
||||
"@expo/config": "~3.3.19",
|
||||
"@expo/config-plugins": "^1.0.14",
|
||||
"@expo/eas-build-job": "^0.2.0",
|
||||
"@expo/eas-json": "^0.2.0",
|
||||
"@expo/json-file": "^8.2.24",
|
||||
"@expo/plist": "^0.0.10",
|
||||
"@expo/plist": "^0.0.11",
|
||||
"@expo/plugin-warn-if-update-available": "^1.7.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "^1.5.0",
|
||||
"@hapi/joi": "^17.1.1",
|
||||
"@oclif/command": "^1",
|
||||
"@oclif/config": "^1",
|
||||
"@oclif/errors": "^1.3.3",
|
||||
"@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",
|
||||
"fast-glob": "^3.2.4",
|
||||
"figures": "^3.2.0",
|
||||
"form-data": "^3.0.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"getenv": "^1.0.0",
|
||||
"got": "^11.5.2",
|
||||
"graphql": "^15.3.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"indent-string": "^4.0.0",
|
||||
"keychain": "^1.3.0",
|
||||
"lodash": "^4.17.20",
|
||||
"md5-file": "^5.0.0",
|
||||
"mime": "^2.4.7",
|
||||
"minimatch": "^3.0.4",
|
||||
"node-fetch": "^2.6.1",
|
||||
"node-forge": "^0.10.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"ora": "^5.1.0",
|
||||
"pkg-dir": "^4.2.0",
|
||||
"progress": "^2.0.3",
|
||||
"prompts": "^2.3.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"tar": "^6.0.5",
|
||||
"tempy": "^0.7.0",
|
||||
"terminal-link": "^2.1.1",
|
||||
"timeago.js": "^4.0.2",
|
||||
"tslib": "^1",
|
||||
"turndown": "^7.0.0",
|
||||
"untildify": "^4.0.0",
|
||||
"uuid": "^8.3.0",
|
||||
"wordwrap": "^1.0.0"
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"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",
|
||||
"@expo/oclif-dev-cli": "1.23.0-expo",
|
||||
"@graphql-codegen/cli": "^1.19.2",
|
||||
"@graphql-codegen/introspection": "^1.18.0",
|
||||
"@graphql-codegen/typescript": "^1.17.11",
|
||||
"@graphql-codegen/typescript-operations": "^1.17.10",
|
||||
"@types/color-string": "^1.5.0",
|
||||
"@types/dateformat": "^3.0.1",
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/lodash": "^4.14.161",
|
||||
"@types/mime": "^2.0.3",
|
||||
"@types/node": "^12",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/node-forge": "^0.9.5",
|
||||
"@types/progress": "^2.0.3",
|
||||
"@types/prompts": "^2.0.8",
|
||||
"@types/prompts": "^2.0.9",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/tough-cookie": "^4.0.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/wordwrap": "^1.0.0",
|
||||
"@types/wrap-ansi": "^3.0.0",
|
||||
"@vercel/ncc": "^0.23.0",
|
||||
"axios": "^0.21.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
"graphql": "^15.3.0",
|
||||
"memfs": "^3.2.0",
|
||||
"tempy": "^0.7.0",
|
||||
"mockdate": "^3.0.2",
|
||||
"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": ">=8.0.0"
|
||||
},
|
||||
@@ -89,26 +115,53 @@
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
"plugins": [
|
||||
"@oclif/plugin-help"
|
||||
"@oclif/plugin-help",
|
||||
"@expo/plugin-warn-if-update-available"
|
||||
],
|
||||
"helpClass": "./build/help",
|
||||
"topics": {
|
||||
"build": {
|
||||
"description": "build app binaries"
|
||||
},
|
||||
"channel": {
|
||||
"description": "manage update channels"
|
||||
},
|
||||
"release": {
|
||||
"description": "manage update releases"
|
||||
},
|
||||
"update": {
|
||||
"description": "roll out changes"
|
||||
"description": "manage individual updates"
|
||||
},
|
||||
"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": {
|
||||
"unversioned": "<%- bin %>-<%- platform %>-<%- arch %><%- ext %>",
|
||||
"versioned": "<%- bin %>-<%- platform %>-<%- arch %><%- ext %>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"postpack": "rm -f oclif.manifest.json",
|
||||
"prepack": "rm -rf build && tsc -b && oclif-dev manifest && oclif-dev readme",
|
||||
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"version": "oclif-dev readme && git add README.md"
|
||||
"version": "yarn oclif-dev readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
|
||||
},
|
||||
"types": "build/index.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
interface MockProject {
|
||||
projectRoot: string;
|
||||
projectTree: Record<string, string>;
|
||||
appJSON: AppJSON;
|
||||
packageJSON: PackageJSON;
|
||||
}
|
||||
|
||||
interface PackageJSON {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
main: string;
|
||||
}
|
||||
|
||||
interface AppJSON {
|
||||
expo: {
|
||||
name: string;
|
||||
version: string;
|
||||
slug: string;
|
||||
sdkVersion: string;
|
||||
owner?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
function createTestProject(
|
||||
user: Actor,
|
||||
appJSONExtraData?: Record<string, object | string>
|
||||
): MockProject {
|
||||
const projectRoot = '/test-project';
|
||||
const packageJSON: PackageJSON = {
|
||||
name: 'testing123',
|
||||
version: '0.1.0',
|
||||
description: 'fake description',
|
||||
main: 'index.js',
|
||||
};
|
||||
|
||||
const owner = appJSONExtraData?.owner as string | undefined;
|
||||
const appJSON: AppJSON = {
|
||||
expo: {
|
||||
name: 'testing 123',
|
||||
version: '0.1.0',
|
||||
slug: 'testing-123',
|
||||
sdkVersion: '33.0.0',
|
||||
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
|
||||
...appJSONExtraData,
|
||||
},
|
||||
};
|
||||
return {
|
||||
appJSON,
|
||||
packageJSON,
|
||||
projectRoot,
|
||||
projectTree: {
|
||||
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
|
||||
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { createTestProject };
|
||||
+12
-37
@@ -1,16 +1,6 @@
|
||||
import { createClient as createUrqlClient } from '@urql/core';
|
||||
import got, { NormalizedOptions } from 'got';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { getAccessToken, getSessionSecret } from './user/User';
|
||||
|
||||
type AccessTokenHeaders = {
|
||||
authorization: string;
|
||||
};
|
||||
|
||||
type SessionHeaders = {
|
||||
'expo-session': string;
|
||||
};
|
||||
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
|
||||
|
||||
export const apiClient = got.extend({
|
||||
prefixUrl: getExpoApiBaseUrl() + '/--/api/v2/',
|
||||
@@ -31,37 +21,22 @@ export const apiClient = got.extend({
|
||||
},
|
||||
});
|
||||
|
||||
export const graphqlClient = createUrqlClient({
|
||||
url: getExpoApiBaseUrl() + '/--/graphql',
|
||||
// @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>'.
|
||||
fetch,
|
||||
fetchOptions: (): { headers?: AccessTokenHeaders | SessionHeaders } => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
return {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const sessionSecret = getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
return {
|
||||
headers: {
|
||||
'expo-session': sessionSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
export function getExpoApiBaseUrl(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return `https://staging.exp.host`;
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return `http://exp.test`;
|
||||
return `http://127.0.0.1:3000`;
|
||||
} else {
|
||||
return `https://exp.host`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getExpoWebsiteBaseUrl(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return `https://staging.expo.io`;
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return `http://expo.test`;
|
||||
} else {
|
||||
return `https://expo.io`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
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 EAS 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import log from '../../log';
|
||||
import { getProjectAccountName } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { ensureValidVersions } from '../utils/updates';
|
||||
|
||||
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
ensureValidVersions(exp);
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
|
||||
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
|
||||
|
||||
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
|
||||
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
|
||||
|
||||
await fs.writeFile(
|
||||
buildGradlePath,
|
||||
`${buildGradleContent}\n// Integration with Expo updates\n${gradleScriptApply}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
|
||||
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
|
||||
const result = await AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
|
||||
|
||||
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncUpdatesConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
ensureValidVersions(exp);
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
try {
|
||||
await ensureUpdatesConfiguredAsync(projectDir, exp);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
let androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
|
||||
|
||||
if (!AndroidConfig.Updates.areVersionsSynced(exp, androidManifest)) {
|
||||
androidManifest = AndroidConfig.Updates.setVersionsConfig(exp, androidManifest);
|
||||
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, androidManifest);
|
||||
}
|
||||
|
||||
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
|
||||
log.warn(
|
||||
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
|
||||
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
|
||||
|
||||
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
|
||||
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
|
||||
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
|
||||
}
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
|
||||
if (!AndroidConfig.Updates.isMainApplicationMetaDataSet(androidManifest)) {
|
||||
throw new Error('Missing values in AndroidManifest.xml');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import {
|
||||
ensureAppIdentifierIsDefinedAsync,
|
||||
getAndroidApplicationIdAsync,
|
||||
getProjectConfigDescription,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { gitAddAsync } from '../../utils/git';
|
||||
import { Platform } from '../types';
|
||||
|
||||
enum ApplicationIdSource {
|
||||
AndroidProject,
|
||||
AppJson,
|
||||
}
|
||||
|
||||
function isApplicationIdValid(applicationId: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId);
|
||||
}
|
||||
|
||||
export async function ensureApplicationIdIsValidAsync(projectDir: string) {
|
||||
const applicationId = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.Android);
|
||||
if (!isApplicationIdValid(applicationId)) {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
log.error(
|
||||
`Invalid format of Android applicationId. Only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter.`
|
||||
);
|
||||
log.error(`Update "android.package" in ${configDescription} and run this command again.`);
|
||||
throw new Error('Invalid applicationId');
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureApplicationIdAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
allowExperimental: boolean
|
||||
): Promise<void> {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
const applicationIdFromConfig = AndroidConfig.Package.getPackage(exp);
|
||||
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
|
||||
|
||||
if (applicationIdFromAndroidProject && applicationIdFromConfig) {
|
||||
if (applicationIdFromConfig !== applicationIdFromAndroidProject) {
|
||||
log.newLine();
|
||||
log.warn(
|
||||
`We detected that your Android project is configured with a different application id than the one defined in ${configDescription}.`
|
||||
);
|
||||
const hasApplicationIdInStaticConfig = await hasApplicationIdInStaticConfigAsync(
|
||||
projectDir,
|
||||
exp
|
||||
);
|
||||
if (!hasApplicationIdInStaticConfig) {
|
||||
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Android project with it.
|
||||
However, if you choose the one defined in the Android project you'll have to update ${configDescription} on your own.`);
|
||||
}
|
||||
log.newLine();
|
||||
const { applicationIdSource } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'applicationIdSource',
|
||||
message: 'Which application id should we use?',
|
||||
choices: [
|
||||
{
|
||||
title: `${chalk.bold(
|
||||
applicationIdFromAndroidProject
|
||||
)} - In build.gradle in Android project`,
|
||||
value: ApplicationIdSource.AndroidProject,
|
||||
},
|
||||
{
|
||||
title: `${chalk.bold(applicationIdFromConfig)} - In your ${configDescription}`,
|
||||
value: ApplicationIdSource.AppJson,
|
||||
},
|
||||
],
|
||||
});
|
||||
switch (applicationIdSource) {
|
||||
case ApplicationIdSource.AppJson: {
|
||||
await updateApplicationIdInBuildGradleAsync(projectDir, exp);
|
||||
break;
|
||||
}
|
||||
case ApplicationIdSource.AndroidProject: {
|
||||
if (hasApplicationIdInStaticConfig) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
|
||||
} else {
|
||||
throw new Error(missingPackageMessage(configDescription));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!applicationIdFromAndroidProject && !applicationIdFromConfig) {
|
||||
throw new Error(missingPackageMessage(configDescription));
|
||||
} else if (applicationIdFromAndroidProject && !applicationIdFromConfig) {
|
||||
if (getConfigFilePaths(projectDir).staticConfigPath) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, applicationIdFromAndroidProject);
|
||||
} else {
|
||||
throw new Error(missingPackageMessage(configDescription));
|
||||
}
|
||||
} else if (!applicationIdFromAndroidProject && applicationIdFromConfig) {
|
||||
// This should never happen, adding warning just in case
|
||||
log.warn(
|
||||
'applicationId is not specified in your ./android/app/build.gradle file. Make sure your project is configured correctly before building.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (allowExperimental) {
|
||||
// this step is optional, the Play store is using applicationId value from build.gradle
|
||||
// to identify the app, so package name does not need to be updated
|
||||
await updatePackageNameAsync(projectDir, exp);
|
||||
await gitAddAsync(path.join(projectDir, 'android'), { intentToAdd: true });
|
||||
}
|
||||
}
|
||||
|
||||
function missingPackageMessage(configDescription: string): string {
|
||||
return `Please define "android.package" in ${configDescription} and run "eas build:configure" again.`;
|
||||
}
|
||||
|
||||
async function updateAppJsonConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
newApplicationId: string
|
||||
): Promise<void> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
assert(paths.staticConfigPath, "can't update dynamic configs");
|
||||
|
||||
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
...rawStaticConfig.expo,
|
||||
android: { ...rawStaticConfig.expo?.android, package: newApplicationId },
|
||||
};
|
||||
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
|
||||
|
||||
exp.android = { ...exp.android, package: newApplicationId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if static config exists and if expo.android.package is defined there.
|
||||
* It will return false if the value in static config is different than "android.package" in ExpoConfig
|
||||
*/
|
||||
async function hasApplicationIdInStaticConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<boolean> {
|
||||
if (!exp.android?.package) {
|
||||
return false;
|
||||
}
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
return false;
|
||||
}
|
||||
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
|
||||
return rawStaticConfig?.expo?.android?.package === exp.android.package;
|
||||
}
|
||||
|
||||
async function updateApplicationIdInBuildGradleAsync(projectDir: string, exp: ExpoConfig) {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
|
||||
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
|
||||
const updatedBuildGradleContents = AndroidConfig.Package.setPackageInBuildGradle(
|
||||
exp,
|
||||
buildGradleContents
|
||||
);
|
||||
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
|
||||
}
|
||||
|
||||
async function updatePackageNameAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
await AndroidConfig.Package.renamePackageOnDisk(exp, projectDir);
|
||||
|
||||
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
|
||||
if (!androidManifestPath) {
|
||||
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
|
||||
}
|
||||
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(
|
||||
androidManifestPath
|
||||
);
|
||||
const updatedAndroidManifest = AndroidConfig.Package.setPackageInAndroidManifest(
|
||||
exp,
|
||||
androidManifest
|
||||
);
|
||||
await AndroidConfig.Manifest.writeAndroidManifestAsync(
|
||||
androidManifestPath,
|
||||
updatedAndroidManifest
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { EasConfig } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import AndroidCredentialsProvider, {
|
||||
AndroidCredentials,
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { createCredentialsContextAsync } from '../../credentials/context';
|
||||
import log from '../../log';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { CredentialsResult, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContext } from '../context';
|
||||
import { ensureCredentialsAsync } from '../credentials';
|
||||
import { Platform } from '../types';
|
||||
import { ensureApplicationIdIsValidAsync } from './applicationId';
|
||||
import { validateAndSyncProjectConfigurationAsync } from './configure';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
|
||||
export async function prepareAndroidBuildAsync(
|
||||
commandCtx: CommandContext,
|
||||
easConfig: EasConfig
|
||||
): Promise<() => Promise<string>> {
|
||||
const buildCtx = createBuildContext<Platform.Android>({
|
||||
commandCtx,
|
||||
platform: Platform.Android,
|
||||
easConfig,
|
||||
});
|
||||
const { buildProfile } = buildCtx;
|
||||
|
||||
if (
|
||||
buildProfile.workflow === Workflow.Generic &&
|
||||
!(await fs.pathExists(path.join(commandCtx.projectDir, 'android')))
|
||||
) {
|
||||
throw new Error(
|
||||
`"android" directory not found. If you're trying to build a managed project, set ${chalk.bold(
|
||||
`builds.android.${commandCtx.profile}.workflow`
|
||||
)} in "eas.json" to "managed".`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
buildProfile.workflow === Workflow.Generic &&
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureApplicationIdIsValidAsync(commandCtx.projectDir);
|
||||
|
||||
return await prepareBuildRequestForPlatformAsync({
|
||||
ctx: buildCtx,
|
||||
projectConfiguration: {},
|
||||
ensureCredentialsAsync: ensureAndroidCredentialsAsync,
|
||||
ensureProjectConfiguredAsync: async () => {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
}
|
||||
},
|
||||
prepareJobAsync,
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
|
||||
if (!shouldProvideCredentials(ctx)) {
|
||||
return;
|
||||
}
|
||||
const provider = new AndroidCredentialsProvider(
|
||||
await createCredentialsContextAsync(ctx.commandCtx.projectDir, {
|
||||
nonInteractive: ctx.commandCtx.nonInteractive,
|
||||
}),
|
||||
{
|
||||
app: {
|
||||
projectName: ctx.commandCtx.projectName,
|
||||
accountName: ctx.commandCtx.accountName,
|
||||
},
|
||||
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
|
||||
}
|
||||
);
|
||||
const credentialsSource = await ensureCredentialsAsync(
|
||||
provider,
|
||||
ctx.buildProfile.workflow,
|
||||
ctx.buildProfile.credentialsSource,
|
||||
ctx.commandCtx.nonInteractive
|
||||
);
|
||||
return {
|
||||
credentials: await provider.getCredentialsAsync(credentialsSource),
|
||||
source: credentialsSource,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
|
||||
import log from '../../log';
|
||||
import { getAndroidApplicationIdAsync } from '../../project/projectUtils';
|
||||
import { gitAddAsync } from '../../utils/git';
|
||||
import { ConfigureContext } from '../context';
|
||||
import { isExpoUpdatesInstalled } from '../utils/updates';
|
||||
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
|
||||
import { configureApplicationIdAsync, ensureApplicationIdIsValidAsync } from './applicationId';
|
||||
|
||||
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (!ctx.hasAndroidNativeProject) {
|
||||
return;
|
||||
}
|
||||
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
|
||||
await configureApplicationIdAsync(ctx.projectDir, ctx.exp, ctx.allowExperimental);
|
||||
await ensureApplicationIdIsValidAsync(ctx.projectDir);
|
||||
|
||||
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
|
||||
await gitAddAsync(easGradlePath, { intentToAdd: true });
|
||||
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
log.withTick('Android project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
const applicationIdFromAndroidProject = await getAndroidApplicationIdAsync(projectDir);
|
||||
const packageNameFromConfig = AndroidConfig.Package.getPackage(exp);
|
||||
const isApplicationIdConfigured =
|
||||
applicationIdFromAndroidProject && applicationIdFromAndroidProject === packageNameFromConfig;
|
||||
|
||||
const isEasBuildGradleConfigured = await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(
|
||||
projectDir
|
||||
);
|
||||
if (!isEasBuildGradleConfigured || !isApplicationIdConfigured) {
|
||||
throw new Error(
|
||||
'Project is not configured. Please run "eas build:configure" to configure the project.'
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Android,
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
Workflow,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { AndroidGenericBuildProfile, AndroidManagedBuildProfile } from '@expo/eas-json';
|
||||
import path from 'path';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { readSecretEnvsAsync } from '../../credentials/credentialsJson/read';
|
||||
import { getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { gitRootDirectoryAsync } from '../../utils/git';
|
||||
import { BuildContext } from '../context';
|
||||
import { Platform } from '../types';
|
||||
|
||||
interface JobData {
|
||||
archiveBucketKey: string;
|
||||
credentials?: AndroidCredentials;
|
||||
}
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
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;
|
||||
projectArchive: ArchiveSource;
|
||||
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: {
|
||||
keystore: {
|
||||
dataBase64: credentials.keystore.keystore,
|
||||
keystorePassword: credentials.keystore.keystorePassword,
|
||||
keyAlias: credentials.keystore.keyAlias,
|
||||
keyPassword: credentials.keystore.keyPassword,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
platform: Platform.Android,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: jobData.archiveBucketKey,
|
||||
},
|
||||
secrets: {
|
||||
...(secretEnvs ? { secretEnvs } : {}),
|
||||
...buildCredentials,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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: Workflow.Generic,
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareManagedJobAsync(
|
||||
ctx: BuildContext<Platform.Android>,
|
||||
jobData: JobData,
|
||||
buildProfile: AndroidManagedBuildProfile
|
||||
): Promise<Partial<Android.ManagedJob>> {
|
||||
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, jobData)),
|
||||
type: Workflow.Managed,
|
||||
username: accountName,
|
||||
buildType: buildProfile.buildType,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import log from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { UploadType, uploadAsync } from '../uploads';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
import { platformDisplayNames } from './constants';
|
||||
import { BuildContext } from './context';
|
||||
import { collectMetadata } from './metadata';
|
||||
import { AnalyticsEvent, Platform, TrackingContext } from './types';
|
||||
import Analytics from './utils/analytics';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
commitPromptAsync,
|
||||
isGitStatusCleanAsync,
|
||||
makeProjectTarballAsync,
|
||||
showDiffAsync,
|
||||
} from './utils/repository';
|
||||
|
||||
export interface CredentialsResult<Credentials> {
|
||||
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
credentials: Credentials;
|
||||
}
|
||||
|
||||
interface Builder<TPlatform extends Platform, Credentials, ProjectConfiguration> {
|
||||
ctx: BuildContext<TPlatform>;
|
||||
projectConfiguration: ProjectConfiguration;
|
||||
|
||||
ensureCredentialsAsync(
|
||||
ctx: BuildContext<TPlatform>
|
||||
): Promise<CredentialsResult<Credentials> | undefined>;
|
||||
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
|
||||
prepareJobAsync(
|
||||
ctx: BuildContext<TPlatform>,
|
||||
jobData: {
|
||||
archiveBucketKey: string;
|
||||
credentials?: Credentials;
|
||||
projectConfiguration?: ProjectConfiguration;
|
||||
}
|
||||
): Promise<Job>;
|
||||
}
|
||||
|
||||
export async function prepareBuildRequestForPlatformAsync<
|
||||
TPlatform extends Platform,
|
||||
Credentials,
|
||||
ProjectConfiguration
|
||||
>(builder: Builder<TPlatform, Credentials, ProjectConfiguration>): Promise<() => Promise<string>> {
|
||||
const credentialsResult = await withAnalyticsAsync(
|
||||
async () => await builder.ensureCredentialsAsync(builder.ctx),
|
||||
{
|
||||
successEvent: AnalyticsEvent.GATHER_CREDENTIALS_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.GATHER_CREDENTIALS_FAIL,
|
||||
trackingCtx: builder.ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
log.addNewLineIfNone();
|
||||
const projectType = builder.ctx.platform === Platform.Android ? 'Android' : 'Xcode';
|
||||
// Currently we are only updaing runtime version durring build, but if it changes in a future
|
||||
// this message should also contain more info on that (or be more generic)
|
||||
await reviewAndCommitChangesAsync(`Update runtime version in the ${projectType} project`, {
|
||||
nonInteractive: builder.ctx.commandCtx.nonInteractive,
|
||||
});
|
||||
}
|
||||
|
||||
const archiveBucketKey = await uploadProjectAsync(builder.ctx);
|
||||
|
||||
const metadata = await collectMetadata(builder.ctx, {
|
||||
credentialsSource: credentialsResult?.source,
|
||||
});
|
||||
const job = await builder.prepareJobAsync(builder.ctx, {
|
||||
archiveBucketKey,
|
||||
credentials: credentialsResult?.credentials,
|
||||
projectConfiguration: builder.projectConfiguration,
|
||||
});
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
return await withAnalyticsAsync(
|
||||
async () => {
|
||||
if (log.isDebug) {
|
||||
log(`Starting ${platformDisplayNames[job.platform]} build`);
|
||||
}
|
||||
const {
|
||||
data: { buildId, deprecationInfo },
|
||||
} = await apiClient
|
||||
.post(`projects/${builder.ctx.commandCtx.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) {
|
||||
const body = error?.response?.body;
|
||||
if (body && JSON.parse(body)?.errors?.[0]?.code === 'TURTLE_DEPRECATED_JOB_FORMAT') {
|
||||
log.error('EAS Build API has changed, please upgrade to the latest eas-cli');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
ctx: BuildContext<TPlatform>
|
||||
): Promise<string> {
|
||||
let projectTarballPath;
|
||||
try {
|
||||
return await withAnalyticsAsync(
|
||||
async () => {
|
||||
const projectTarball = await makeProjectTarballAsync();
|
||||
projectTarballPath = projectTarball.path;
|
||||
|
||||
const { bucketKey } = await uploadAsync(
|
||||
UploadType.TURTLE_PROJECT_SOURCES,
|
||||
projectTarball.path,
|
||||
createProgressTracker({
|
||||
total: projectTarball.size,
|
||||
message: 'Uploading to EAS Build',
|
||||
completedMessage: `Uploaded to EAS`,
|
||||
})
|
||||
);
|
||||
return bucketKey;
|
||||
},
|
||||
{
|
||||
successEvent: AnalyticsEvent.PROJECT_UPLOAD_SUCCESS,
|
||||
failureEvent: AnalyticsEvent.PROJECT_UPLOAD_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (projectTarballPath) {
|
||||
await fs.remove(projectTarballPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withAnalyticsAsync<Result>(
|
||||
fn: () => Promise<Result>,
|
||||
analytics: {
|
||||
successEvent: AnalyticsEvent;
|
||||
failureEvent: AnalyticsEvent;
|
||||
trackingCtx: TrackingContext;
|
||||
}
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
enum ShouldCommitChanges {
|
||||
Yes,
|
||||
ShowDiffFirst,
|
||||
Abort,
|
||||
}
|
||||
|
||||
async function reviewAndCommitChangesAsync(
|
||||
commitMessage: string,
|
||||
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
|
||||
): Promise<void> {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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(commitMessage);
|
||||
log.withTick('Committed changes.');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, { nonInteractive, askedFirstTime: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { gitAddAsync } from '../utils/git';
|
||||
import { configureAndroidAsync } from './android/configure';
|
||||
import { ConfigureContext } from './context';
|
||||
import { configureIosAsync } from './ios/configure';
|
||||
import { RequestedPlatform } from './types';
|
||||
import {
|
||||
commitPromptAsync,
|
||||
ensureGitRepoExistsAsync,
|
||||
isGitStatusCleanAsync,
|
||||
maybeBailOnGitStatusAsync,
|
||||
showDiffAsync,
|
||||
} from './utils/repository';
|
||||
|
||||
const configureCommitMessage = {
|
||||
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
|
||||
[RequestedPlatform.iOS]: 'Configure EAS Build for iOS',
|
||||
[RequestedPlatform.All]: 'Configure EAS Build',
|
||||
};
|
||||
|
||||
export async function configureAsync(options: {
|
||||
platform: RequestedPlatform;
|
||||
projectDir: string;
|
||||
allowExperimental: boolean;
|
||||
}): Promise<void> {
|
||||
await ensureGitRepoExistsAsync();
|
||||
await maybeBailOnGitStatusAsync();
|
||||
|
||||
const { exp } = getConfig(options.projectDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
const ctx: ConfigureContext = {
|
||||
user: await ensureLoggedInAsync(),
|
||||
projectDir: options.projectDir,
|
||||
exp,
|
||||
allowExperimental: options.allowExperimental,
|
||||
requestedPlatform: options.platform,
|
||||
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].includes(
|
||||
options.platform
|
||||
),
|
||||
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.iOS].includes(options.platform),
|
||||
hasAndroidNativeProject: await fs.pathExists(path.join(options.projectDir, 'android')),
|
||||
hasIosNativeProject: await fs.pathExists(path.join(options.projectDir, 'ios')),
|
||||
};
|
||||
|
||||
log.newLine();
|
||||
await ensureEasJsonExistsAsync(ctx);
|
||||
if (ctx.shouldConfigureAndroid) {
|
||||
await configureAndroidAsync(ctx);
|
||||
}
|
||||
if (ctx.shouldConfigureIos) {
|
||||
await configureIosAsync(ctx);
|
||||
}
|
||||
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
log.newLine();
|
||||
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
|
||||
} else {
|
||||
log.newLine();
|
||||
log.withTick('No changes were necessary, the project is already configured correctly.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<void> {
|
||||
const easJsonPath = path.join(ctx.projectDir, 'eas.json');
|
||||
let existingEasJson;
|
||||
|
||||
if (await fs.pathExists(easJsonPath)) {
|
||||
const reader = new EasJsonReader(ctx.projectDir, ctx.requestedPlatform);
|
||||
await reader.validateAsync();
|
||||
|
||||
existingEasJson = await reader.readRawAsync();
|
||||
log.withTick('Validated eas.json');
|
||||
|
||||
// If we have already populated eas.json with the default fields for the
|
||||
// platform then proceed
|
||||
if (ctx.requestedPlatform !== 'all' && existingEasJson.builds[ctx.requestedPlatform]) {
|
||||
return;
|
||||
} else if (
|
||||
ctx.requestedPlatform === 'all' &&
|
||||
existingEasJson.builds.ios &&
|
||||
existingEasJson.builds.android
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldInitIOS =
|
||||
['all', 'ios'].includes(ctx.requestedPlatform) && !existingEasJson?.builds.ios;
|
||||
const shouldInitAndroid =
|
||||
['all', 'android'].includes(ctx.requestedPlatform) && !existingEasJson?.builds.android;
|
||||
|
||||
const easJson = {
|
||||
builds: {
|
||||
...existingEasJson,
|
||||
...(shouldInitAndroid
|
||||
? {
|
||||
android: {
|
||||
release: {
|
||||
workflow: ctx.hasAndroidNativeProject ? 'generic' : 'managed',
|
||||
},
|
||||
},
|
||||
}
|
||||
: null),
|
||||
...(shouldInitIOS
|
||||
? {
|
||||
ios: {
|
||||
release: {
|
||||
workflow: ctx.hasIosNativeProject ? 'generic' : 'managed',
|
||||
},
|
||||
},
|
||||
}
|
||||
: null),
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
|
||||
await gitAddAsync(easJsonPath, { intentToAdd: true });
|
||||
log.withTick(`${existingEasJson ? 'Updated' : 'Generated'} eas.json`);
|
||||
}
|
||||
|
||||
enum ShouldCommitChanges {
|
||||
Yes,
|
||||
ShowDiffFirst,
|
||||
Skip,
|
||||
}
|
||||
|
||||
async function reviewAndCommitChangesAsync(
|
||||
commitMessage: string,
|
||||
askedFirstTime: boolean = true
|
||||
): Promise<void> {
|
||||
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: 'Skip committing changes, I will do it later on my own',
|
||||
value: ShouldCommitChanges.Skip,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (selected === ShouldCommitChanges.Yes) {
|
||||
await commitPromptAsync(commitMessage);
|
||||
log.withTick('Committed changes');
|
||||
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
|
||||
await showDiffAsync();
|
||||
await reviewAndCommitChangesAsync(commitMessage, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Platform, RequestedPlatform } from './types';
|
||||
|
||||
export const platformDisplayNames = {
|
||||
[RequestedPlatform.iOS]: 'iOS',
|
||||
[RequestedPlatform.Android]: 'Android',
|
||||
[RequestedPlatform.All]: 'Android and iOS',
|
||||
};
|
||||
|
||||
export const platformEmojis = {
|
||||
[Platform.iOS]: '🍎',
|
||||
[Platform.Android]: '🤖',
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ExpoConfig, getConfig } from '@expo/config';
|
||||
import { AndroidBuildProfile, EasConfig, iOSBuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { Platform, RequestedPlatform, TrackingContext } from './types';
|
||||
|
||||
export interface CommandContext {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
profile: string;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
user: Actor;
|
||||
accountName: string;
|
||||
projectName: string;
|
||||
exp: ExpoConfig;
|
||||
trackingCtx: TrackingContext;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
skipProjectConfiguration: boolean;
|
||||
waitForBuildEnd: boolean;
|
||||
}
|
||||
|
||||
export async function createCommandContextAsync({
|
||||
requestedPlatform,
|
||||
profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive = false,
|
||||
skipCredentialsCheck = false,
|
||||
skipProjectConfiguration = false,
|
||||
waitForBuildEnd,
|
||||
}: {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
profile: string;
|
||||
projectId: 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 = getProjectAccountName(exp, user);
|
||||
const projectName = exp.slug;
|
||||
|
||||
return {
|
||||
requestedPlatform,
|
||||
profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
user,
|
||||
accountName,
|
||||
projectName,
|
||||
exp,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck,
|
||||
skipProjectConfiguration,
|
||||
waitForBuildEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConfigureContext {
|
||||
user: Actor;
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
allowExperimental: boolean;
|
||||
requestedPlatform: RequestedPlatform;
|
||||
shouldConfigureAndroid: boolean;
|
||||
shouldConfigureIos: boolean;
|
||||
hasAndroidNativeProject: boolean;
|
||||
hasIosNativeProject: boolean;
|
||||
}
|
||||
|
||||
type PlatformBuildProfile<T extends Platform> = T extends Platform.Android
|
||||
? AndroidBuildProfile
|
||||
: iOSBuildProfile;
|
||||
|
||||
export interface BuildContext<T extends Platform> {
|
||||
commandCtx: CommandContext;
|
||||
trackingCtx: TrackingContext;
|
||||
platform: T;
|
||||
buildProfile: PlatformBuildProfile<T>;
|
||||
}
|
||||
|
||||
export function createBuildContext<T extends Platform>({
|
||||
platform,
|
||||
easConfig,
|
||||
commandCtx,
|
||||
}: {
|
||||
platform: T;
|
||||
easConfig: EasConfig;
|
||||
commandCtx: CommandContext;
|
||||
}): 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../api';
|
||||
import log from '../log';
|
||||
import { sleep } from '../utils/promise';
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
import { CommandContext } from './context';
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { Build, BuildStatus, Platform, RequestedPlatform } 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(commandCtx.nonInteractive);
|
||||
|
||||
const scheduledBuilds = await startBuildsAsync(commandCtx);
|
||||
log.newLine();
|
||||
printLogsUrls(commandCtx.accountName, scheduledBuilds);
|
||||
log.newLine();
|
||||
|
||||
if (commandCtx.waitForBuildEnd) {
|
||||
const builds = await waitForBuildEndAsync(
|
||||
commandCtx,
|
||||
scheduledBuilds.map(i => i.buildId)
|
||||
);
|
||||
log.newLine();
|
||||
printBuildResults(commandCtx.accountName, builds);
|
||||
}
|
||||
}
|
||||
|
||||
async function startBuildsAsync(
|
||||
commandCtx: CommandContext
|
||||
): Promise<{ platform: Platform; buildId: string }[]> {
|
||||
const shouldBuildAndroid = [RequestedPlatform.Android, RequestedPlatform.All].includes(
|
||||
commandCtx.requestedPlatform
|
||||
);
|
||||
const shouldBuildiOS = [RequestedPlatform.iOS, RequestedPlatform.All].includes(
|
||||
commandCtx.requestedPlatform
|
||||
);
|
||||
const easConfig = await new EasJsonReader(
|
||||
commandCtx.projectDir,
|
||||
commandCtx.requestedPlatform
|
||||
).readAsync(commandCtx.profile);
|
||||
|
||||
const builds: {
|
||||
platform: Platform;
|
||||
sendBuildRequestAsync: () => Promise<string>;
|
||||
}[] = [];
|
||||
if (shouldBuildAndroid) {
|
||||
const sendBuildRequestAsync = await prepareAndroidBuildAsync(commandCtx, easConfig);
|
||||
builds.push({ platform: Platform.Android, sendBuildRequestAsync });
|
||||
}
|
||||
if (shouldBuildiOS) {
|
||||
const sendBuildRequestAsync = await prepareIosBuildAsync(commandCtx, easConfig);
|
||||
builds.push({ platform: Platform.iOS, sendBuildRequestAsync });
|
||||
}
|
||||
return Promise.all(
|
||||
builds.map(async ({ platform, sendBuildRequestAsync }) => ({
|
||||
platform,
|
||||
buildId: await sendBuildRequestAsync(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForBuildEndAsync(
|
||||
commandCtx: CommandContext,
|
||||
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/${commandCtx.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 builds 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...'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { CredentialsProvider } from '../credentials/CredentialsProvider';
|
||||
import log from '../log';
|
||||
import { confirmAsync, promptAsync } from '../prompts';
|
||||
import { platformDisplayNames } from './constants';
|
||||
|
||||
function logCredentials(target: 'local' | 'remote', platform: Platform) {
|
||||
let message = `Using ${target} ${platformDisplayNames[platform]} credentials`;
|
||||
if (target === 'local') message += ` ${chalk.dim('(credentials.json)')}`;
|
||||
if (target === 'remote') message += ` ${chalk.dim('(Expo server)')}`;
|
||||
log.succeed(message);
|
||||
}
|
||||
|
||||
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 EAS 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 EAS servers`
|
||||
);
|
||||
}
|
||||
|
||||
const { select } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'select',
|
||||
message: 'Which credentials do you want to use for this build?',
|
||||
choices: [
|
||||
{ title: 'Local credentials.json', value: CredentialsSource.LOCAL },
|
||||
{ title: 'Credentials stored on EAS servers.', value: CredentialsSource.REMOTE },
|
||||
],
|
||||
});
|
||||
return select;
|
||||
} else {
|
||||
return CredentialsSource.LOCAL;
|
||||
}
|
||||
} else if (hasLocal) {
|
||||
logCredentials('local', provider.platform);
|
||||
return CredentialsSource.LOCAL;
|
||||
} else if (hasRemote) {
|
||||
logCredentials('remote', provider.platform);
|
||||
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 if (log.isDebug) {
|
||||
log.warn(
|
||||
`Credentials for this app are not configured and there is no entry in credentials.json for ${platform}`
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${platform} credentials not found, generate now?`,
|
||||
});
|
||||
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:
|
||||
logCredentials('local', provider.platform);
|
||||
return CredentialsSource.LOCAL;
|
||||
case CredentialsSource.REMOTE:
|
||||
logCredentials('remote', provider.platform);
|
||||
return CredentialsSource.REMOTE;
|
||||
case CredentialsSource.AUTO:
|
||||
return await ensureCredentialsAutoAsync(provider, workflow, nonInteractive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import plist from '@expo/plist';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import { getProjectAccountName } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { gitAddAsync } from '../../utils/git';
|
||||
import { ensureValidVersions } from '../utils/updates';
|
||||
|
||||
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
ensureValidVersions(exp);
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
let xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
|
||||
|
||||
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
|
||||
xcodeProject = IOSConfig.Updates.ensureBundleReactNativePhaseContainsConfigurationScript(
|
||||
projectDir,
|
||||
exp,
|
||||
xcodeProject
|
||||
);
|
||||
await fs.writeFile(IOSConfig.Paths.getPBXProjectPath(projectDir), xcodeProject.writeSync());
|
||||
}
|
||||
|
||||
let expoPlist = await readExpoPlistAsync(projectDir);
|
||||
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
|
||||
expoPlist = IOSConfig.Updates.setUpdatesConfig(exp, expoPlist, accountName);
|
||||
await writeExpoPlistAsync(projectDir, expoPlist);
|
||||
}
|
||||
// TODO: ensure ExpoPlist in pbxproj
|
||||
}
|
||||
|
||||
export async function syncUpdatesConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
ensureValidVersions(exp);
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
try {
|
||||
await ensureUpdatesConfiguredAsync(projectDir, exp);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let expoPlist = await readExpoPlistAsync(projectDir);
|
||||
if (!IOSConfig.Updates.isPlistVersionConfigurationSynced(exp, expoPlist)) {
|
||||
expoPlist = IOSConfig.Updates.setVersionsConfig(exp, expoPlist);
|
||||
await writeExpoPlistAsync(projectDir, expoPlist);
|
||||
}
|
||||
|
||||
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
|
||||
log.warn(
|
||||
'Native project configuration is not synced with values present in you app.json, run "eas build:configure" to make sure all values are applied in the native project'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
|
||||
const xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
|
||||
|
||||
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, exp, xcodeProject)) {
|
||||
const script = 'expo-updates/scripts/create-manifest-ios.sh';
|
||||
const buildPhase = '"Bundle React Native code and images"';
|
||||
throw new Error(`Path to ${script} is missing in a ${buildPhase} build phase.`);
|
||||
}
|
||||
|
||||
const expoPlist = await readExpoPlistAsync(projectDir);
|
||||
if (!IOSConfig.Updates.isPlistConfigurationSet(expoPlist)) {
|
||||
throw new Error('Missing values in Expo.plist');
|
||||
}
|
||||
}
|
||||
|
||||
async function readExpoPlistAsync(projectDir: string): Promise<IOSConfig.ExpoPlist> {
|
||||
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
|
||||
|
||||
let expoPlist = {};
|
||||
if (await fs.pathExists(expoPlistPath)) {
|
||||
const expoPlistContent = await fs.readFile(expoPlistPath, 'utf8');
|
||||
expoPlist = plist.parse(expoPlistContent);
|
||||
}
|
||||
return expoPlist;
|
||||
}
|
||||
|
||||
async function writeExpoPlistAsync(
|
||||
projectDir: string,
|
||||
expoPlist: IOSConfig.ExpoPlist
|
||||
): Promise<void> {
|
||||
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
|
||||
const expoPlistContent = plist.build(expoPlist);
|
||||
|
||||
await fs.mkdirp(path.dirname(expoPlistPath));
|
||||
await fs.writeFile(expoPlistPath, expoPlistContent);
|
||||
await gitAddAsync(expoPlistPath, { intentToAdd: true });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { EasConfig } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommandContext, createBuildContext } from '../context';
|
||||
import { Platform } from '../types';
|
||||
import { ensureBundleIdentifierIsValidAsync } from './bundleIdentifer';
|
||||
import { validateAndSyncProjectConfigurationAsync } from './configure';
|
||||
import { ensureIosCredentialsAsync } from './credentials';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
|
||||
export async function prepareIosBuildAsync(
|
||||
commandCtx: CommandContext,
|
||||
easConfig: EasConfig
|
||||
): Promise<() => Promise<string>> {
|
||||
const buildCtx = createBuildContext<Platform.iOS>({
|
||||
commandCtx,
|
||||
platform: Platform.iOS,
|
||||
easConfig,
|
||||
});
|
||||
|
||||
if (
|
||||
buildCtx.buildProfile.workflow === Workflow.Generic &&
|
||||
!(await fs.pathExists(path.join(commandCtx.projectDir, 'ios')))
|
||||
) {
|
||||
throw new Error(
|
||||
`"ios" directory not found. If you're trying to build a managed project, set ${chalk.bold(
|
||||
`builds.ios.${commandCtx.profile}.workflow`
|
||||
)} in "eas.json" to "managed".`
|
||||
);
|
||||
}
|
||||
|
||||
let iosNativeProjectScheme: string | undefined;
|
||||
let iosApplicationNativeTarget: string | undefined;
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
iosNativeProjectScheme = buildCtx.buildProfile.scheme ?? (await resolveSchemeAsync(buildCtx));
|
||||
iosApplicationNativeTarget = await IOSConfig.Target.getApplicationTargetForSchemeAsync(
|
||||
buildCtx.commandCtx.projectDir,
|
||||
iosNativeProjectScheme
|
||||
);
|
||||
}
|
||||
|
||||
await ensureBundleIdentifierIsValidAsync(commandCtx.projectDir);
|
||||
|
||||
return await prepareBuildRequestForPlatformAsync({
|
||||
ctx: buildCtx,
|
||||
projectConfiguration: {
|
||||
iosNativeProjectScheme,
|
||||
iosApplicationNativeTarget,
|
||||
},
|
||||
ensureCredentialsAsync: ensureIosCredentialsAsync,
|
||||
ensureProjectConfiguredAsync: async () => {
|
||||
if (buildCtx.buildProfile.workflow === Workflow.Generic) {
|
||||
await validateAndSyncProjectConfigurationAsync(commandCtx.projectDir, commandCtx.exp);
|
||||
}
|
||||
},
|
||||
prepareJobAsync,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSchemeAsync(ctx: BuildContext<Platform.iOS>): Promise<string> {
|
||||
const schemes = IOSConfig.Scheme.getSchemesFromXcodeproj(ctx.commandCtx.projectDir);
|
||||
if (schemes.length === 1) {
|
||||
return schemes[0];
|
||||
}
|
||||
|
||||
const sortedSchemes = sortBy(schemes);
|
||||
log.newLine();
|
||||
log(
|
||||
`We've found multiple schemes in your Xcode project: ${chalk.bold(sortedSchemes.join(', '))}`
|
||||
);
|
||||
log(
|
||||
`You can specify the scheme you want to build at ${chalk.bold(
|
||||
`builds.ios.${ctx.commandCtx.profile}.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import log from '../../log';
|
||||
import {
|
||||
ensureAppIdentifierIsDefinedAsync,
|
||||
getProjectConfigDescription,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { Platform } from '../types';
|
||||
|
||||
enum BundleIdentiferSource {
|
||||
XcodeProject,
|
||||
AppJson,
|
||||
}
|
||||
|
||||
function isBundleIdentifierValid(bundleIdentifier: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(bundleIdentifier);
|
||||
}
|
||||
|
||||
export async function ensureBundleIdentifierIsValidAsync(projectDir: string) {
|
||||
const bundleIdentifier = await ensureAppIdentifierIsDefinedAsync(projectDir, Platform.iOS);
|
||||
if (!isBundleIdentifierValid(bundleIdentifier)) {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
log.error(
|
||||
`Invalid format of iOS bundleId. Only alphanumeric characters, '.' and '-' are allowed, and each '.' must be followed by a letter.`
|
||||
);
|
||||
log.error(`Update "ios.bundleIdentifier" in ${configDescription} and run this command again.`);
|
||||
throw new Error('Invalid bundleIdentifier');
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureBundleIdentifierAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
const configDescription = getProjectConfigDescription(projectDir);
|
||||
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
|
||||
projectDir
|
||||
);
|
||||
const bundleIdentifierFromConfig = IOSConfig.BundleIdenitifer.getBundleIdentifier(exp);
|
||||
if (bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
|
||||
if (bundleIdentifierFromPbxproj !== bundleIdentifierFromConfig) {
|
||||
log.addNewLineIfNone();
|
||||
log.warn(
|
||||
`We detected that your Xcode project is configured with a different bundle identifier than the one defined in ${configDescription}.`
|
||||
);
|
||||
const hasBundleIdentifierInStaticConfig = await hasBundleIdentifierInStaticConfigAsync(
|
||||
projectDir,
|
||||
exp
|
||||
);
|
||||
if (!hasBundleIdentifierInStaticConfig) {
|
||||
log(`If you choose the one defined in ${configDescription} we'll automatically configure your Xcode project with it.
|
||||
However, if you choose the one defined in the Xcode project you'll have to update ${configDescription} on your own.`);
|
||||
}
|
||||
log.newLine();
|
||||
const { bundleIdentifierSource } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'bundleIdentifierSource',
|
||||
message: 'Which bundle identifier should we use?',
|
||||
choices: [
|
||||
{
|
||||
title: `${chalk.bold(bundleIdentifierFromPbxproj)} - In Xcode project`,
|
||||
value: BundleIdentiferSource.XcodeProject,
|
||||
},
|
||||
{
|
||||
title: `${chalk.bold(bundleIdentifierFromConfig)} - In your ${configDescription}`,
|
||||
value: BundleIdentiferSource.AppJson,
|
||||
},
|
||||
],
|
||||
});
|
||||
switch (bundleIdentifierSource) {
|
||||
case BundleIdentiferSource.AppJson: {
|
||||
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
|
||||
projectDir,
|
||||
bundleIdentifierFromConfig
|
||||
);
|
||||
break;
|
||||
}
|
||||
case BundleIdentiferSource.XcodeProject: {
|
||||
if (hasBundleIdentifierInStaticConfig) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
|
||||
} else {
|
||||
throw new Error(missingBundleIdentifierMessage(configDescription));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
|
||||
throw new Error(missingBundleIdentifierMessage(configDescription));
|
||||
} else if (bundleIdentifierFromPbxproj && !bundleIdentifierFromConfig) {
|
||||
if (getConfigFilePaths(projectDir).staticConfigPath) {
|
||||
await updateAppJsonConfigAsync(projectDir, exp, bundleIdentifierFromPbxproj);
|
||||
} else {
|
||||
throw new Error(missingBundleIdentifierMessage(configDescription));
|
||||
}
|
||||
} else if (!bundleIdentifierFromPbxproj && bundleIdentifierFromConfig) {
|
||||
IOSConfig.BundleIdenitifer.setBundleIdentifierForPbxproj(
|
||||
projectDir,
|
||||
bundleIdentifierFromConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function missingBundleIdentifierMessage(configDescription: string): string {
|
||||
return `Please define "ios.bundleIdentifier" in ${configDescription} and run "eas build:configure" again.`;
|
||||
}
|
||||
|
||||
async function updateAppJsonConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
newBundleIdentifier: string
|
||||
): Promise<void> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
assert(paths.staticConfigPath, "can't update dynamic configs");
|
||||
|
||||
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
...rawStaticConfig.expo,
|
||||
ios: { ...rawStaticConfig.expo?.ios, bundleIdentifier: newBundleIdentifier },
|
||||
};
|
||||
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
|
||||
|
||||
exp.ios = { ...exp.ios, bundleIdentifier: newBundleIdentifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if static config exists and if ios.bundleIdentifier is defined there.
|
||||
* It will return false if the value in static config is different than "ios.bundleIdentifier" in ExpoConfig
|
||||
*/
|
||||
async function hasBundleIdentifierInStaticConfigAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<boolean> {
|
||||
if (!exp.ios?.bundleIdentifier) {
|
||||
return false;
|
||||
}
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
return false;
|
||||
}
|
||||
const rawStaticConfig = await fs.readJson(paths.staticConfigPath);
|
||||
return rawStaticConfig?.expo?.ios?.bundleIdentifier === exp.ios.bundleIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
|
||||
import log from '../../log';
|
||||
import { ConfigureContext } from '../context';
|
||||
import { isExpoUpdatesInstalled } from '../utils/updates';
|
||||
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
|
||||
import {
|
||||
configureBundleIdentifierAsync,
|
||||
ensureBundleIdentifierIsValidAsync,
|
||||
} from './bundleIdentifer';
|
||||
|
||||
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (!ctx.hasIosNativeProject) {
|
||||
return;
|
||||
}
|
||||
await configureBundleIdentifierAsync(ctx.projectDir, ctx.exp);
|
||||
await ensureBundleIdentifierIsValidAsync(ctx.projectDir);
|
||||
|
||||
if (isExpoUpdatesInstalled(ctx.projectDir)) {
|
||||
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
log.withTick('iOS project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
const bundleIdentifierFromPbxproj = IOSConfig.BundleIdenitifer.getBundleIdentifierFromPbxproj(
|
||||
projectDir
|
||||
);
|
||||
if (!bundleIdentifierFromPbxproj || bundleIdentifierFromPbxproj !== exp.ios?.bundleIdentifier) {
|
||||
throw new Error(
|
||||
'Bundle identifier is not configured correctly in your Xcode project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
|
||||
import { createCredentialsContextAsync } from '../../credentials/context';
|
||||
import IosCredentialsProvider, {
|
||||
IosCredentials,
|
||||
} from '../../credentials/ios/IosCredentialsProvider';
|
||||
import { AppLookupParams } from '../../credentials/ios/credentials';
|
||||
import { CredentialsResult } from '../build';
|
||||
import { BuildContext } from '../context';
|
||||
import { ensureCredentialsAsync } from '../credentials';
|
||||
import { Platform } from '../types';
|
||||
|
||||
export 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');
|
||||
return await resolveIosCredentialsAsync(ctx.commandCtx.projectDir, {
|
||||
app: {
|
||||
accountName: ctx.commandCtx.accountName,
|
||||
projectName: ctx.commandCtx.projectName,
|
||||
bundleIdentifier: ctx.commandCtx.exp.ios.bundleIdentifier,
|
||||
},
|
||||
workflow: ctx.buildProfile.workflow,
|
||||
credentialsSource: ctx.buildProfile.credentialsSource,
|
||||
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
|
||||
nonInteractive: ctx.commandCtx.nonInteractive,
|
||||
skipCredentialsCheck: ctx.commandCtx.skipCredentialsCheck,
|
||||
});
|
||||
}
|
||||
|
||||
interface ResolveCredentialsParams {
|
||||
app: AppLookupParams;
|
||||
workflow: Workflow;
|
||||
credentialsSource: CredentialsSource;
|
||||
distribution: DistributionType;
|
||||
nonInteractive: boolean;
|
||||
skipCredentialsCheck: boolean;
|
||||
}
|
||||
|
||||
export async function resolveIosCredentialsAsync(
|
||||
projectDir: string,
|
||||
params: ResolveCredentialsParams
|
||||
): Promise<CredentialsResult<IosCredentials>> {
|
||||
const provider = new IosCredentialsProvider(
|
||||
await createCredentialsContextAsync(projectDir, {
|
||||
nonInteractive: params.nonInteractive,
|
||||
}),
|
||||
{
|
||||
app: params.app,
|
||||
distribution: params.distribution,
|
||||
skipCredentialsCheck: params.skipCredentialsCheck,
|
||||
}
|
||||
);
|
||||
const credentialsSource = await ensureCredentialsAsync(
|
||||
provider,
|
||||
params.workflow,
|
||||
params.credentialsSource,
|
||||
params.nonInteractive
|
||||
);
|
||||
return {
|
||||
credentials: await provider.getCredentialsAsync(credentialsSource),
|
||||
source: credentialsSource,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.iOS>): boolean {
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
Workflow,
|
||||
iOS,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { iOSGenericBuildProfile, iOSManagedBuildProfile } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
IosCredentials,
|
||||
IosTargetCredentials,
|
||||
isCredentialsMap,
|
||||
readSecretEnvsAsync,
|
||||
} from '../../credentials/credentialsJson/read';
|
||||
import { getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { gitRootDirectoryAsync } from '../../utils/git';
|
||||
import { BuildContext } from '../context';
|
||||
import { Platform } from '../types';
|
||||
|
||||
interface JobData {
|
||||
archiveBucketKey: string;
|
||||
credentials?: IosCredentials;
|
||||
projectConfiguration: {
|
||||
iosNativeProjectScheme?: string;
|
||||
iosApplicationNativeTarget?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
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.iOS;
|
||||
projectArchive: ArchiveSource;
|
||||
releaseChannel: string;
|
||||
secrets: {
|
||||
buildCredentials: iOS.BuildCredentials;
|
||||
secretEnvs?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareJobCommonAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
{
|
||||
archiveBucketKey,
|
||||
credentials,
|
||||
targetName,
|
||||
}: { archiveBucketKey: string; credentials?: IosCredentials; targetName?: string }
|
||||
): Promise<Partial<CommonJobProperties>> {
|
||||
const secretEnvs = await readSecretEnvsAsync(ctx.commandCtx.projectDir);
|
||||
|
||||
let buildCredentials: CommonJobProperties['secrets']['buildCredentials'] = {};
|
||||
if (credentials && isCredentialsMap(credentials)) {
|
||||
const targets = Object.keys(credentials);
|
||||
for (const target of targets) {
|
||||
buildCredentials[target] = prepareTargetCredentials(credentials[target]);
|
||||
}
|
||||
} else if (credentials) {
|
||||
// targetName
|
||||
// - for managed projects: sanitized .name from the app config
|
||||
// - for generic projects: name of the main application target
|
||||
assert(targetName, 'target name should be defined');
|
||||
buildCredentials = {
|
||||
[targetName]: prepareTargetCredentials(credentials),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
platform: Platform.iOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: archiveBucketKey,
|
||||
},
|
||||
secrets: {
|
||||
...(secretEnvs ? { secretEnvs } : {}),
|
||||
buildCredentials,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepareTargetCredentials(targetCredentials: IosTargetCredentials): iOS.TargetCredentials {
|
||||
return {
|
||||
provisioningProfileBase64: targetCredentials.provisioningProfile,
|
||||
distributionCertificate: {
|
||||
dataBase64: targetCredentials.distributionCertificate.certP12,
|
||||
password: targetCredentials.distributionCertificate.certPassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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, {
|
||||
archiveBucketKey: jobData.archiveBucketKey,
|
||||
credentials: jobData.credentials,
|
||||
targetName: jobData.projectConfiguration.iosApplicationNativeTarget,
|
||||
})),
|
||||
type: Workflow.Generic,
|
||||
scheme: jobData.projectConfiguration.iosNativeProjectScheme,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareManagedJobAsync(
|
||||
ctx: BuildContext<Platform.iOS>,
|
||||
jobData: JobData,
|
||||
buildProfile: iOSManagedBuildProfile
|
||||
): Promise<Partial<iOS.ManagedJob>> {
|
||||
const projectRootDirectory = path.relative(await gitRootDirectoryAsync(), process.cwd()) || '.';
|
||||
const accountName = await getProjectAccountNameAsync(ctx.commandCtx.projectDir);
|
||||
const targetName = sanitizedName(ctx.commandCtx.exp.name);
|
||||
return {
|
||||
...(await prepareJobCommonAsync(ctx, {
|
||||
archiveBucketKey: jobData.archiveBucketKey,
|
||||
credentials: jobData.credentials,
|
||||
targetName,
|
||||
})),
|
||||
type: Workflow.Managed,
|
||||
username: accountName,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
projectRootDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
// copy-pasted from expo-cli/packages/xdl/src/Exp.ts
|
||||
// it's used in eject
|
||||
function sanitizedName(name: string) {
|
||||
return name
|
||||
.replace(/[\W_]+/g, '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
|
||||
import { getAppIdentifierAsync } from '../project/projectUtils';
|
||||
import { BuildContext } from './context';
|
||||
import { BuildMetadata, Platform } from './types';
|
||||
|
||||
/**
|
||||
* 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 async function collectMetadata<T extends Platform>(
|
||||
ctx: BuildContext<T>,
|
||||
{
|
||||
credentialsSource,
|
||||
}: {
|
||||
credentialsSource?: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
|
||||
}
|
||||
): Promise<BuildMetadata> {
|
||||
return {
|
||||
appVersion: ctx.commandCtx.exp.version!,
|
||||
cliVersion: packageJSON.version,
|
||||
workflow: ctx.buildProfile.workflow,
|
||||
credentialsSource,
|
||||
sdkVersion: ctx.commandCtx.exp.sdkVersion,
|
||||
trackingContext: ctx.trackingCtx,
|
||||
distribution: ctx.buildProfile.distribution ?? DistributionType.STORE,
|
||||
appName: ctx.commandCtx.exp.name,
|
||||
appIdentifier:
|
||||
(await getAppIdentifierAsync(ctx.commandCtx.projectDir, ctx.platform)) ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import {
|
||||
AndroidBuildProfile,
|
||||
CredentialsSource,
|
||||
DistributionType,
|
||||
iOSBuildProfile,
|
||||
} from '@expo/eas-json';
|
||||
|
||||
export enum RequestedPlatform {
|
||||
Android = 'android',
|
||||
iOS = 'ios',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export { Platform };
|
||||
|
||||
export enum BuildStatus {
|
||||
IN_QUEUE = 'in-queue',
|
||||
IN_PROGRESS = 'in-progress',
|
||||
ERRORED = 'errored',
|
||||
FINISHED = 'finished',
|
||||
}
|
||||
|
||||
export type TrackingContext = Record<string, string | number>;
|
||||
|
||||
export interface Build {
|
||||
id: string;
|
||||
status: BuildStatus;
|
||||
platform: Platform;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
artifacts?: BuildArtifacts;
|
||||
metadata?: Partial<BuildMetadata>;
|
||||
}
|
||||
|
||||
interface BuildArtifacts {
|
||||
buildUrl?: string;
|
||||
logsUrl: string;
|
||||
}
|
||||
|
||||
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 EAS 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;
|
||||
|
||||
/**
|
||||
* Distribution type
|
||||
* Indicates whether this is a build for store or internal distribution.
|
||||
*/
|
||||
distribution: DistributionType;
|
||||
|
||||
/**
|
||||
* App name (expo.name in app.json/app.config.js)
|
||||
*/
|
||||
appName?: string;
|
||||
|
||||
/**
|
||||
* App identifier:
|
||||
* - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js)
|
||||
* - Android builds: the application id (expo.android.package in app.json/app.config.js)
|
||||
*/
|
||||
appIdentifier?: 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) => {},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { platformDisplayNames } from '../constants';
|
||||
import { Build, BuildStatus } from '../types';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
interface Options {
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
export default function formatBuild(build: Build, { accountName }: Options) {
|
||||
const fields: { label: string; value: string }[] = [
|
||||
{ label: 'ID', value: build.id },
|
||||
{
|
||||
label: 'Platform',
|
||||
value: platformDisplayNames[build.platform],
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case BuildStatus.IN_QUEUE:
|
||||
return chalk.blue('in queue');
|
||||
case BuildStatus.IN_PROGRESS:
|
||||
return chalk.blue('in progress');
|
||||
case BuildStatus.FINISHED:
|
||||
return chalk.green('finished');
|
||||
case BuildStatus.ERRORED:
|
||||
return chalk.red('errored');
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: getBuildLogsUrl({ buildId: build.id, account: accountName }),
|
||||
},
|
||||
{
|
||||
label: 'Artifact',
|
||||
get value() {
|
||||
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
|
||||
return '<in progress>';
|
||||
}
|
||||
|
||||
const url = build.artifacts?.buildUrl;
|
||||
|
||||
if (!url) {
|
||||
return chalk.red('not found');
|
||||
}
|
||||
|
||||
return url;
|
||||
},
|
||||
},
|
||||
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
|
||||
{
|
||||
label: 'Finished at',
|
||||
value:
|
||||
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
|
||||
? '<in progress>'
|
||||
: new Date(build.updatedAt).toLocaleString(),
|
||||
},
|
||||
// TODO: we can do it once we migrate to GraphQL
|
||||
// { label: 'Started by', get: build => 'TODO' },
|
||||
];
|
||||
|
||||
return formatFields(fields);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DistributionType } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../log';
|
||||
import { platformDisplayNames, platformEmojis } from '../constants';
|
||||
import { Build } from '../types';
|
||||
import { getBuildLogsUrl } from './url';
|
||||
|
||||
export interface DeprecationInfo {
|
||||
type: 'user-facing' | 'internal';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function printLogsUrls(
|
||||
accountName: string,
|
||||
builds: { platform: 'android' | 'ios'; buildId: string }[]
|
||||
): void {
|
||||
if (builds.length === 1) {
|
||||
const { buildId } = builds[0];
|
||||
const logsUrl = getBuildLogsUrl({
|
||||
buildId,
|
||||
account: accountName,
|
||||
});
|
||||
log(`Build details: ${chalk.underline(logsUrl)}`);
|
||||
} else {
|
||||
builds.forEach(({ buildId, platform }) => {
|
||||
const logsUrl = getBuildLogsUrl({
|
||||
buildId,
|
||||
account: accountName,
|
||||
});
|
||||
log(`${platformDisplayNames[platform]} build details: ${chalk.underline(logsUrl)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function printBuildResults(accountName: string, builds: (Build | null)[]): void {
|
||||
if (builds.length === 1) {
|
||||
const [build] = builds;
|
||||
assert(build, 'Build should be defined');
|
||||
printBuildResult(accountName, build);
|
||||
} else {
|
||||
(builds.filter(i => i) as Build[])
|
||||
.filter(build => build.status === 'finished')
|
||||
.forEach(build => printBuildResult(accountName, build));
|
||||
}
|
||||
}
|
||||
|
||||
function printBuildResult(accountName: string, build: Build): void {
|
||||
if (build.metadata?.distribution === DistributionType.INTERNAL) {
|
||||
const logsUrl = getBuildLogsUrl({
|
||||
buildId: build.id,
|
||||
account: accountName,
|
||||
});
|
||||
log(
|
||||
`${platformEmojis[build.platform]} Open this link on your ${
|
||||
platformDisplayNames[build.platform]
|
||||
} devices to install the app:`
|
||||
);
|
||||
log(`${chalk.underline(logsUrl)}`);
|
||||
log.newLine();
|
||||
} 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(`${platformEmojis[build.platform]} ${platformDisplayNames[build.platform]} app:`);
|
||||
log(`${chalk.underline(url)}`);
|
||||
log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
export function printDeprecationWarnings(deprecationInfo?: DeprecationInfo): void {
|
||||
if (!deprecationInfo) {
|
||||
return;
|
||||
}
|
||||
if (deprecationInfo.type === '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 === 'user-facing') {
|
||||
log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
|
||||
log.warn(
|
||||
'There might be some changes necessary to your project config, latest eas-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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
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 { confirmAsync, promptAsync } from '../../prompts';
|
||||
import {
|
||||
doesGitRepoExistAsync,
|
||||
getGitDiffOutputAsync,
|
||||
gitDiffAsync,
|
||||
gitRootDirectoryAsync,
|
||||
gitStatusAsync,
|
||||
isGitInstalledAsync,
|
||||
} from '../../utils/git';
|
||||
import { getTmpDirectory } from '../../utils/paths';
|
||||
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
|
||||
|
||||
async function ensureGitRepoExistsAsync(): Promise<void> {
|
||||
if (!(await isGitInstalledAsync())) {
|
||||
throw new Error('git command not found, install it before proceeding');
|
||||
}
|
||||
|
||||
if (await doesGitRepoExistAsync()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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']);
|
||||
|
||||
log("We're going to make an initial commit for you repository.");
|
||||
|
||||
await spawnAsync('git', ['add', '-A']);
|
||||
await commitPromptAsync('Initial commit');
|
||||
}
|
||||
|
||||
async function isGitStatusCleanAsync(): Promise<boolean> {
|
||||
const changes = await gitStatusAsync();
|
||||
return changes.length === 0;
|
||||
}
|
||||
|
||||
async function maybeBailOnGitStatusAsync(): Promise<void> {
|
||||
if (await isGitStatusCleanAsync()) {
|
||||
return;
|
||||
}
|
||||
log.addNewLineIfNone();
|
||||
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
|
||||
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?`,
|
||||
});
|
||||
|
||||
if (!answer) {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGitStatusIsCleanAsync(nonInteractive = false): Promise<void> {
|
||||
if (await isGitStatusCleanAsync()) {
|
||||
return;
|
||||
}
|
||||
log.addNewLineIfNone();
|
||||
log.warn(`${chalk.bold('Warning!')} Your git working tree is dirty.`);
|
||||
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();
|
||||
} else {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
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 spawnAsync('git', [
|
||||
'clone',
|
||||
'--local',
|
||||
'--no-hardlinks',
|
||||
'--depth',
|
||||
'1',
|
||||
`file://${await gitRootDirectoryAsync()}`,
|
||||
shallowClonePath,
|
||||
]);
|
||||
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
|
||||
'.',
|
||||
]);
|
||||
} finally {
|
||||
// Stop the timer
|
||||
clearTimeout(timer);
|
||||
await fs.remove(shallowClonePath);
|
||||
|
||||
const duration = endTimer(compressTimerLabel);
|
||||
if (spinner.isSpinning) {
|
||||
const prettyTime = formatMilliseconds(duration);
|
||||
spinner.succeed(`Compressed project files ${chalk.dim(prettyTime)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { size } = await fs.stat(tarPath);
|
||||
return { size, path: tarPath };
|
||||
}
|
||||
|
||||
async function showDiffAsync() {
|
||||
const outputTooLarge = (await getGitDiffOutputAsync()).split(/\r\n|\r|\n/).length > 100;
|
||||
await gitDiffAsync({ withPager: outputTooLarge });
|
||||
}
|
||||
|
||||
async function commitPromptAsync(initialCommitMessage?: string): Promise<void> {
|
||||
const { message } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'message',
|
||||
message: 'Commit message:',
|
||||
initial: initialCommitMessage,
|
||||
validate: (input: string) => input !== '',
|
||||
});
|
||||
|
||||
// Add changed files only
|
||||
await spawnAsync('git', ['add', '-u']);
|
||||
await spawnAsync('git', ['commit', '-m', message]);
|
||||
}
|
||||
|
||||
export {
|
||||
isGitStatusCleanAsync,
|
||||
showDiffAsync,
|
||||
commitPromptAsync,
|
||||
ensureGitRepoExistsAsync,
|
||||
ensureGitStatusIsCleanAsync,
|
||||
maybeBailOnGitStatusAsync,
|
||||
makeProjectTarballAsync,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ExpoConfig, getPackageJson } from '@expo/config';
|
||||
|
||||
export function isExpoUpdatesInstalled(projectDir: string) {
|
||||
const packageJson = getPackageJson(projectDir);
|
||||
return packageJson.dependencies && 'expo-updates' in packageJson.dependencies;
|
||||
}
|
||||
|
||||
export function ensureValidVersions(exp: ExpoConfig): void {
|
||||
if (!exp.runtimeVersion && !exp.sdkVersion) {
|
||||
throw new Error(
|
||||
"Couldn't find either 'runtimeVersion' or 'sdkVersion' to configure 'expo-updates'. Please specify at least one of these properties under the 'expo' key in 'app.json'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getExpoWebsiteBaseUrl } from '../../api';
|
||||
|
||||
export function getBuildLogsUrl({
|
||||
buildId,
|
||||
account,
|
||||
}: {
|
||||
buildId: string;
|
||||
account: string;
|
||||
}): string {
|
||||
return `${getExpoWebsiteBaseUrl()}/accounts/${account}/builds/${buildId}`;
|
||||
}
|
||||
|
||||
export function getArtifactUrl(artifactId: string): string {
|
||||
return `${getExpoWebsiteBaseUrl()}/artifacts/${artifactId}`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import log from '../../log';
|
||||
import { showLoginPromptAsync } from '../../user/actions';
|
||||
|
||||
export default class AccountLogin extends Command {
|
||||
static description = 'log in with your Expo account';
|
||||
|
||||
static aliases = ['login'];
|
||||
|
||||
async run() {
|
||||
log('Log in to EAS');
|
||||
await showLoginPromptAsync();
|
||||
log('Logged in');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import log from '../../log';
|
||||
import { logoutAsync } from '../../user/User';
|
||||
|
||||
export default class AccountLogout extends Command {
|
||||
static description = 'log out';
|
||||
|
||||
static aliases = ['logout'];
|
||||
|
||||
async run() {
|
||||
await logoutAsync();
|
||||
log('Logged out');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../log';
|
||||
import { getUserAsync } from '../../user/User';
|
||||
import { getActorDisplayName } from '../../user/actions';
|
||||
|
||||
export default class AccountView extends Command {
|
||||
static description = 'show the username you are logged in as';
|
||||
|
||||
static aliases = ['whoami'];
|
||||
|
||||
async run() {
|
||||
const user = await getUserAsync();
|
||||
if (user) {
|
||||
log(chalk.green(getActorDisplayName(user)));
|
||||
} else {
|
||||
log.warn('Not logged in');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { configureAsync } from '../../build/configure';
|
||||
import { RequestedPlatform } from '../../build/types';
|
||||
import log, { learnMore } from '../../log';
|
||||
import { findProjectRootAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
export default class BuildConfigure extends Command {
|
||||
static description = 'Configure the project to support EAS Build.';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({
|
||||
description: 'Platform to configure',
|
||||
char: 'p',
|
||||
options: ['android', 'ios', 'all'],
|
||||
}),
|
||||
'allow-experimental': flags.boolean({
|
||||
description: 'Enable experimental configuration steps.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { flags } = this.parse(BuildConfigure);
|
||||
log(
|
||||
'💡 The following process will configure your iOS and/or Android project to be compatible with EAS Build. These changes only apply to your local project files and you can safely revert them at any time.'
|
||||
);
|
||||
log.newLine();
|
||||
|
||||
const platform =
|
||||
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
|
||||
const allowExperimental = flags['allow-experimental'];
|
||||
|
||||
if (allowExperimental) {
|
||||
log.warn(
|
||||
`Project configuration will execute some additional steps that might fail if structure of your native project is significantly different from ${chalk.bold(
|
||||
'expo eject'
|
||||
)} or ${chalk.bold('expo init')}`
|
||||
);
|
||||
}
|
||||
|
||||
await ensureLoggedInAsync();
|
||||
await configureAsync({
|
||||
platform,
|
||||
allowExperimental,
|
||||
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
|
||||
});
|
||||
|
||||
log.newLine();
|
||||
logSuccess(platform);
|
||||
}
|
||||
}
|
||||
|
||||
function logSuccess(platform: RequestedPlatform) {
|
||||
let platformsText = 'iOS and Android projects are';
|
||||
let storesText = 'the Apple App Store or Google Play Store';
|
||||
|
||||
if (platform === 'android') {
|
||||
platformsText = 'Android project is';
|
||||
storesText = 'the Google Play Store';
|
||||
} else if (platform === 'ios') {
|
||||
platformsText = 'iOS project is';
|
||||
storesText = 'the Apple App Store';
|
||||
}
|
||||
|
||||
log(`🎉 Your ${platformsText} ready to build.
|
||||
|
||||
- Run ${chalk.bold('eas build')} when you are ready to create your first build.
|
||||
- Once the build is completed, run ${chalk.bold('eas submit')} to upload the app to ${storesText}
|
||||
- ${learnMore(
|
||||
'https://docs.expo.io/build/introduction',
|
||||
'Learn more about other capabilities of EAS Build'
|
||||
)}`);
|
||||
}
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which platforms would you like to configure for EAS Build?',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
title: 'All',
|
||||
value: RequestedPlatform.All,
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
value: RequestedPlatform.iOS,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
value: RequestedPlatform.Android,
|
||||
},
|
||||
],
|
||||
});
|
||||
return platform;
|
||||
}
|
||||
@@ -1,11 +1,218 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export default class BuildIndex extends Command {
|
||||
static description = 'build an app binary for your project';
|
||||
import { apiClient } from '../../api';
|
||||
import { configureAsync } from '../../build/configure';
|
||||
import { createCommandContextAsync } from '../../build/context';
|
||||
import { buildAsync } from '../../build/create';
|
||||
import { AnalyticsEvent, Build as BuildType, RequestedPlatform } from '../../build/types';
|
||||
import Analytics from '../../build/utils/analytics';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import { isGitStatusCleanAsync } from '../../build/utils/repository';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import log, { learnMore } from '../../log';
|
||||
import {
|
||||
isEasEnabledForProjectAsync,
|
||||
warnEasUnavailable,
|
||||
} from '../../project/isEasEnabledForProject';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
static flags = {};
|
||||
export default class Build extends Command {
|
||||
static description = 'Start a build';
|
||||
|
||||
async run() {
|
||||
throw new Error('Not implemented');
|
||||
static flags = {
|
||||
platform: flags.enum({ char: 'p', options: ['android', 'ios', 'all'] }),
|
||||
'skip-credentials-check': flags.boolean({
|
||||
default: false,
|
||||
description: 'Skip validation of build credentials',
|
||||
}),
|
||||
'skip-project-configuration': flags.boolean({
|
||||
default: false,
|
||||
description: 'Skip project configuration',
|
||||
}),
|
||||
profile: flags.string({
|
||||
default: 'release',
|
||||
description: 'Name of the build profile from eas.json',
|
||||
}),
|
||||
'non-interactive': flags.boolean({
|
||||
default: false,
|
||||
description: 'Run command in --non-interactive mode',
|
||||
}),
|
||||
wait: flags.boolean({
|
||||
default: true,
|
||||
description: 'Wait for build(s) to complete',
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = this.parse(Build);
|
||||
const user = await ensureLoggedInAsync();
|
||||
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
if (!flags.platform && nonInteractive) {
|
||||
throw new Error('--platform is required when building in non-interactive mode');
|
||||
}
|
||||
const platform =
|
||||
(flags.platform as RequestedPlatform | undefined) ?? (await promptForPlatformAsync());
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
|
||||
accountName,
|
||||
platform,
|
||||
projectId,
|
||||
user,
|
||||
});
|
||||
if (accountHasPendingBuilds) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureProjectConfiguredAsync(projectDir);
|
||||
|
||||
const trackingCtx = {
|
||||
tracking_id: uuidv4(),
|
||||
requested_platform: flags.platform,
|
||||
};
|
||||
Analytics.logEvent(AnalyticsEvent.BUILD_COMMAND, trackingCtx);
|
||||
|
||||
const commandCtx = await createCommandContextAsync({
|
||||
requestedPlatform: platform,
|
||||
profile: flags.profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
trackingCtx,
|
||||
nonInteractive,
|
||||
skipCredentialsCheck: flags['skip-credentials-check'],
|
||||
skipProjectConfiguration: flags['skip-project-configuration'],
|
||||
waitForBuildEnd: flags.wait,
|
||||
});
|
||||
await buildAsync(commandCtx);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNoPendingBuildsExistAsync({
|
||||
user,
|
||||
platform,
|
||||
accountName,
|
||||
projectId,
|
||||
}: {
|
||||
user: Actor;
|
||||
platform: RequestedPlatform;
|
||||
accountName: string;
|
||||
projectId: string;
|
||||
}): Promise<boolean> {
|
||||
// allow expo admins to run as many builds as they wish
|
||||
if (user.isExpoAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appPlatforms = toAppPlatforms(platform);
|
||||
const maybePendingBuilds = await Promise.all(
|
||||
appPlatforms.map(appPlatform => BuildQuery.getPendingBuildIdAsync(accountName, appPlatform))
|
||||
);
|
||||
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
|
||||
if (pendingBuilds.length > 0) {
|
||||
log.newLine();
|
||||
log.error(
|
||||
'Your other builds are still pending. Wait for them to complete before running this command again.'
|
||||
);
|
||||
log.newLine();
|
||||
const results = await Promise.all(
|
||||
pendingBuilds.map(pendingBuild => {
|
||||
return apiClient.get<{ data: BuildType }>(
|
||||
`projects/${projectId}/builds/${pendingBuild?.id}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
log(formatBuild(result.body.data, { accountName }));
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toAppPlatforms(requestedPlatform: RequestedPlatform): AppPlatform[] {
|
||||
if (requestedPlatform === RequestedPlatform.All) {
|
||||
return [AppPlatform.Android, AppPlatform.Ios];
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return [AppPlatform.Android];
|
||||
} else {
|
||||
return [AppPlatform.Ios];
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Build for platforms',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
title: 'All',
|
||||
value: RequestedPlatform.All,
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
value: RequestedPlatform.iOS,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
value: RequestedPlatform.Android,
|
||||
},
|
||||
],
|
||||
});
|
||||
return platform;
|
||||
}
|
||||
|
||||
async function ensureProjectConfiguredAsync(projectDir: string): Promise<void> {
|
||||
if (await fs.pathExists(path.join(projectDir, 'eas.json'))) {
|
||||
return;
|
||||
}
|
||||
const confirm = await confirmAsync({
|
||||
message: 'This app is not set up for building with EAS. Set it up now?',
|
||||
});
|
||||
if (confirm) {
|
||||
await configureAsync({
|
||||
projectDir,
|
||||
platform: RequestedPlatform.All,
|
||||
allowExperimental: false,
|
||||
});
|
||||
if (!(await isGitStatusCleanAsync())) {
|
||||
throw new Error(
|
||||
'Build process requires clean git working tree, please commit all your changes and run `eas build` again'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Aborting, please run ${chalk.bold('eas build:configure')} or create eas.json (${learnMore(
|
||||
'https://docs.expo.io/build/eas-json'
|
||||
)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
|
||||
export default class BuildList extends Command {
|
||||
static description = 'list all builds for your project';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
|
||||
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
|
||||
limit: flags.integer(),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { platform, status, limit = 10 } = this.parse(BuildList).flags;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
|
||||
const spinner = ora().start('Fetching the build list for the project…');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { platform, status, limit },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
const { builds } = response.body.data;
|
||||
|
||||
if (builds.length) {
|
||||
if (platform || status) {
|
||||
spinner.succeed(
|
||||
`Showing ${builds.length} matching builds for the project ${projectName}`
|
||||
);
|
||||
} else {
|
||||
spinner.succeed(`Showing last ${builds.length} builds for the project ${projectName}`);
|
||||
}
|
||||
|
||||
const list = builds
|
||||
.map(build => formatBuild(build, { accountName }))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
log(`\n${list}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
}
|
||||
} catch (e) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build list ${projectName}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
|
||||
export default class BuildStatus extends Command {
|
||||
static description = 'get the status of the latest builds for your project';
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
|
||||
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
|
||||
};
|
||||
|
||||
async run() {
|
||||
// const { flags } = this.parse(Add);
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import { HTTPError } from 'got';
|
||||
import ora from 'ora';
|
||||
|
||||
import { apiClient } from '../../api';
|
||||
import { Build } from '../../build/types';
|
||||
import formatBuild from '../../build/utils/formatBuild';
|
||||
import log from '../../log';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getProjectFullNameAsync,
|
||||
getProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
|
||||
export default class BuildView extends Command {
|
||||
static description = 'view a build for your project';
|
||||
|
||||
static args = [{ name: 'buildId' }];
|
||||
|
||||
async run() {
|
||||
const { buildId } = this.parse(BuildView).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const projectName = await getProjectFullNameAsync(projectDir);
|
||||
|
||||
const spinner = ora().start('Fetching the build…');
|
||||
|
||||
try {
|
||||
let build: Build;
|
||||
|
||||
if (buildId == null) {
|
||||
const response = await apiClient.get<{ data: { builds: Build[] } }>(
|
||||
`projects/${projectId}/builds`,
|
||||
{
|
||||
searchParams: { limit: 1 },
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data.builds[0];
|
||||
} else {
|
||||
const response = await apiClient.get<{ data: Build }>(
|
||||
`projects/${projectId}/builds/${buildId}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
build = response.body.data;
|
||||
}
|
||||
|
||||
if (buildId) {
|
||||
spinner.succeed(`Found a matching build for the project ${projectName}`);
|
||||
} else {
|
||||
spinner.succeed(`Showing the last build for the project ${projectName}`);
|
||||
}
|
||||
|
||||
log(`\n${formatBuild(build, { accountName })}`);
|
||||
} catch (e) {
|
||||
const error = e as HTTPError;
|
||||
|
||||
if (error.response.statusCode === 400) {
|
||||
if (buildId) {
|
||||
spinner.fail(
|
||||
`Couldn't find a build for the project ${projectName} matching the id ${buildId}`
|
||||
);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
|
||||
}
|
||||
} else {
|
||||
if (buildId) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { UpdateChannel } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getReleaseByNameAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { createUpdateReleaseOnAppAsync } from '../release/create';
|
||||
|
||||
async function createUpdateChannelOnAppAsync({
|
||||
appId,
|
||||
channelName,
|
||||
releaseId,
|
||||
}: {
|
||||
appId: string;
|
||||
channelName: string;
|
||||
releaseId: string;
|
||||
}): Promise<UpdateChannel> {
|
||||
// Point the new channel at a release with its same name.
|
||||
const releaseMapping = JSON.stringify({
|
||||
data: [{ releaseId, releaseMappingLogic: 'true' }],
|
||||
version: 0,
|
||||
});
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateChannel: { createUpdateChannelForApp: UpdateChannel } },
|
||||
{ appId: string; channelName: string; releaseMapping: string }
|
||||
>(
|
||||
gql`
|
||||
mutation CreateUpdateChannelForApp(
|
||||
$appId: ID!
|
||||
$channelName: String!
|
||||
$releaseMapping: String!
|
||||
) {
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(
|
||||
appId: $appId
|
||||
channelName: $channelName
|
||||
releaseMapping: $releaseMapping
|
||||
) {
|
||||
id
|
||||
channelName
|
||||
releaseMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
releaseMapping,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateChannel.createUpdateChannelForApp;
|
||||
}
|
||||
|
||||
export default class ChannelCreate extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Create a channel on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'channelName',
|
||||
required: false,
|
||||
description: 'Name of the channel to create',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description:
|
||||
'print output as a JSON object with the new channel ID, name and release mapping.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { channelName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelCreate);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!channelName) {
|
||||
const validationMessage = 'Channel name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ channelName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'channelName',
|
||||
message: 'Please name the channel:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
let releaseId: string;
|
||||
let releaseMessage: string;
|
||||
try {
|
||||
const existingRelease = await getReleaseByNameAsync({
|
||||
appId: projectId,
|
||||
releaseName: channelName,
|
||||
});
|
||||
releaseId = existingRelease.id;
|
||||
releaseMessage = `We found a release with the same name`;
|
||||
} catch (e) {
|
||||
const newRelease = await createUpdateReleaseOnAppAsync({
|
||||
appId: projectId,
|
||||
releaseName: channelName,
|
||||
});
|
||||
releaseId = newRelease.id;
|
||||
releaseMessage = `We also went ahead and made a release with the same name`;
|
||||
}
|
||||
|
||||
const newChannel = await createUpdateChannelOnAppAsync({
|
||||
appId: projectId,
|
||||
channelName,
|
||||
releaseId,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
log(newChannel);
|
||||
return;
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Created a new channel ${chalk.bold(newChannel.channelName)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}. ${releaseMessage} and have pointed the channel at it. You can now update your app by publishing!`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import { runCredentialsManagerAsync } from '../credentials/CredentialsManager';
|
||||
import { CredentialsManager } from '../credentials/CredentialsManager';
|
||||
import { createCredentialsContextAsync } from '../credentials/context';
|
||||
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
|
||||
|
||||
@@ -9,6 +9,6 @@ export default class Credentials extends Command {
|
||||
|
||||
async run() {
|
||||
const ctx = await createCredentialsContextAsync(process.cwd(), {});
|
||||
await runCredentialsManagerAsync(ctx, new SelectPlatform());
|
||||
new CredentialsManager(ctx).runActionAsync(new SelectPlatform());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
|
||||
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
|
||||
import formatDevice from '../../devices/utils/formatDevice';
|
||||
import log from '../../log';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export default class BuildList extends Command {
|
||||
static description = 'list all registered devices for your account';
|
||||
|
||||
static flags = {
|
||||
'apple-team-id': flags.string(),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
|
||||
let spinner: ora.Ora;
|
||||
|
||||
if (!appleTeamIdentifier) {
|
||||
spinner = ora().start('Fetching the list of teams for the project…');
|
||||
|
||||
try {
|
||||
const teams = await AppleTeamQuery.getAllForAccountAsync(accountName);
|
||||
|
||||
if (teams.length) {
|
||||
spinner.succeed();
|
||||
|
||||
if (teams.length === 1) {
|
||||
appleTeamIdentifier = teams[0].appleTeamIdentifier;
|
||||
} else {
|
||||
const result = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'appleTeamIdentifier',
|
||||
message: 'What Apple Team would you like to list devices for?',
|
||||
choices: teams.map(team => ({
|
||||
title: `${team.appleTeamName} (ID: ${team.appleTeamIdentifier})`,
|
||||
value: team.appleTeamIdentifier,
|
||||
})),
|
||||
});
|
||||
|
||||
appleTeamIdentifier = result.appleTeamIdentifier;
|
||||
}
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any teams for the account ${accountName}`);
|
||||
}
|
||||
} catch (e) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the list of teams`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
assert(appleTeamIdentifier, 'No team identifier is specified');
|
||||
|
||||
spinner = ora().start('Fetching the list of devices for the team…');
|
||||
|
||||
try {
|
||||
const result = await AppleDeviceQuery.getAllForAppleTeamAsync(
|
||||
accountName,
|
||||
appleTeamIdentifier
|
||||
);
|
||||
|
||||
if (result?.appleDevices.length) {
|
||||
const { appleTeamName, appleDevices } = result;
|
||||
|
||||
spinner.succeed(`Found ${appleDevices.length} devices for team ${appleTeamName}`);
|
||||
|
||||
const list = appleDevices
|
||||
.map(device =>
|
||||
formatDevice(device, { appleTeamName, appleTeamIdentifier: appleTeamIdentifier! })
|
||||
)
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
log(`\n${list}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find any devices for the team ${appleTeamIdentifier}`);
|
||||
}
|
||||
} catch (e) {
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the device list`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import ora from 'ora';
|
||||
|
||||
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
|
||||
import formatDevice from '../../devices/utils/formatDevice';
|
||||
import log from '../../log';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
|
||||
export default class DeviceView extends Command {
|
||||
static description = 'view a device for your project';
|
||||
|
||||
static args = [{ name: 'UDID' }];
|
||||
|
||||
async run() {
|
||||
const { UDID } = this.parse(DeviceView).args;
|
||||
|
||||
if (!UDID) {
|
||||
log(
|
||||
`The device UDID is required to view a specific device. For example:
|
||||
|
||||
eas device:view 00005787-000872430189501D
|
||||
|
||||
If you are not sure what is the UDID of the device you are looking for, run:
|
||||
|
||||
eas device:list
|
||||
`
|
||||
);
|
||||
throw new Error('Device UDID is missing');
|
||||
}
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
|
||||
const spinner = ora().start(`Fetching device details for ${UDID}…`);
|
||||
|
||||
try {
|
||||
const device = await AppleDeviceQuery.getByDeviceIdentifier(accountName, UDID);
|
||||
|
||||
if (device) {
|
||||
spinner.succeed('Fetched device details');
|
||||
log(`\n${formatDevice(device, device.appleTeam)}`);
|
||||
} else {
|
||||
spinner.fail(`Couldn't find a device with the UDID ${UDID}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
spinner.fail(`Something went wrong and we couldn't fetch the device with UDID ${UDID}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import { showLoginPromptAsync } from '../user/actions';
|
||||
|
||||
export default class Login extends Command {
|
||||
static description = 'log in with your EAS account';
|
||||
|
||||
async run() {
|
||||
this.log('Log in to EAS');
|
||||
await showLoginPromptAsync();
|
||||
this.log('Logged in.');
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
import { logoutAsync } from '../user/User';
|
||||
|
||||
export default class Logout extends Command {
|
||||
static description = 'log out';
|
||||
|
||||
async run() {
|
||||
await logoutAsync();
|
||||
this.log('Logged out.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { UpdateRelease } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { getBranchNameAsync } from '../../utils/git';
|
||||
|
||||
export async function createUpdateReleaseOnAppAsync({
|
||||
appId,
|
||||
releaseName,
|
||||
}: {
|
||||
appId: string;
|
||||
releaseName: string;
|
||||
}): Promise<Pick<UpdateRelease, 'id' | 'releaseName'>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateRelease: { createUpdateReleaseForApp: Pick<UpdateRelease, 'id' | 'releaseName'> } },
|
||||
{ appId: string; releaseName: string }
|
||||
>(
|
||||
gql`
|
||||
mutation CreateUpdateReleaseForApp($appId: ID!, $releaseName: String!) {
|
||||
updateRelease {
|
||||
createUpdateReleaseForApp(appId: $appId, releaseName: $releaseName) {
|
||||
id
|
||||
releaseName
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
releaseName,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateRelease.createUpdateReleaseForApp;
|
||||
}
|
||||
|
||||
export default class ReleaseCreate extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Create a release on the current project.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'releaseName',
|
||||
required: false,
|
||||
description: 'Name of the release to create',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'return a json with the new release ID and name.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { releaseName },
|
||||
flags,
|
||||
} = this.parse(ReleaseCreate);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
});
|
||||
|
||||
if (!releaseName) {
|
||||
const validationMessage = 'Release name may not be empty.';
|
||||
if (flags.json) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ releaseName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'releaseName',
|
||||
message: 'Please name the release:',
|
||||
initial:
|
||||
(await getBranchNameAsync()) || `release-${Math.random().toString(36).substr(2, 4)}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const newRelease = await createUpdateReleaseOnAppAsync({ appId: projectId, releaseName });
|
||||
|
||||
if (flags.json) {
|
||||
log(newRelease);
|
||||
return;
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Created a new release: ${chalk.bold(newRelease.releaseName)} on project ${chalk.bold(
|
||||
`@${accountName}/${exp.slug}`
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import { CLIError } from '@oclif/errors';
|
||||
import CliTable from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
import { format } from 'timeago.js';
|
||||
|
||||
import { graphqlClient } from '../../graphql/client';
|
||||
import { RootQuery, Update, UpdateRelease } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { findProjectRootAsync, getProjectFullNameAsync } from '../../project/projectUtils';
|
||||
import { getActorDisplayName } from '../../user/actions';
|
||||
|
||||
const RELEASES_LIMIT = 10_000;
|
||||
|
||||
export default class ReleaseList extends Command {
|
||||
static hidden = true;
|
||||
|
||||
static description = 'List all releases on this project.';
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: 'return output as JSON',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { flags } = this.parse(ReleaseList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const fullName = await getProjectFullNameAsync(projectDir);
|
||||
const releases = await this.listReleasesAsync({ fullName });
|
||||
if (flags.json) {
|
||||
log(JSON.stringify(releases, null, 2));
|
||||
} else {
|
||||
const table = new CliTable({ head: ['Release', 'Latest update'] });
|
||||
table.push(
|
||||
...releases.map((release: UpdateRelease) => [
|
||||
release.releaseName,
|
||||
formatUpdate(release.updates[0]),
|
||||
])
|
||||
);
|
||||
log(table.toString());
|
||||
if (releases.length >= RELEASES_LIMIT) {
|
||||
log.warn(`Showing first ${RELEASES_LIMIT} releases, some results might be omitted.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listReleasesAsync({ fullName }: { fullName: string }): Promise<UpdateRelease[]> {
|
||||
const { data, error } = await graphqlClient
|
||||
.query<RootQuery>(
|
||||
gql`
|
||||
query ReleasesByAppQuery($fullName: String!, $limit: Int!) {
|
||||
app {
|
||||
byFullName(fullName: $fullName) {
|
||||
id
|
||||
fullName
|
||||
updateReleases(offset: 0, limit: $limit) {
|
||||
id
|
||||
releaseName
|
||||
updates(offset: 0, limit: 1) {
|
||||
id
|
||||
actor {
|
||||
__typename
|
||||
id
|
||||
... on User {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
updatedAt
|
||||
updateMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
fullName,
|
||||
limit: RELEASES_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
if (error.networkError) {
|
||||
throw new CLIError(`Fetching releases failed: ${error.networkError.message}`);
|
||||
} else {
|
||||
throw new CLIError(error.graphQLErrors.map(e => e.message).join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
return data?.app?.byFullName.updateReleases ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdate(update: Update | undefined): string {
|
||||
if (!update) {
|
||||
return 'N/A';
|
||||
}
|
||||
const message = update.updateMessage ? `"${update.updateMessage}" ` : '';
|
||||
return `${message}(${format(update.updatedAt, 'en_US')} by ${getActorDisplayName(
|
||||
update.actor as any
|
||||
)})`;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import ora from 'ora';
|
||||
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import {
|
||||
findProjectRootAsync,
|
||||
getProjectAccountNameAsync,
|
||||
getReleaseByNameAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { buildUpdateInfoGroupAsync, collectAssets, uploadAssetsAsync } from '../../project/publish';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getLastCommitMessageAsync } from '../../utils/git';
|
||||
|
||||
export default class ReleasePublish extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Publish an update group to a release.';
|
||||
|
||||
static flags = {
|
||||
'input-dir': flags.string({
|
||||
description: 'location of the bundle',
|
||||
default: 'dist',
|
||||
required: false,
|
||||
}),
|
||||
release: flags.string({
|
||||
description: 'current name of the release.',
|
||||
}),
|
||||
message: flags.string({
|
||||
description: 'Short message describing the updates.',
|
||||
required: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: `return a json with the edited release's ID and name.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
release: releaseName,
|
||||
message: updateMessage,
|
||||
'input-dir': inputDir,
|
||||
},
|
||||
} = this.parse(ReleasePublish);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug, runtimeVersion },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
if (!runtimeVersion) {
|
||||
throw new Error(
|
||||
"Couldn't find 'runtimeVersion'. Please specify it under the 'expo' key in 'app.json'"
|
||||
);
|
||||
}
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!releaseName) {
|
||||
const validationMessage = 'release name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ releaseName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'releaseName',
|
||||
message: 'Please enter the name of the release to publish on:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!updateMessage) {
|
||||
const validationMessage = 'publish message may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ publishMessage: updateMessage } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'publishMessage',
|
||||
message: `Please enter a publication message.`,
|
||||
initial: (await getLastCommitMessageAsync())?.trim(),
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const { id: releaseId } = await getReleaseByNameAsync({
|
||||
appId: projectId,
|
||||
releaseName: releaseName!,
|
||||
});
|
||||
|
||||
let updateInfoGroup;
|
||||
const assetSpinner = ora('Uploading assets...').start();
|
||||
try {
|
||||
const assets = collectAssets(inputDir!);
|
||||
await uploadAssetsAsync(assets);
|
||||
updateInfoGroup = await buildUpdateInfoGroupAsync(assets);
|
||||
assetSpinner.succeed('Uploaded assets!');
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload assets');
|
||||
throw e;
|
||||
}
|
||||
|
||||
let newUpdateGroup;
|
||||
const publishSpinner = ora('Publishing...').start();
|
||||
try {
|
||||
newUpdateGroup = await PublishMutation.publishUpdateGroupAsync({
|
||||
releaseId,
|
||||
updateInfoGroup,
|
||||
runtimeVersion,
|
||||
updateMessage,
|
||||
});
|
||||
publishSpinner.succeed('Published!');
|
||||
} catch (e) {
|
||||
publishSpinner.fail('Failed to published updates');
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
log(newUpdateGroup);
|
||||
} else {
|
||||
const outputMessage = new Table({
|
||||
wordWrap: true,
|
||||
chars: {
|
||||
top: '',
|
||||
'top-mid': '',
|
||||
'top-left': '',
|
||||
'top-right': '',
|
||||
bottom: '',
|
||||
'bottom-mid': '',
|
||||
'bottom-left': '',
|
||||
'bottom-right': '',
|
||||
left: '',
|
||||
'left-mid': '',
|
||||
mid: '',
|
||||
'mid-mid': '',
|
||||
right: '',
|
||||
'right-mid': '',
|
||||
middle: ' ',
|
||||
},
|
||||
style: { 'padding-left': 0, 'padding-right': 0 },
|
||||
});
|
||||
outputMessage.push(
|
||||
[chalk.dim('project'), `@${accountName}/${slug}`],
|
||||
[chalk.dim('release'), releaseName],
|
||||
[chalk.dim('runtimeVersion'), runtimeVersion],
|
||||
[chalk.dim('updateGroupID'), newUpdateGroup.updateGroup],
|
||||
[chalk.dim('message'), updateMessage]
|
||||
);
|
||||
log(outputMessage.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { EditUpdateReleaseInput, UpdateRelease } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountName } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
|
||||
async function renameUpdateReleaseOnAppAsync({
|
||||
appId,
|
||||
currentName,
|
||||
newName,
|
||||
}: {
|
||||
appId: string;
|
||||
currentName: string;
|
||||
newName: string;
|
||||
}): Promise<UpdateRelease> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{ updateRelease: { editUpdateRelease: UpdateRelease } },
|
||||
{
|
||||
input: EditUpdateReleaseInput;
|
||||
}
|
||||
>(
|
||||
gql`
|
||||
mutation EditUpdateRelease($input: EditUpdateReleaseInput!) {
|
||||
updateRelease {
|
||||
editUpdateRelease(input: $input) {
|
||||
id
|
||||
releaseName
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
appId,
|
||||
releaseName: currentName,
|
||||
newReleaseName: newName,
|
||||
},
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateRelease.editUpdateRelease;
|
||||
}
|
||||
|
||||
export default class ReleaseRename extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Rename a release.';
|
||||
|
||||
static flags = {
|
||||
from: flags.string({
|
||||
description: 'current name of the release.',
|
||||
required: false,
|
||||
}),
|
||||
to: flags.string({
|
||||
description: 'new name of the release.',
|
||||
required: false,
|
||||
}),
|
||||
json: flags.boolean({
|
||||
description: `return a json with the edited release's ID and name.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
flags: { json: jsonFlag, from: currentName, to: newName },
|
||||
} = this.parse(ReleaseRename);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const { exp } = await getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
});
|
||||
|
||||
if (!currentName) {
|
||||
const validationMessage = 'current name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ currentName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'currentName',
|
||||
message: 'Please enter the current name of the release to rename:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!newName) {
|
||||
const validationMessage = 'new name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ newName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'newName',
|
||||
message: `Please rename ${currentName}`,
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const editedRelease = await renameUpdateReleaseOnAppAsync({
|
||||
appId: projectId,
|
||||
currentName: currentName!,
|
||||
newName: newName!,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
log(editedRelease);
|
||||
return;
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Renamed release from ${currentName} to ${chalk.bold(
|
||||
editedRelease.releaseName
|
||||
)} on project ${chalk.bold(`@${accountName}/${exp.slug}`)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { getConfig } from '@expo/config';
|
||||
import { Command, flags } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import gql from 'graphql-tag';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
|
||||
import { Update } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { ensureProjectExistsAsync } from '../../project/ensureProjectExists';
|
||||
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
const PAGE_LIMIT = 10_000;
|
||||
|
||||
type TruncatedUpdate = Pick<Update, 'updateGroup' | 'updateMessage' | 'createdAt' | 'actor'>;
|
||||
|
||||
async function viewUpdateReleaseAsync({
|
||||
appId,
|
||||
releaseName,
|
||||
}: {
|
||||
appId: string;
|
||||
releaseName: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
releaseName: string;
|
||||
updates: TruncatedUpdate[];
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
{
|
||||
app: {
|
||||
byId: {
|
||||
updateReleaseByReleaseName: {
|
||||
id: string;
|
||||
releaseName: string;
|
||||
updates: TruncatedUpdate[];
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
{
|
||||
appId: string;
|
||||
releaseName: string;
|
||||
limit: number;
|
||||
}
|
||||
>(
|
||||
gql`
|
||||
query ViewRelease($appId: String!, $releaseName: String!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateReleaseByReleaseName(releaseName: $releaseName) {
|
||||
id
|
||||
releaseName
|
||||
updates(offset: 0, limit: $limit) {
|
||||
id
|
||||
updateGroup
|
||||
updateMessage
|
||||
createdAt
|
||||
actor {
|
||||
id
|
||||
... on User {
|
||||
firstName
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
releaseName,
|
||||
limit: PAGE_LIMIT,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.app.byId.updateReleaseByReleaseName;
|
||||
}
|
||||
|
||||
export default class ReleaseView extends Command {
|
||||
static hidden = true;
|
||||
static description = 'View a release.';
|
||||
|
||||
static args = [
|
||||
{
|
||||
name: 'releaseName',
|
||||
required: false,
|
||||
description: 'Name of the release to view',
|
||||
},
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: flags.boolean({
|
||||
description: `return a json with the release's ID name and recent update groups.`,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
let {
|
||||
args: { releaseName },
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ReleaseView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const accountName = await getProjectAccountNameAsync(projectDir);
|
||||
const {
|
||||
exp: { slug },
|
||||
} = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await ensureProjectExistsAsync({
|
||||
accountName,
|
||||
projectName: slug,
|
||||
});
|
||||
|
||||
if (!releaseName) {
|
||||
const validationMessage = 'Release name may not be empty.';
|
||||
if (jsonFlag) {
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
({ releaseName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'releaseName',
|
||||
message: 'Please enter the name of the release to view:',
|
||||
validate: value => (value ? true : validationMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
const UpdateRelease = await viewUpdateReleaseAsync({
|
||||
appId: projectId,
|
||||
releaseName,
|
||||
});
|
||||
|
||||
const updates = Object.values(groupBy(UpdateRelease.updates, u => u.updateGroup)).map(
|
||||
updateGroup => updateGroup[0]
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
log({ ...UpdateRelease, updates });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateGroupTable = new Table({
|
||||
head: ['createdAt', 'updateMessage', 'updateGroup', 'actor'],
|
||||
wordWrap: true,
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
updateGroupTable.push([
|
||||
new Date(update.createdAt).toLocaleString(),
|
||||
update.updateMessage,
|
||||
update.updateGroup,
|
||||
update.actor?.firstName,
|
||||
]);
|
||||
}
|
||||
|
||||
log.withTick(
|
||||
`️Release: ${chalk.bold(UpdateRelease.releaseName)} on project ${chalk.bold(
|
||||
`@${accountName}/${slug}`
|
||||
)}. Release ID: ${chalk.bold(UpdateRelease.id)}`
|
||||
);
|
||||
log(chalk.bold('Recent update groups published on this release:'));
|
||||
log(updateGroupTable.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Command, flags } from '@oclif/command';
|
||||
|
||||
import { learnMore } from '../log';
|
||||
import { isEasEnabledForProjectAsync, warnEasUnavailable } from '../project/isEasEnabledForProject';
|
||||
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
|
||||
import { promptAsync } from '../prompts';
|
||||
import AndroidSubmitCommand from '../submissions/android/AndroidSubmitCommand';
|
||||
import IosSubmitCommand from '../submissions/ios/IosSubmitCommand';
|
||||
import {
|
||||
AndroidSubmitCommandFlags,
|
||||
IosSubmitCommandFlags,
|
||||
SubmissionPlatform,
|
||||
} from '../submissions/types';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
|
||||
const COMMON_FLAGS = '';
|
||||
const ANDROID_FLAGS = 'Android specific options';
|
||||
const IOS_FLAGS = 'iOS specific options';
|
||||
|
||||
export default class BuildSubmit extends Command {
|
||||
static description = 'Submits build artifact to app store';
|
||||
static usage = 'submit --platform=(android|ios)';
|
||||
static aliases = ['build:submit'];
|
||||
|
||||
static examples = [
|
||||
`$ eas submit --platform=ios
|
||||
- Fully interactive iOS submission\n`,
|
||||
`$ eas submit --platform=android
|
||||
- Fully interactive Android submission\n`,
|
||||
`$ eas submit -p android --latest --key=/path/to/google-services.json
|
||||
- Minimal non-interactive Android submission, however it can ask you for other params if not specified\n`,
|
||||
`$ EXPO_APPLE_APP_SPECIFIC_PASSWORD=xxx eas submit -p ios --latest [email protected] --asc-app-id=1234567890,
|
||||
- Minimal non-interactive iOS submission, assuming you already have an app in App Store Connect
|
||||
and provide its App ID`,
|
||||
];
|
||||
|
||||
static flags = {
|
||||
platform: flags.enum({
|
||||
char: 'p',
|
||||
description: 'For which platform you want to submit a build',
|
||||
options: ['android', 'ios'],
|
||||
helpLabel: COMMON_FLAGS,
|
||||
}),
|
||||
|
||||
/* Common flags for both platforms */
|
||||
latest: flags.boolean({
|
||||
description: 'Submit the latest build for specified platform',
|
||||
exclusive: ['id', 'path', 'url'],
|
||||
default: false,
|
||||
helpLabel: COMMON_FLAGS,
|
||||
}),
|
||||
id: flags.string({
|
||||
description: 'ID of the build to submit',
|
||||
exclusive: ['latest, path, url'],
|
||||
helpLabel: COMMON_FLAGS,
|
||||
}),
|
||||
path: flags.string({
|
||||
description: 'Path to the .apk/.aab file',
|
||||
exclusive: ['latest', 'id', 'url'],
|
||||
helpLabel: COMMON_FLAGS,
|
||||
}),
|
||||
url: flags.string({
|
||||
description: 'App archive url',
|
||||
exclusive: ['latest', 'id', 'path'],
|
||||
helpLabel: COMMON_FLAGS,
|
||||
}),
|
||||
|
||||
verbose: flags.boolean({
|
||||
description: 'Always print logs from Submission Service',
|
||||
default: false,
|
||||
helpLabel: COMMON_FLAGS,
|
||||
}),
|
||||
|
||||
/* Android specific flags */
|
||||
type: flags.enum<'apk' | 'aab'>({
|
||||
description: 'Android archive type',
|
||||
options: ['apk', 'aab'],
|
||||
helpLabel: ANDROID_FLAGS,
|
||||
}),
|
||||
|
||||
key: flags.string({
|
||||
description: 'Path to the JSON key used to authenticate with Google Play',
|
||||
helpLabel: ANDROID_FLAGS,
|
||||
}),
|
||||
'android-package': flags.string({
|
||||
description: 'Android package name (default: expo.android.package from app config)',
|
||||
helpLabel: ANDROID_FLAGS,
|
||||
}),
|
||||
|
||||
track: flags.enum({
|
||||
description: 'The track of the application to use',
|
||||
default: 'internal',
|
||||
options: ['production', 'beta', 'alpha', 'internal', 'rollout'],
|
||||
helpLabel: ANDROID_FLAGS,
|
||||
}),
|
||||
'release-status': flags.enum({
|
||||
description: 'Release status (used when uploading new APKs/AABs)',
|
||||
default: 'completed',
|
||||
options: ['completed', 'draft', 'halted', 'inProgress'],
|
||||
helpLabel: ANDROID_FLAGS,
|
||||
}),
|
||||
|
||||
/* iOS specific flags */
|
||||
'apple-id': flags.string({
|
||||
description: 'Your Apple ID username (you can also set EXPO_APPLE_ID env variable)',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
'asc-app-id': flags.string({
|
||||
description: `App Store Connect unique application Apple ID number. Providing this param results in skipping app creation step. ${learnMore(
|
||||
'https://expo.fyi/asc-app-id'
|
||||
)}`,
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
'apple-team-id': flags.string({
|
||||
description: 'Your Apple Developer Team ID',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
'app-name': flags.string({
|
||||
description:
|
||||
'The name of your app as it will appear on the App Store (default: expo.name from app config)',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
'bundle-identifier': flags.string({
|
||||
description:
|
||||
'Your iOS Bundle Identifier (default: expo.ios.bundleIdentifier from app config)',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
sku: flags.string({
|
||||
description:
|
||||
'An unique ID for your app that is not visible on the App Store, will be generated unless provided',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
language: flags.string({
|
||||
description: 'Primary language (e.g. English, German, ...)',
|
||||
default: 'en-US',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
'company-name': flags.string({
|
||||
description:
|
||||
'The name of your company, needed only for the first upload of any app to App Store',
|
||||
helpLabel: IOS_FLAGS,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
// android
|
||||
'android-package': androidPackage,
|
||||
'release-status': releaseStatus,
|
||||
|
||||
// ios
|
||||
'apple-id': appleId,
|
||||
'asc-app-id': ascAppId,
|
||||
'apple-team-id': appleTeamId,
|
||||
'app-name': appName,
|
||||
'bundle-identifier': bundleIdentifier,
|
||||
'company-name': companyName,
|
||||
|
||||
// common
|
||||
platform: platformFromParams,
|
||||
...flags
|
||||
},
|
||||
} = this.parse(BuildSubmit);
|
||||
|
||||
await ensureLoggedInAsync();
|
||||
|
||||
const platform = platformFromParams ?? (await promptForPlatformAsync());
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectId = await getProjectIdAsync(projectDir);
|
||||
|
||||
if (!(await isEasEnabledForProjectAsync(projectId))) {
|
||||
warnEasUnavailable();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Make this work outside project dir
|
||||
if (!projectDir) {
|
||||
throw new Error("Please run this command inside your project's directory");
|
||||
}
|
||||
|
||||
if (platform === SubmissionPlatform.Android) {
|
||||
const options: AndroidSubmitCommandFlags = {
|
||||
androidPackage,
|
||||
releaseStatus,
|
||||
...flags,
|
||||
};
|
||||
|
||||
const ctx = AndroidSubmitCommand.createContext(projectDir, projectId, options);
|
||||
const command = new AndroidSubmitCommand(ctx);
|
||||
await command.runAsync();
|
||||
} else if (platform === SubmissionPlatform.iOS) {
|
||||
const options: IosSubmitCommandFlags = {
|
||||
appleId,
|
||||
ascAppId,
|
||||
appleTeamId,
|
||||
appName,
|
||||
bundleIdentifier,
|
||||
companyName,
|
||||
...flags,
|
||||
};
|
||||
|
||||
const ctx = IosSubmitCommand.createContext(projectDir, projectId, options);
|
||||
const command = new IosSubmitCommand(ctx);
|
||||
await command.runAsync();
|
||||
} else {
|
||||
throw new Error(`Unsupported platform: ${platform}!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForPlatformAsync(): Promise<SubmissionPlatform> {
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Submit to platform',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
title: 'iOS',
|
||||
value: SubmissionPlatform.iOS,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
value: SubmissionPlatform.Android,
|
||||
},
|
||||
],
|
||||
});
|
||||
return platform;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
export default class UpdateIndex extends Command {
|
||||
static description = 'create a revision for given channel';
|
||||
|
||||
static aliases = ['update:publish'];
|
||||
|
||||
async run() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Command } from '@oclif/command';
|
||||
|
||||
export default class UpdateShow extends Command {
|
||||
static hidden = true;
|
||||
static description = 'details about a particular revision';
|
||||
|
||||
async run() {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Command } from '@oclif/command';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../log';
|
||||
import { getUserAsync } from '../user/User';
|
||||
|
||||
export default class Whoami extends Command {
|
||||
static description = 'show the username you are logged in as';
|
||||
|
||||
async run() {
|
||||
const user = await getUserAsync();
|
||||
if (user?.username) {
|
||||
log(chalk.green(user.username));
|
||||
} else {
|
||||
log.warn('Not logged in.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,13 @@
|
||||
import log from '../log';
|
||||
import { Context } from './context';
|
||||
|
||||
export interface CredentialsManager {
|
||||
runActionAsync(action: Action): Promise<void>;
|
||||
|
||||
pushNextAction(action: Action): void;
|
||||
|
||||
popAction(): Action | null;
|
||||
export interface Action<T = void> {
|
||||
runAsync(manager: CredentialsManager, ctx: Context): Promise<T>;
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
runAsync(manager: CredentialsManager, ctx: Context): Promise<void>;
|
||||
}
|
||||
export class CredentialsManager {
|
||||
constructor(private ctx: Context) {}
|
||||
|
||||
export class QuitError extends Error {}
|
||||
|
||||
export async function runCredentialsManagerAsync(ctx: Context, startAction: Action): Promise<void> {
|
||||
const manager = new CredentialsManagerImpl(ctx, startAction);
|
||||
await manager.runManagerAsync();
|
||||
}
|
||||
|
||||
class CredentialsManagerImpl implements CredentialsManager {
|
||||
private actionStack: Action[] = [];
|
||||
|
||||
constructor(private ctx: Context, startAction: Action) {
|
||||
this.actionStack.push(startAction);
|
||||
}
|
||||
|
||||
public pushNextAction(action: Action): void {
|
||||
this.actionStack.push(action);
|
||||
}
|
||||
|
||||
public popAction(): Action | null {
|
||||
return this.actionStack.pop() ?? null;
|
||||
}
|
||||
|
||||
public async runActionAsync(action: Action) {
|
||||
await new CredentialsManagerImpl(this.ctx, action).doRunManagerAsync();
|
||||
}
|
||||
|
||||
public async runManagerAsync(): Promise<void> {
|
||||
try {
|
||||
await this.doRunManagerAsync();
|
||||
} catch (error) {
|
||||
if (error instanceof QuitError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async doRunManagerAsync(): Promise<void> {
|
||||
while (true) {
|
||||
try {
|
||||
const currentAction = this.popAction();
|
||||
if (!currentAction) {
|
||||
return;
|
||||
}
|
||||
await currentAction.runAsync(this, this.ctx);
|
||||
} catch (error) {
|
||||
if (error instanceof QuitError) {
|
||||
throw error;
|
||||
}
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
public async runActionAsync<T>(action: Action<T>): Promise<T> {
|
||||
return await action.runAsync(this, this.ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
export interface CredentialsProvider {
|
||||
platform: Platform;
|
||||
hasRemoteAsync(): Promise<boolean>;
|
||||
hasLocalAsync(): Promise<boolean>;
|
||||
isLocalSyncedAsync(): Promise<boolean>;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { User } from '../../user/User';
|
||||
import { Actor } from '../../user/User';
|
||||
|
||||
export const jester: User = {
|
||||
export const jester: Actor = {
|
||||
__typename: 'User',
|
||||
id: 'jester-id',
|
||||
username: 'jester',
|
||||
userId: 'jester-id',
|
||||
accounts: [{ id: 'jester-account-id', name: 'jester' }],
|
||||
};
|
||||
|
||||
export const jester2: User = {
|
||||
export const jester2: Actor = {
|
||||
__typename: 'User',
|
||||
id: 'jester2-id',
|
||||
username: 'jester2',
|
||||
userId: 'jester2-id',
|
||||
accounts: [{ id: 'jester2-account-id', name: 'jester2' }],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import { CredentialsManager } from '../CredentialsManager';
|
||||
import { Context } from '../context';
|
||||
import { getAndroidApiMockWithoutCredentials } from './fixtures-android';
|
||||
import { testAppJson, testUsername } from './fixtures-constants';
|
||||
import { getIosApiMockWithoutCredentials } from './fixtures-ios';
|
||||
|
||||
export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
|
||||
const defaultMock = {
|
||||
ios: jest.fn(),
|
||||
ios: getIosApiMockWithoutCredentials(),
|
||||
android: getAndroidApiMockWithoutCredentials(),
|
||||
appStore: jest.fn(),
|
||||
ensureAppleCtx: jest.fn(),
|
||||
@@ -16,7 +17,7 @@ export function createCtxMock(mockOverride: Record<string, any> = {}): Context {
|
||||
},
|
||||
hasAppleCtx: jest.fn(() => true),
|
||||
hasProjectContext: true,
|
||||
manifest: testAppJson,
|
||||
exp: testAppJson,
|
||||
projectDir: '.',
|
||||
};
|
||||
return merge(defaultMock, mockOverride) as any;
|
||||
@@ -28,10 +29,6 @@ export function createManagerMock(mockOverride: Record<string, any> = {}): Crede
|
||||
runActionAsync: jest.fn(() => {
|
||||
throw new Error('unexpected call'); // should be implemented in test directly
|
||||
}),
|
||||
pushNextAction: jest.fn(() => {
|
||||
throw new Error('unexpected call'); // should be implemented in test directly
|
||||
}),
|
||||
popAction: jest.fn(),
|
||||
},
|
||||
mockOverride
|
||||
);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
DistributionCertificate,
|
||||
DistributionCertificateStoreInfo,
|
||||
ProvisioningProfile,
|
||||
ProvisioningProfileStoreInfo,
|
||||
PushKey,
|
||||
PushKeyStoreInfo,
|
||||
} from '../ios/appstore/Credentials.types';
|
||||
import { IosDistCredentials, IosPushCredentials } from '../ios/credentials';
|
||||
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
|
||||
import { testBundleIdentifier, testExperienceName } from './fixtures-constants';
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
export const testAppleTeam = {
|
||||
id: 'test-team-id',
|
||||
};
|
||||
export const testProvisioningProfile: ProvisioningProfile = {
|
||||
provisioningProfileId: 'test-id',
|
||||
provisioningProfile: testProvisioningProfileBase64,
|
||||
teamId: 'id',
|
||||
};
|
||||
export const testProvisioningProfiles = [testProvisioningProfile];
|
||||
export const testProvisioningProfileFromApple: ProvisioningProfileStoreInfo = {
|
||||
name: 'test-name',
|
||||
status: 'Active',
|
||||
expires: tomorrow.getTime(),
|
||||
distributionMethod: 'test',
|
||||
certificates: [],
|
||||
provisioningProfileId: testProvisioningProfile.provisioningProfileId,
|
||||
provisioningProfile: testProvisioningProfile.provisioningProfile,
|
||||
teamId: 'id',
|
||||
};
|
||||
export const testProvisioningProfilesFromApple = [testProvisioningProfileFromApple];
|
||||
|
||||
export const testDistCert: DistributionCertificate = {
|
||||
certP12: 'Y2VydHAxMg==',
|
||||
certPassword: 'test-password',
|
||||
distCertSerialNumber: 'test-serial',
|
||||
teamId: 'test-team-id',
|
||||
};
|
||||
export const testIosDistCredential: IosDistCredentials = {
|
||||
id: 1,
|
||||
type: 'dist-cert',
|
||||
...testDistCert,
|
||||
};
|
||||
export const testIosDistCredentials = [testIosDistCredential];
|
||||
export const testDistCertFromApple: DistributionCertificateStoreInfo = {
|
||||
id: 'test-id',
|
||||
name: 'test-name',
|
||||
status: 'Active',
|
||||
created: today.getTime(),
|
||||
expires: tomorrow.getTime(),
|
||||
ownerName: 'test-owner',
|
||||
ownerId: 'test-id',
|
||||
serialNumber: testIosDistCredential.distCertSerialNumber as string,
|
||||
};
|
||||
export const testDistCertsFromApple = [testDistCertFromApple];
|
||||
|
||||
export const testPushKey: PushKey = {
|
||||
apnsKeyP8: 'test-p8',
|
||||
apnsKeyId: 'test-key-id',
|
||||
teamId: 'test-team-id',
|
||||
};
|
||||
|
||||
export const testIosPushCredential: IosPushCredentials = {
|
||||
id: 2,
|
||||
type: 'push-key',
|
||||
...testPushKey,
|
||||
};
|
||||
export const testIosPushCredentials = [testIosPushCredential];
|
||||
export const testPushKeyFromApple: PushKeyStoreInfo = {
|
||||
id: testIosPushCredential.apnsKeyId,
|
||||
name: 'test-name',
|
||||
};
|
||||
export const testPushKeysFromApple = [testPushKeyFromApple];
|
||||
export const testLegacyPushCert = {
|
||||
pushId: 'test-push-id',
|
||||
pushP12: 'test-push-p12',
|
||||
pushPassword: 'test-push-password',
|
||||
};
|
||||
export const testAppCredential = {
|
||||
experienceName: testExperienceName,
|
||||
bundleIdentifier: testBundleIdentifier,
|
||||
distCredentialsId: testIosDistCredential.id,
|
||||
pushCredentialsId: testIosPushCredential.id,
|
||||
credentials: {
|
||||
...testProvisioningProfile,
|
||||
},
|
||||
};
|
||||
export const testAllCredentialsForApp = {
|
||||
...testAppCredential,
|
||||
pushCredentials: testPushKey,
|
||||
distCredentials: testDistCert,
|
||||
};
|
||||
export const testAppCredentials = [testAppCredential];
|
||||
export const testAllCredentials = {
|
||||
userCredentials: [...testIosDistCredentials, ...testIosPushCredentials],
|
||||
appCredentials: testAppCredentials,
|
||||
};
|
||||
|
||||
export function getIosApiMock() {
|
||||
return {
|
||||
getAllCredentials: jest.fn(() => testAllCredentials),
|
||||
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
|
||||
getDistributionCertificateByIdAsync: jest.fn(id =>
|
||||
id === testIosDistCredential.id ? testIosDistCredential : null
|
||||
),
|
||||
createDistributionCertificateAsync: jest.fn((owner, cert) => ({
|
||||
id: 123,
|
||||
type: 'dist-cert',
|
||||
...cert,
|
||||
})),
|
||||
updateDistributionCertificateAsync: jest.fn(),
|
||||
deleteDistributionCertificateAsync: jest.fn(),
|
||||
useDistributionCertificateAsync: jest.fn(),
|
||||
getPushKey: jest.fn(() => testIosPushCredential),
|
||||
createPushKeyAsync: jest.fn((owner, key) => ({ id: 124, type: 'push-key', ...key })),
|
||||
updatePushKeyAsync: jest.fn(),
|
||||
deletePushKeyAsync: jest.fn(),
|
||||
usePushKeyAsync: jest.fn(),
|
||||
|
||||
getAppCredentialsAsync: jest.fn(() => testAppCredentials),
|
||||
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
updateProvisioningProfileAsync: jest.fn(),
|
||||
deleteProvisioningProfileAsync: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getIosApiMockWithoutCredentials() {
|
||||
return {
|
||||
getAllCredentials: jest.fn(() => ({ appCredentials: [], userCredentials: [] })),
|
||||
getDistributionCertificateAsync: jest.fn(() => null),
|
||||
getDistributionCertificateByIdAsync: jest.fn(() => {
|
||||
throw new Error('not found');
|
||||
}),
|
||||
createDistributionCertificateAsync: jest.fn(),
|
||||
updateDistributionCertificateAsync: jest.fn(),
|
||||
deleteDistributionCertificateAsync: jest.fn(),
|
||||
useDistributionCertificateAsync: jest.fn(),
|
||||
getPushKeyAsync: jest.fn(() => null),
|
||||
createPushKeyAsync: jest.fn(),
|
||||
updatePushKeyAsync: jest.fn(),
|
||||
deletePushKeyAsync: jest.fn(),
|
||||
usePushKeyAsync: jest.fn(),
|
||||
getAppCredentialsAsync: jest.fn(app => ({
|
||||
experienceName: `${app.accountName}/${app.projectName}`,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
})),
|
||||
getProvisioningProfileAsync: jest.fn(() => null),
|
||||
updateProvisioningProfileAsync: jest.fn(),
|
||||
deleteProvisioningProfileAsync: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
|
||||
import log from '../../log';
|
||||
import { CredentialsManager } from '../CredentialsManager';
|
||||
import { CredentialsProvider } from '../CredentialsProvider';
|
||||
import { Context } from '../context';
|
||||
import * as credentialsJsonReader from '../credentialsJson/read';
|
||||
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
|
||||
import { Keystore } from './credentials';
|
||||
|
||||
export interface AndroidCredentials {
|
||||
keystore: Keystore;
|
||||
}
|
||||
|
||||
interface AppLookupParams {
|
||||
projectName: string;
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
app: AppLookupParams;
|
||||
skipCredentialsCheck?: boolean;
|
||||
}
|
||||
|
||||
export default class AndroidCredentialsProvider implements CredentialsProvider {
|
||||
public readonly platform = Platform.Android;
|
||||
|
||||
constructor(private ctx: Context, private options: Options) {}
|
||||
|
||||
private get projectFullName(): string {
|
||||
const { projectName, accountName } = this.options.app;
|
||||
return `@${accountName}/${projectName}`;
|
||||
}
|
||||
|
||||
public async hasRemoteAsync(): Promise<boolean> {
|
||||
const keystore = await this.ctx.android.fetchKeystoreAsync(this.projectFullName);
|
||||
return !!keystore;
|
||||
}
|
||||
|
||||
public async hasLocalAsync(): Promise<boolean> {
|
||||
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
|
||||
return !!rawCredentialsJson?.android;
|
||||
} catch (err) {
|
||||
log.error(err); // malformed json
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async isLocalSyncedAsync(): Promise<boolean> {
|
||||
try {
|
||||
const [remote, local] = await Promise.all([
|
||||
this.ctx.android.fetchKeystoreAsync(this.projectFullName),
|
||||
credentialsJsonReader.readAndroidCredentialsAsync(this.ctx.projectDir),
|
||||
]);
|
||||
const r = remote;
|
||||
const l = local?.keystore;
|
||||
return !!(
|
||||
r &&
|
||||
l &&
|
||||
r.keystore === l.keystore &&
|
||||
r.keystorePassword === l.keystorePassword &&
|
||||
r.keyAlias === l.keyAlias &&
|
||||
r.keyPassword === l.keyPassword
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getCredentialsAsync(
|
||||
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
|
||||
): Promise<AndroidCredentials> {
|
||||
switch (src) {
|
||||
case CredentialsSource.LOCAL:
|
||||
return await this.getLocalAsync();
|
||||
case CredentialsSource.REMOTE:
|
||||
return await this.getRemoteAsync();
|
||||
}
|
||||
throw new Error('Unknown');
|
||||
}
|
||||
|
||||
private async getRemoteAsync(): Promise<AndroidCredentials> {
|
||||
await new CredentialsManager(this.ctx).runActionAsync(
|
||||
new SetupBuildCredentials(this.projectFullName)
|
||||
);
|
||||
const keystore = await this.ctx.android.fetchKeystoreAsync(this.projectFullName);
|
||||
if (!keystore) {
|
||||
throw new Error('Unable to set up credentials, failed to fetch keystore from EAS servers');
|
||||
}
|
||||
return { keystore };
|
||||
}
|
||||
|
||||
private async getLocalAsync(): Promise<AndroidCredentials> {
|
||||
return await credentialsJsonReader.readAndroidCredentialsAsync(this.ctx.projectDir);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -19,6 +17,6 @@ export class UpdateFcmKey implements Action {
|
||||
]);
|
||||
|
||||
await ctx.android.updateFcmKeyAsync(this.experienceName, fcmApiKey);
|
||||
log(chalk.green('Updated successfully'));
|
||||
log.succeed('Updated successfully');
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export class RemoveKeystore implements Action {
|
||||
await manager.runActionAsync(new BackupKeystore(this.projectFullName));
|
||||
|
||||
await ctx.android.removeKeystoreAsync(this.projectFullName);
|
||||
log(chalk.green('Keystore removed successfully.'));
|
||||
log.succeed('Keystore removed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
import { readAndroidCredentialsAsync } from '../../credentialsJson/read';
|
||||
import { validateKeystoreAsync } from '../utils/keystore';
|
||||
import { UpdateKeystore } from './UpdateKeystore';
|
||||
|
||||
export class SetupBuildCredentials implements Action {
|
||||
@@ -13,6 +16,30 @@ export class SetupBuildCredentials implements Action {
|
||||
throw new Error('Generating a new Keystore is not supported in --non-interactive mode');
|
||||
}
|
||||
|
||||
manager.pushNextAction(new UpdateKeystore(this.projectFullName));
|
||||
await manager.runActionAsync(new UpdateKeystore(this.projectFullName));
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupBuildCredentialsFromCredentialsJson implements Action {
|
||||
constructor(
|
||||
private projectFullName: string,
|
||||
private options: { skipKeystoreValidation: boolean }
|
||||
) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
let localCredentials;
|
||||
try {
|
||||
localCredentials = await readAndroidCredentialsAsync(ctx.projectDir);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Reading credentials from credentials.json failed. Make sure this file is correct and all credentials are present there.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
if (!this.options.skipKeystoreValidation) {
|
||||
await validateKeystoreAsync(localCredentials.keystore);
|
||||
}
|
||||
await ctx.android.updateKeystoreAsync(this.projectFullName, localCredentials.keystore);
|
||||
log.succeed('Keystore updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
import { Context } from '../../context';
|
||||
import { updateAndroidCredentialsAsync } from '../../credentialsJson/update';
|
||||
|
||||
export class UpdateCredentialsJson implements Action {
|
||||
constructor(private projectFullName: string) {}
|
||||
|
||||
async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
log('Updating Android credentials in credentials.json');
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
log.succeed(
|
||||
'Android part of your local credentials.json is synced with values stored on EAS servers.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class UpdateKeystore implements Action {
|
||||
const keystore = await this.provideOrGenerateAsync();
|
||||
|
||||
await ctx.android.updateKeystoreAsync(this.projectFullName, keystore);
|
||||
log(chalk.green('Keystore updated successfully.'));
|
||||
log.succeed('Keystore updated');
|
||||
}
|
||||
|
||||
private async provideOrGenerateAsync(): Promise<Keystore> {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('run RemoveKeystore when keystore does exist', () => {
|
||||
|
||||
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
|
||||
@@ -59,7 +59,6 @@ describe('run RemoveKeystore when keystore does exist', () => {
|
||||
|
||||
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -83,7 +82,7 @@ describe('run RemoveKeystore when keystore does exist', () => {
|
||||
}
|
||||
|
||||
expect(prompts).not.toHaveBeenCalled();
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -100,7 +99,7 @@ describe('run RemoveKeystore when keystore does not exist', () => {
|
||||
|
||||
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).not.toHaveBeenCalled();
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
|
||||
@@ -115,7 +114,7 @@ describe('run RemoveKeystore when keystore does not exist', () => {
|
||||
|
||||
await new RemoveKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).not.toHaveBeenCalled();
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.removeKeystoreAsync).not.toHaveBeenCalled();
|
||||
|
||||
+4
-4
@@ -38,7 +38,7 @@ describe('run SetupBuildCredentials when www has valid credentials', () => {
|
||||
|
||||
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe('run SetupBuildCredentials when www has valid credentials', () => {
|
||||
|
||||
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -69,12 +69,12 @@ describe('run SetupBuildCredentials when www has no credentials', () => {
|
||||
});
|
||||
const manager = createManagerMock();
|
||||
|
||||
(manager.pushNextAction as any).mockImplementationOnce((action: Action) => {
|
||||
(manager.runActionAsync as any).mockImplementationOnce((action: Action) => {
|
||||
expect(action).toBeInstanceOf(UpdateKeystore);
|
||||
});
|
||||
await new SetupBuildCredentials(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.runActionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(0); // will be called in UpdateKeystore
|
||||
});
|
||||
|
||||
@@ -38,11 +38,11 @@ describe('run UpdateKeystore when keystore exist on www', () => {
|
||||
});
|
||||
const manager = createManagerMock();
|
||||
|
||||
(prompts as any).mockImplementationOnce(() => ({ answer: false })); // promptForCredentials: let expo handle
|
||||
(prompts as any).mockImplementationOnce(() => ({ value: true })); // promptForCredentials: let expo handle
|
||||
|
||||
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -58,7 +58,7 @@ describe('run UpdateKeystore when keystore exist on www', () => {
|
||||
|
||||
await fs.writeFile('/keystore.jks', testKeystore.keystore, 'base64');
|
||||
(prompts as any)
|
||||
.mockImplementationOnce(() => ({ answer: true })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ value: false })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ input: '/keystore.jks' })) // keystore
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // keystore password
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // key alias
|
||||
@@ -66,14 +66,14 @@ describe('run UpdateKeystore when keystore exist on www', () => {
|
||||
|
||||
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).toHaveBeenCalledTimes(5);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run UpdateKeystore when keystore deos not exist on www', () => {
|
||||
describe('run UpdateKeystore when keystore does not exist on www', () => {
|
||||
it('should fail to fetch credentials and generate new ones', async () => {
|
||||
const ctx = createCtxMock({
|
||||
android: {
|
||||
@@ -82,11 +82,11 @@ describe('run UpdateKeystore when keystore deos not exist on www', () => {
|
||||
});
|
||||
const manager = createManagerMock();
|
||||
|
||||
(prompts as any).mockImplementationOnce(() => ({ answer: false })); // Let expo handle
|
||||
(prompts as any).mockImplementationOnce(() => ({ value: true })); // Let expo handle
|
||||
|
||||
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -102,7 +102,7 @@ describe('run UpdateKeystore when keystore deos not exist on www', () => {
|
||||
|
||||
await fs.writeFile('/keystore.jks', testKeystore.keystore, 'base64');
|
||||
(prompts as any)
|
||||
.mockImplementationOnce(() => ({ answer: true })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ value: false })) // promptForCredentials: user specified
|
||||
.mockImplementationOnce(() => ({ input: '/keystore.jks' })) // keystore
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // keystore password
|
||||
.mockImplementationOnce(() => ({ input: 'test' })) // key alias
|
||||
@@ -110,7 +110,7 @@ describe('run UpdateKeystore when keystore deos not exist on www', () => {
|
||||
|
||||
await new UpdateKeystore(testExperienceName).runAsync(manager, ctx);
|
||||
|
||||
expect(manager.pushNextAction).not.toHaveBeenCalled();
|
||||
expect(manager.runActionAsync).not.toHaveBeenCalled();
|
||||
expect(prompts).toHaveBeenCalledTimes(5);
|
||||
expect(ctx.android.fetchKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateKeystoreAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -20,9 +20,7 @@ export type AndroidCredentials = {
|
||||
export const keystoreSchema: CredentialSchema<Keystore> = {
|
||||
name: 'Android Keystore',
|
||||
provideMethodQuestion: {
|
||||
question: `Would you like to upload a Keystore or have us generate one for you?\nIf you don't know what this means, let us generate it! :)`,
|
||||
expoGenerated: 'Generate new keystore',
|
||||
userProvided: 'I want to upload my own file',
|
||||
question: `Generate a new Android Keystore?`,
|
||||
},
|
||||
questions: [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import spawnAsync from '@expo/spawn-async';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import tempy from 'tempy';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import log from '../../../log';
|
||||
@@ -12,10 +13,7 @@ export async function keytoolCommandExistsAsync(): Promise<boolean> {
|
||||
try {
|
||||
await spawnAsync('keytool');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -128,3 +126,24 @@ export async function generateRandomKeystoreAsync(): Promise<Keystore> {
|
||||
};
|
||||
return await createKeystoreAsync(keystoreData);
|
||||
}
|
||||
|
||||
export async function validateKeystoreAsync(keystore: Keystore): Promise<void> {
|
||||
await ensureKeytoolCommandExistsAsync();
|
||||
try {
|
||||
await tempy.write.task(Buffer.from(keystore.keystore, 'base64'), async keystorePath => {
|
||||
await spawnAsync('keytool', [
|
||||
'-list',
|
||||
'-keystore',
|
||||
keystorePath,
|
||||
'-storepass',
|
||||
keystore.keystorePassword,
|
||||
'-alias',
|
||||
keystore.keyAlias,
|
||||
]);
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`An error occurred when validating the Android keystore: ${e.stdout ?? e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import chalk from 'chalk';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import log from '../log';
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { User } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync, getActorDisplayName } from '../user/actions';
|
||||
import AndroidApi from './android/api/Client';
|
||||
import iOSApi from './ios/api/Client';
|
||||
import * as IosGraphqlClient from './ios/api/GraphqlClient';
|
||||
import AppStoreApi from './ios/appstore/AppStoreApi';
|
||||
|
||||
interface AppleCtxOptions {
|
||||
@@ -22,10 +24,11 @@ interface Options extends AppleCtxOptions {
|
||||
|
||||
export interface Context {
|
||||
readonly projectDir: string;
|
||||
readonly user: User;
|
||||
readonly user: Actor;
|
||||
readonly nonInteractive: boolean;
|
||||
readonly android: AndroidApi;
|
||||
readonly ios: iOSApi;
|
||||
readonly newIos: typeof IosGraphqlClient;
|
||||
readonly appStore: AppStoreApi;
|
||||
readonly hasProjectContext: boolean;
|
||||
readonly exp: ExpoConfig;
|
||||
@@ -54,13 +57,14 @@ export async function createCredentialsContextAsync(
|
||||
class CredentialsContext implements Context {
|
||||
public readonly android = new AndroidApi();
|
||||
public readonly ios = new iOSApi();
|
||||
public readonly newIos = IosGraphqlClient;
|
||||
public readonly appStore: AppStoreApi;
|
||||
public readonly nonInteractive: boolean;
|
||||
private shouldAskAuthenticateAppStore: boolean = true;
|
||||
|
||||
constructor(
|
||||
public readonly projectDir: string,
|
||||
public readonly user: User,
|
||||
public readonly user: Actor,
|
||||
private _exp: ExpoConfig | undefined,
|
||||
options: Options
|
||||
) {
|
||||
@@ -87,15 +91,17 @@ class CredentialsContext implements Context {
|
||||
|
||||
public logOwnerAndProject() {
|
||||
if (this.hasProjectContext) {
|
||||
const owner = getProjectAccountName(this.exp, this.user);
|
||||
// Figure out if User A is configuring credentials as admin for User B's project
|
||||
const isProxyUser = this.exp.owner && this.exp.owner !== this.user.username;
|
||||
const isProxyUser = this.user.__typename === 'Robot' || owner !== this.user.username;
|
||||
|
||||
log(
|
||||
`Accessing credentials ${isProxyUser ? 'on behalf of' : 'for'} ${
|
||||
this.exp.owner ?? this.user.username
|
||||
} in project ${this.exp.slug}`
|
||||
`Accessing credentials ${isProxyUser ? 'on behalf of' : 'for'} ${owner} in project ${
|
||||
this.exp.slug
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
log(`Accessing credentials for ${this.exp.owner ?? this.user.username}`);
|
||||
log(`Accessing credentials for ${this.exp.owner ?? getActorDisplayName(this.user)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +122,7 @@ class CredentialsContext implements Context {
|
||||
);
|
||||
log(
|
||||
chalk.green(
|
||||
'This is optional, but without Apple account access you will need to provide all the values manually and we can only run minimal validatation on them.'
|
||||
'This is optional, but without Apple account access you will need to provide all the values manually and we can only run minimal validation on them.'
|
||||
)
|
||||
);
|
||||
const confirm = await confirmAsync({
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import * as credentialsJsonReader from '../read';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
describe('credentialsJson', () => {
|
||||
describe('readAndroidAsync', () => {
|
||||
it('should read android credentials if everything is correct', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'keystore.jks': 'somebinarydata',
|
||||
});
|
||||
const result = await credentialsJsonReader.readAndroidCredentialsAsync('.');
|
||||
expect(result).toEqual({
|
||||
keystore: {
|
||||
keystore: 'c29tZWJpbmFyeWRhdGE=',
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should throw error when credentials.json is missing', async () => {
|
||||
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow(
|
||||
'credentials.json must exist in the project root directory and contain a valid JSON'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when android entry is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({}),
|
||||
'keystore.jks': 'somebinarydata',
|
||||
});
|
||||
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow(
|
||||
'Android credentials are missing from credentials.json'
|
||||
);
|
||||
});
|
||||
it('should throw error when one of the required fields is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'keystore.jks': 'somebinarydata',
|
||||
});
|
||||
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow(
|
||||
'credentials.json is not valid [ValidationError: "android.keystore.keystorePath" is required]'
|
||||
);
|
||||
});
|
||||
it('should throw error when file specified in cedentials.json is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const promise = credentialsJsonReader.readAndroidCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow(
|
||||
"ENOENT: no such file or directory, open 'keystore.jks'"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readIosAsync', () => {
|
||||
describe('single target credentials', () => {
|
||||
it('should read ios credentials if everything is correct', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
expect(result).toEqual({
|
||||
provisioningProfile: 'c29tZWJpbmFyeWNvbnRlbnQ=',
|
||||
distributionCertificate: {
|
||||
certP12: 'c29tZWJpbmFyeWNvbnRlbnQy',
|
||||
certPassword: 'certPass',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should throw error when credentials.json is missing', async () => {
|
||||
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow(
|
||||
'credentials.json must exist in the project root directory and contain a valid JSON'
|
||||
);
|
||||
});
|
||||
it('should throw error if ios field is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow('iOS credentials are missing from credentials.json');
|
||||
});
|
||||
it('should throw error if some field is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow(
|
||||
'credentials.json is not valid [ValidationError: "ios" does not match any of the allowed types]'
|
||||
);
|
||||
});
|
||||
it('should throw error if dist cert file is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
});
|
||||
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'cert.p12'");
|
||||
});
|
||||
it('should throw error if provisioningProfile file is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
const promise = credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
await expect(promise).rejects.toThrow("ENOENT: no such file or directory, open 'pprofile'");
|
||||
});
|
||||
});
|
||||
describe('multi-target credentials', () => {
|
||||
it('should read ios credentials if everything is correct', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
target1: {
|
||||
provisioningProfilePath: 'pprofile-1.mobileprovision',
|
||||
distributionCertificate: {
|
||||
path: 'dist-cert-1.p12',
|
||||
password: 'cert-pass-1',
|
||||
},
|
||||
},
|
||||
target2: {
|
||||
provisioningProfilePath: 'pprofile-2.mobileprovision',
|
||||
distributionCertificate: {
|
||||
path: 'dist-cert-2.p12',
|
||||
password: 'cert-pass-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
|
||||
'./pprofile-2.mobileprovision': 'pprofile-2-somebinarycontent',
|
||||
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
|
||||
'./dist-cert-2.p12': 'cert-2-somebinarycontent',
|
||||
});
|
||||
const result = await credentialsJsonReader.readIosCredentialsAsync('.');
|
||||
expect(result).toEqual({
|
||||
target1: {
|
||||
provisioningProfile: 'cHByb2ZpbGUtMS1zb21lYmluYXJ5Y29udGVudA==',
|
||||
distributionCertificate: {
|
||||
certP12: 'Y2VydC0xLXNvbWViaW5hcnljb250ZW50',
|
||||
certPassword: 'cert-pass-1',
|
||||
},
|
||||
},
|
||||
target2: {
|
||||
provisioningProfile: 'cHByb2ZpbGUtMi1zb21lYmluYXJ5Y29udGVudA==',
|
||||
distributionCertificate: {
|
||||
certP12: 'Y2VydC0yLXNvbWViaW5hcnljb250ZW50',
|
||||
certPassword: 'cert-pass-2',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSecretEnvsAsync', () => {
|
||||
it('should read secretEnvs field correctly', async () => {
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
npmToken: 'VALUE',
|
||||
},
|
||||
}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
const result = await credentialsJsonReader.readSecretEnvsAsync('.');
|
||||
expect(result).toEqual({
|
||||
NPM_TOKEN: 'VALUE',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import prompts from 'prompts';
|
||||
|
||||
import { asMock } from '../../../__tests__/utils';
|
||||
import { testKeystore } from '../../__tests__/fixtures-android';
|
||||
import { createCtxMock } from '../../__tests__/fixtures-context';
|
||||
import {
|
||||
testAllCredentialsForApp,
|
||||
testIosDistCredential,
|
||||
testProvisioningProfile,
|
||||
} from '../../__tests__/fixtures-ios';
|
||||
import { updateAndroidCredentialsAsync, updateIosCredentialsAsync } from '../update';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('prompts');
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleWarn = console.warn;
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
beforeAll(() => {
|
||||
console.log = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
asMock(prompts).mockReset();
|
||||
asMock(prompts).mockImplementation(() => {
|
||||
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update credentials.json', () => {
|
||||
describe(updateAndroidCredentialsAsync, () => {
|
||||
it('should update keystore in credentials.json if www returns valid credentials', async () => {
|
||||
const ctx = createCtxMock({
|
||||
android: {
|
||||
fetchKeystoreAsync: jest.fn(() => testKeystore),
|
||||
},
|
||||
});
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'keystore.jks': 'somebinarydata',
|
||||
});
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
const keystore = await fs.readFile('./keystore.jks', 'base64');
|
||||
const credJson = await fs.readJson('./credentials.json');
|
||||
expect(keystore).toEqual(testKeystore.keystore);
|
||||
expect(credJson).toEqual({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: testKeystore.keystorePassword,
|
||||
keyAlias: testKeystore.keyAlias,
|
||||
keyPassword: testKeystore.keyPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should create credentials.json and keystore if credentials.json does not exist', async () => {
|
||||
const ctx = createCtxMock({
|
||||
android: {
|
||||
fetchKeystoreAsync: jest.fn(() => testKeystore),
|
||||
},
|
||||
});
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
const keystore = await fs.readFile('./android/keystores/keystore.jks', 'base64');
|
||||
const credJson = await fs.readJson('./credentials.json');
|
||||
expect(keystore).toEqual(testKeystore.keystore);
|
||||
expect(credJson).toEqual({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'android/keystores/keystore.jks',
|
||||
keystorePassword: testKeystore.keystorePassword,
|
||||
keyAlias: testKeystore.keyAlias,
|
||||
keyPassword: testKeystore.keyPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should not do anything if keystore in www is missing', async () => {
|
||||
const ctx = createCtxMock();
|
||||
const credJson = {
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
},
|
||||
};
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify(credJson),
|
||||
'keystore.jks': 'somebinarydata',
|
||||
});
|
||||
try {
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
throw new Error('updateAndroidCredentialsAsync should throw an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(
|
||||
'There are no credentials configured for this project on EAS servers'
|
||||
);
|
||||
}
|
||||
const keystore = await fs.readFile('./keystore.jks', 'base64');
|
||||
const newCredJson = await fs.readJson('./credentials.json');
|
||||
expect(keystore).toEqual('c29tZWJpbmFyeWRhdGE='); // base64 "somebinarydata"
|
||||
expect(newCredJson).toEqual(credJson);
|
||||
});
|
||||
it('should update keystore and credentials.json if android part of credentials.json is not valid', async () => {
|
||||
const ctx = createCtxMock({
|
||||
android: {
|
||||
fetchKeystoreAsync: jest.fn(() => testKeystore),
|
||||
},
|
||||
});
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({ android: { test: '123' } }),
|
||||
});
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
const keystore = await fs.readFile('./android/keystores/keystore.jks', 'base64');
|
||||
const credJson = await fs.readJson('./credentials.json');
|
||||
expect(keystore).toEqual(testKeystore.keystore);
|
||||
expect(credJson).toEqual({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'android/keystores/keystore.jks',
|
||||
keystorePassword: testKeystore.keystorePassword,
|
||||
keyAlias: testKeystore.keyAlias,
|
||||
keyPassword: testKeystore.keyPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should update keystore and credentials.json if ios part of credentials.json is not valid', async () => {
|
||||
const ctx = createCtxMock({
|
||||
android: {
|
||||
fetchKeystoreAsync: jest.fn(() => testKeystore),
|
||||
},
|
||||
});
|
||||
const credJson = {
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: 'keystorePassword',
|
||||
keyAlias: 'keyAlias',
|
||||
keyPassword: 'keyPassword',
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
test: '123',
|
||||
},
|
||||
};
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify(credJson),
|
||||
'keystore.jks': 'somebinarydata',
|
||||
});
|
||||
await updateAndroidCredentialsAsync(ctx);
|
||||
const keystore = await fs.readFile('./keystore.jks', 'base64');
|
||||
const newCredJson = await fs.readJson('./credentials.json');
|
||||
expect(keystore).toEqual(testKeystore.keystore);
|
||||
expect(newCredJson).toEqual({
|
||||
android: {
|
||||
keystore: {
|
||||
keystorePath: 'keystore.jks',
|
||||
keystorePassword: testKeystore.keystorePassword,
|
||||
keyAlias: testKeystore.keyAlias,
|
||||
keyPassword: testKeystore.keyPassword,
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
test: '123',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(updateIosCredentialsAsync, () => {
|
||||
it('should update ios credentials in credentials.json if www returns valid credentials', async () => {
|
||||
const ctx = createCtxMock({
|
||||
ios: {
|
||||
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
|
||||
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
|
||||
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
},
|
||||
});
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
|
||||
const certP12 = await fs.readFile('./cert.p12', 'base64');
|
||||
const pprofile = await fs.readFile('./pprofile', 'base64');
|
||||
const credJson = await fs.readJson('./credentials.json');
|
||||
expect(certP12).toEqual(testAllCredentialsForApp.distCredentials.certP12);
|
||||
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
|
||||
expect(credJson).toEqual({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: testAllCredentialsForApp.distCredentials.certPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should create credentials.json provisioning profile and distribution certificate if credentials.json does not exist', async () => {
|
||||
const ctx = createCtxMock({
|
||||
ios: {
|
||||
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
|
||||
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
|
||||
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
},
|
||||
});
|
||||
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
|
||||
const certP12 = await fs.readFile('./ios/certs/dist-cert.p12', 'base64');
|
||||
const pprofile = await fs.readFile('./ios/certs/profile.mobileprovision', 'base64');
|
||||
const credJson = await fs.readJson('./credentials.json');
|
||||
expect(certP12).toEqual(testAllCredentialsForApp.distCredentials.certP12);
|
||||
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
|
||||
expect(credJson).toEqual({
|
||||
ios: {
|
||||
provisioningProfilePath: 'ios/certs/profile.mobileprovision',
|
||||
distributionCertificate: {
|
||||
path: 'ios/certs/dist-cert.p12',
|
||||
password: testAllCredentialsForApp.distCredentials.certPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should not do anything if no credentials are returned from www', async () => {
|
||||
const ctx = createCtxMock({
|
||||
ios: {
|
||||
getAppCredentialsAsync: jest.fn(() => null),
|
||||
getDistributionCertificateAsync: jest.fn(() => null),
|
||||
getProvisioningProfileAsync: jest.fn(() => null),
|
||||
},
|
||||
});
|
||||
const credJson = {
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
};
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify(credJson),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
try {
|
||||
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
|
||||
throw new Error('updateIosCredentialsAsync should throw na error');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(
|
||||
'There are no credentials configured for this project on EAS servers'
|
||||
);
|
||||
}
|
||||
const certP12 = await fs.readFile('./cert.p12', 'base64');
|
||||
const pprofile = await fs.readFile('./pprofile', 'base64');
|
||||
const newCredJson = await fs.readJson('./credentials.json');
|
||||
expect(certP12).toEqual('c29tZWJpbmFyeWNvbnRlbnQy'); // base64 "somebinarycontent2"
|
||||
expect(pprofile).toEqual('c29tZWJpbmFyeWNvbnRlbnQ='); // base64 "somebinarycontent"
|
||||
expect(newCredJson).toEqual(credJson);
|
||||
});
|
||||
it('should display confirm prompt if some credentials are missing in www (confirm: true)', async () => {
|
||||
const ctx = createCtxMock({
|
||||
ios: {
|
||||
getAppCredentialsAsync: jest.fn(() => ({
|
||||
...testAllCredentialsForApp,
|
||||
distCredentialsId: undefined,
|
||||
distCredentials: undefined,
|
||||
})),
|
||||
getDistributionCertificateAsync: jest.fn(() => null),
|
||||
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
},
|
||||
});
|
||||
|
||||
(prompts as any)
|
||||
.mockImplementationOnce(() => ({ value: true })) // Continue with partial credentials
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
distributionCertificate: {
|
||||
path: 'cert.p12',
|
||||
password: 'certPass',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile': 'somebinarycontent',
|
||||
'./cert.p12': 'somebinarycontent2',
|
||||
});
|
||||
await updateIosCredentialsAsync(ctx, 'bundleIdentifier');
|
||||
const certP12Exists = await fs.pathExists('./cert.p12');
|
||||
const pprofile = await fs.readFile('./pprofile', 'base64');
|
||||
const credJson = await fs.readJson('./credentials.json');
|
||||
expect(certP12Exists).toEqual(false);
|
||||
expect(pprofile).toEqual(testAllCredentialsForApp.credentials.provisioningProfile);
|
||||
expect(credJson).toEqual({
|
||||
ios: {
|
||||
provisioningProfilePath: 'pprofile',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should throw an error if credentials.json contains credentials for multi-target projects', async () => {
|
||||
const ctx = createCtxMock({
|
||||
ios: {
|
||||
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
|
||||
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
|
||||
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
},
|
||||
});
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
target1: {
|
||||
provisioningProfilePath: 'pprofile-1.mobileprovision',
|
||||
distributionCertificate: {
|
||||
path: 'dist-cert-1.p12',
|
||||
password: 'cert-pass-1',
|
||||
},
|
||||
},
|
||||
target2: {
|
||||
provisioningProfilePath: 'pprofile-2.mobileprovision',
|
||||
distributionCertificate: {
|
||||
path: 'dist-cert-2.p12',
|
||||
password: 'cert-pass-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
|
||||
'./pprofile-2.mobileprovision': 'pprofile-2-somebinarycontent',
|
||||
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
|
||||
'./dist-cert-2.p12': 'cert-2-somebinarycontent',
|
||||
});
|
||||
await expect(() => updateIosCredentialsAsync(ctx, 'bundleIdentifier')).rejects.toThrow(
|
||||
/Updating credentials.json failed/
|
||||
);
|
||||
});
|
||||
it(`should throw an error if there is a typo in credentials.json (because we can't tell if this is a target name or not)`, async () => {
|
||||
const ctx = createCtxMock({
|
||||
ios: {
|
||||
getAppCredentialsAsync: jest.fn(() => testAllCredentialsForApp),
|
||||
getDistributionCertificateAsync: jest.fn(() => testIosDistCredential),
|
||||
getProvisioningProfileAsync: jest.fn(() => testProvisioningProfile),
|
||||
},
|
||||
});
|
||||
vol.fromJSON({
|
||||
'./credentials.json': JSON.stringify({
|
||||
ios: {
|
||||
// typo!!!
|
||||
xprovisioningProfilePath: 'pprofile-1.mobileprovision',
|
||||
distributionCertificate: {
|
||||
path: 'dist-cert-1.p12',
|
||||
password: 'cert-pass-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'./pprofile-1.mobileprovision': 'pprofile-1-somebinarycontent',
|
||||
'./dist-cert-1.p12': 'cert-1-somebinarycontent',
|
||||
});
|
||||
await expect(() => updateIosCredentialsAsync(ctx, 'bundleIdentifier')).rejects.toThrow(
|
||||
/Updating credentials.json failed/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import Joi from '@hapi/joi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { Keystore } from '../android/credentials';
|
||||
|
||||
interface CredentialsJsonIosCredentials {
|
||||
provisioningProfilePath: string;
|
||||
distributionCertificate: {
|
||||
path: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
export interface CredentialsJson {
|
||||
android?: {
|
||||
keystore: {
|
||||
keystorePath: string;
|
||||
keystorePassword: string;
|
||||
keyAlias: string;
|
||||
keyPassword: string;
|
||||
};
|
||||
};
|
||||
ios?: CredentialsJsonIosCredentials | Record<string, CredentialsJsonIosCredentials>;
|
||||
experimental?: {
|
||||
npmToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const IosTargetCredentials = Joi.object({
|
||||
provisioningProfilePath: Joi.string().required(),
|
||||
distributionCertificate: Joi.object({
|
||||
path: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
}).required(),
|
||||
});
|
||||
|
||||
const CredentialsJsonSchema = Joi.object({
|
||||
android: Joi.object({
|
||||
keystore: Joi.object({
|
||||
keystorePath: Joi.string().required(),
|
||||
keystorePassword: Joi.string().required(),
|
||||
keyAlias: Joi.string().required(),
|
||||
keyPassword: Joi.string().required(),
|
||||
}).required(),
|
||||
}),
|
||||
ios: [
|
||||
IosTargetCredentials,
|
||||
Joi.object().pattern(Joi.string().required(), IosTargetCredentials.required()),
|
||||
],
|
||||
experimental: Joi.object({
|
||||
npmToken: Joi.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
interface AndroidCredentials {
|
||||
keystore: Keystore;
|
||||
}
|
||||
|
||||
export interface IosTargetCredentials {
|
||||
provisioningProfile: string;
|
||||
distributionCertificate: {
|
||||
certP12: string;
|
||||
certPassword: string;
|
||||
};
|
||||
}
|
||||
export type IosTargetCredentialsMap = Record<string, IosTargetCredentials>;
|
||||
export type IosCredentials = IosTargetCredentials | IosTargetCredentialsMap;
|
||||
|
||||
export async function fileExistsAsync(projectDir: string): Promise<boolean> {
|
||||
return await fs.pathExists(path.join(projectDir, 'credentials.json'));
|
||||
}
|
||||
|
||||
export async function readAndroidCredentialsAsync(projectDir: string): Promise<AndroidCredentials> {
|
||||
const credentialsJson = await readAsync(projectDir);
|
||||
if (!credentialsJson.android) {
|
||||
throw new Error('Android credentials are missing from credentials.json');
|
||||
}
|
||||
const keystoreInfo = credentialsJson.android.keystore;
|
||||
return {
|
||||
keystore: {
|
||||
keystore: await fs.readFile(getAbsolutePath(projectDir, keystoreInfo.keystorePath), 'base64'),
|
||||
keystorePassword: keystoreInfo.keystorePassword,
|
||||
keyAlias: keystoreInfo.keyAlias,
|
||||
keyPassword: keystoreInfo.keyPassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readIosCredentialsAsync(projectDir: string): Promise<IosCredentials> {
|
||||
const credentialsJson = await readAsync(projectDir);
|
||||
if (!credentialsJson.ios) {
|
||||
throw new Error('iOS credentials are missing from credentials.json');
|
||||
}
|
||||
|
||||
if (!isInternalCredentialsMap(credentialsJson.ios)) {
|
||||
return await readCredentialsForTargetAsync(projectDir, credentialsJson.ios);
|
||||
} else {
|
||||
const targets = Object.keys(credentialsJson.ios);
|
||||
const targetCredentialsMap: IosTargetCredentialsMap = {};
|
||||
for (const target of targets) {
|
||||
targetCredentialsMap[target] = await readCredentialsForTargetAsync(
|
||||
projectDir,
|
||||
credentialsJson.ios[target]
|
||||
);
|
||||
}
|
||||
return targetCredentialsMap;
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalCredentialsMap(
|
||||
ios: CredentialsJsonIosCredentials | Record<string, CredentialsJsonIosCredentials>
|
||||
): ios is Record<string, CredentialsJsonIosCredentials> {
|
||||
return typeof ios.provisioningProfilePath !== 'string';
|
||||
}
|
||||
|
||||
export function isCredentialsMap(ios: IosCredentials): ios is IosTargetCredentialsMap {
|
||||
return typeof ios.provisioningProfile !== 'string';
|
||||
}
|
||||
|
||||
async function readCredentialsForTargetAsync(
|
||||
projectDir: string,
|
||||
targetCredentials: CredentialsJsonIosCredentials
|
||||
): Promise<IosTargetCredentials> {
|
||||
return {
|
||||
provisioningProfile: await fs.readFile(
|
||||
getAbsolutePath(projectDir, targetCredentials.provisioningProfilePath),
|
||||
'base64'
|
||||
),
|
||||
distributionCertificate: {
|
||||
certP12: await fs.readFile(
|
||||
getAbsolutePath(projectDir, targetCredentials.distributionCertificate.path),
|
||||
'base64'
|
||||
),
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSecretEnvsAsync(
|
||||
projectDir: string
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!(await fileExistsAsync(projectDir))) {
|
||||
return undefined;
|
||||
}
|
||||
const credentialsJson = await readAsync(projectDir);
|
||||
const npmToken = credentialsJson?.experimental?.npmToken;
|
||||
return npmToken ? { NPM_TOKEN: npmToken } : undefined;
|
||||
}
|
||||
|
||||
async function readAsync(projectDir: string): Promise<CredentialsJson> {
|
||||
const credentialsJSONRaw = await readRawAsync(projectDir);
|
||||
|
||||
const { value: credentialsJson, error } = CredentialsJsonSchema.validate(credentialsJSONRaw, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(`credentials.json is not valid [${error.toString()}]`);
|
||||
}
|
||||
|
||||
return credentialsJson;
|
||||
}
|
||||
|
||||
export async function readRawAsync(projectDir: string): Promise<any> {
|
||||
const credentialsJsonFilePath = path.join(projectDir, 'credentials.json');
|
||||
try {
|
||||
const credentialsJSONContents = await fs.readFile(credentialsJsonFilePath, 'utf8');
|
||||
return JSON.parse(credentialsJSONContents);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`credentials.json must exist in the project root directory and contain a valid JSON`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getAbsolutePath(projectDir: string, filePath: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import log from '../../log';
|
||||
import { getProjectAccountName } from '../../project/projectUtils';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { gitStatusAsync } from '../../utils/git';
|
||||
import { Context } from '../context';
|
||||
import { CredentialsJson } from './read';
|
||||
|
||||
/**
|
||||
* Update Android credentials.json with values from www, content of credentials.json
|
||||
* is not validated
|
||||
*/
|
||||
export async function updateAndroidCredentialsAsync(ctx: Context): Promise<void> {
|
||||
const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json');
|
||||
let rawCredentialsJsonObject: any = {};
|
||||
if (await fs.pathExists(credentialsJsonFilePath)) {
|
||||
try {
|
||||
// reading raw file without validation (used only to access keystorePath)
|
||||
const rawFile = await fs.readFile(credentialsJsonFilePath, 'utf-8');
|
||||
rawCredentialsJsonObject = JSON.parse(rawFile);
|
||||
} catch (error) {
|
||||
log.error(`Reading credentials.json failed [${error}]`);
|
||||
log.error('Make sure that file is correct (or remove it) and rerun this command.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const accountName = await getProjectAccountName(ctx.exp, ctx.user);
|
||||
const experienceName = `@${accountName}/${ctx.exp.slug}`;
|
||||
const keystore = await ctx.android.fetchKeystoreAsync(experienceName);
|
||||
if (!keystore) {
|
||||
throw new Error('There are no credentials configured for this project on EAS servers');
|
||||
}
|
||||
|
||||
const isKeystoreComplete =
|
||||
keystore.keystore && keystore.keystorePassword && keystore.keyPassword && keystore.keyAlias;
|
||||
|
||||
if (!isKeystoreComplete) {
|
||||
const confirm = await confirmAsync({
|
||||
message:
|
||||
'Credentials on EAS servers might be invalid or incomplete. Are you sure you want to continue?',
|
||||
});
|
||||
if (!confirm) {
|
||||
log.warn('Aborting...');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const keystorePath =
|
||||
rawCredentialsJsonObject?.android?.keystore?.keystorePath ?? 'android/keystores/keystore.jks';
|
||||
log(`Writing Keystore to ${keystorePath}`);
|
||||
await updateFileAsync(ctx.projectDir, keystorePath, keystore.keystore);
|
||||
const shouldWarnKeystore = await isFileUntrackedAsync(keystorePath);
|
||||
|
||||
const androidCredentials: Partial<CredentialsJson['android']> = {
|
||||
keystore: {
|
||||
keystorePath,
|
||||
keystorePassword: keystore.keystorePassword,
|
||||
keyAlias: keystore.keyAlias,
|
||||
keyPassword: keystore.keyPassword,
|
||||
},
|
||||
};
|
||||
rawCredentialsJsonObject.android = androidCredentials;
|
||||
await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, {
|
||||
spaces: 2,
|
||||
});
|
||||
const shouldWarnCredentialsJson = await isFileUntrackedAsync('credentials.json');
|
||||
|
||||
const newFilePaths = [];
|
||||
if (shouldWarnKeystore) {
|
||||
newFilePaths.push(keystorePath);
|
||||
}
|
||||
if (shouldWarnCredentialsJson) {
|
||||
newFilePaths.push('credentials.json');
|
||||
}
|
||||
displayUntrackedFilesWarning(newFilePaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update iOS credentials in credentials.json with values from www, content
|
||||
* of credentials.json is not validated, if www has incomplete credentials
|
||||
* credentials.json will be updated partially
|
||||
*/
|
||||
export async function updateIosCredentialsAsync(
|
||||
ctx: Context,
|
||||
bundleIdentifier: string
|
||||
): Promise<void> {
|
||||
const credentialsJsonFilePath = path.join(ctx.projectDir, 'credentials.json');
|
||||
let rawCredentialsJsonObject: any = {};
|
||||
if (await fs.pathExists(credentialsJsonFilePath)) {
|
||||
try {
|
||||
// reading raw file without validation (used only to access paths)
|
||||
const rawFile = await fs.readFile(credentialsJsonFilePath, 'utf-8');
|
||||
rawCredentialsJsonObject = JSON.parse(rawFile);
|
||||
} catch (error) {
|
||||
log.error(`There was an error while reading credentials.json [${error}]`);
|
||||
log.error('Make sure that file is correct (or remove it) and rerun this command.');
|
||||
throw error;
|
||||
}
|
||||
|
||||
// TODO: implement updating credentials.json with multi-target credentials from EAS servers
|
||||
if (rawCredentialsJsonObject?.ios) {
|
||||
const keys = Object.keys(rawCredentialsJsonObject.ios);
|
||||
const maybeUnknownKey = keys.find(
|
||||
key => key !== 'provisioningProfilePath' && key !== 'distributionCertificate'
|
||||
);
|
||||
if (maybeUnknownKey) {
|
||||
log.error(
|
||||
`It looks like your credentials.json either contains multi-target credentials or you've made a typo.`
|
||||
);
|
||||
log.error(`- The key in 'ios' object that EAS CLI encountered is: ${maybeUnknownKey}`);
|
||||
log.error(
|
||||
`- Updating credentials.json (for multi-target iOS projects) with credentials stored on EAS servers is not supported at the moment, sorry!`
|
||||
);
|
||||
throw new Error('Updating credentials.json failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accountName = getProjectAccountName(ctx.exp, ctx.user);
|
||||
const appLookupParams = {
|
||||
accountName,
|
||||
projectName: ctx.exp.slug,
|
||||
bundleIdentifier,
|
||||
};
|
||||
const profilePath =
|
||||
rawCredentialsJsonObject?.ios?.provisioningProfilePath ?? 'ios/certs/profile.mobileprovision';
|
||||
const distCertPath =
|
||||
rawCredentialsJsonObject?.ios?.distributionCertificate?.path ?? 'ios/certs/dist-cert.p12';
|
||||
const appCredentials = await ctx.ios.getAppCredentialsAsync(appLookupParams);
|
||||
const distCredentials = await ctx.ios.getDistributionCertificateAsync(appLookupParams);
|
||||
if (!appCredentials?.credentials?.provisioningProfile && !distCredentials) {
|
||||
throw new Error('There are no credentials configured for this project on EAS servers');
|
||||
}
|
||||
|
||||
const areCredentialsComplete =
|
||||
appCredentials?.credentials?.provisioningProfile &&
|
||||
distCredentials?.certP12 &&
|
||||
distCredentials?.certPassword;
|
||||
|
||||
if (!areCredentialsComplete) {
|
||||
const confirm = await confirmAsync({
|
||||
message:
|
||||
'Credentials on EAS servers might be invalid or incomplete. Are you sure you want to continue?',
|
||||
});
|
||||
if (!confirm) {
|
||||
log.warn('Aborting...');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log(`Writing Provisioning Profile to ${profilePath}`);
|
||||
await updateFileAsync(
|
||||
ctx.projectDir,
|
||||
profilePath,
|
||||
appCredentials?.credentials?.provisioningProfile
|
||||
);
|
||||
const shouldWarnPProfile = await isFileUntrackedAsync(profilePath);
|
||||
|
||||
log(`Writing Distribution Certificate to ${distCertPath}`);
|
||||
await updateFileAsync(ctx.projectDir, distCertPath, distCredentials?.certP12);
|
||||
const shouldWarnDistCert = await isFileUntrackedAsync(distCertPath);
|
||||
|
||||
const iosCredentials: Partial<CredentialsJson['ios']> = {
|
||||
...(appCredentials?.credentials?.provisioningProfile
|
||||
? { provisioningProfilePath: profilePath }
|
||||
: {}),
|
||||
...(distCredentials?.certP12 && distCredentials?.certPassword
|
||||
? {
|
||||
distributionCertificate: {
|
||||
path: distCertPath,
|
||||
password: distCredentials?.certPassword,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
rawCredentialsJsonObject.ios = iosCredentials;
|
||||
await fs.writeJson(credentialsJsonFilePath, rawCredentialsJsonObject, {
|
||||
spaces: 2,
|
||||
});
|
||||
const shouldWarnCredentialsJson = await isFileUntrackedAsync('credentials.json');
|
||||
|
||||
const newFilePaths = [];
|
||||
if (shouldWarnPProfile) {
|
||||
newFilePaths.push(profilePath);
|
||||
}
|
||||
if (shouldWarnDistCert) {
|
||||
newFilePaths.push(distCertPath);
|
||||
}
|
||||
if (shouldWarnCredentialsJson) {
|
||||
newFilePaths.push('credentials.json');
|
||||
}
|
||||
displayUntrackedFilesWarning(newFilePaths);
|
||||
}
|
||||
|
||||
async function updateFileAsync(projectDir: string, filePath: string, base64Data?: string) {
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
|
||||
if (await fs.pathExists(absolutePath)) {
|
||||
await fs.remove(absolutePath);
|
||||
}
|
||||
if (base64Data) {
|
||||
await fs.mkdirp(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, Buffer.from(base64Data, 'base64'));
|
||||
}
|
||||
}
|
||||
|
||||
async function isFileUntrackedAsync(path: string): Promise<boolean> {
|
||||
const withUntrackedFiles = await gitStatusAsync({ showUntracked: true });
|
||||
const trackedFiles = await gitStatusAsync({ showUntracked: false });
|
||||
const pathWithoutLeadingDot = path.replace(/^\.\//, ''); // remove leading './' from path
|
||||
return (
|
||||
withUntrackedFiles.includes(pathWithoutLeadingDot) &&
|
||||
!trackedFiles.includes(pathWithoutLeadingDot)
|
||||
);
|
||||
}
|
||||
|
||||
function displayUntrackedFilesWarning(newFilePaths: string[]) {
|
||||
if (newFilePaths.length === 1) {
|
||||
log.warn(
|
||||
`File ${newFilePaths[0]} is currently untracked, remember to add it to .gitignore or to encrypt it. (e.g. with git-crypt)`
|
||||
);
|
||||
} else if (newFilePaths.length > 1) {
|
||||
log.warn(
|
||||
`Files ${newFilePaths.join(
|
||||
', '
|
||||
)} are currently untracked, remember to add them to .gitignore or to encrypt them. (e.g. with git-crypt)`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CredentialsSource, DistributionType } from '@expo/eas-json';
|
||||
|
||||
import { IosDistributionType } from '../../graphql/generated';
|
||||
import log from '../../log';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import { CredentialsManager } from '../CredentialsManager';
|
||||
import { CredentialsProvider } from '../CredentialsProvider';
|
||||
import { Context } from '../context';
|
||||
import * as credentialsJsonReader from '../credentialsJson/read';
|
||||
import type { IosCredentials } from '../credentialsJson/read';
|
||||
import { SetupBuildCredentials } from './actions/SetupBuildCredentials';
|
||||
|
||||
export { IosCredentials };
|
||||
|
||||
interface PartialIosCredentials {
|
||||
provisioningProfile?: string;
|
||||
distributionCertificate?: {
|
||||
certP12?: string;
|
||||
certPassword?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Options {
|
||||
app: AppLookupParams;
|
||||
distribution: DistributionType;
|
||||
skipCredentialsCheck?: boolean;
|
||||
}
|
||||
|
||||
interface AppLookupParams {
|
||||
projectName: string;
|
||||
accountName: string;
|
||||
bundleIdentifier: string;
|
||||
}
|
||||
|
||||
export default class IosCredentialsProvider implements CredentialsProvider {
|
||||
public readonly platform = Platform.iOS;
|
||||
|
||||
constructor(private ctx: Context, private options: Options) {}
|
||||
|
||||
public async hasRemoteAsync(): Promise<boolean> {
|
||||
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
|
||||
return !!(
|
||||
distributionCertificate?.certP12 ||
|
||||
distributionCertificate?.certPassword ||
|
||||
provisioningProfile
|
||||
);
|
||||
}
|
||||
|
||||
public async hasLocalAsync(): Promise<boolean> {
|
||||
if (this.options.distribution === DistributionType.INTERNAL) {
|
||||
// TODO: add support for using credentials.json for internal distribution
|
||||
return false;
|
||||
}
|
||||
if (!(await credentialsJsonReader.fileExistsAsync(this.ctx.projectDir))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const rawCredentialsJson = await credentialsJsonReader.readRawAsync(this.ctx.projectDir);
|
||||
return !!rawCredentialsJson?.ios;
|
||||
} catch (err) {
|
||||
log.error(err); // malformed json
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async isLocalSyncedAsync(): Promise<boolean> {
|
||||
try {
|
||||
const [remote, local] = await Promise.all([this.fetchRemoteAsync(), this.getLocalAsync()]);
|
||||
const r = remote;
|
||||
const l = local;
|
||||
|
||||
// TODO
|
||||
// For now, when credentials.json contains credentials for multi-target project
|
||||
// assume they are synced with the Expo servers.
|
||||
// Change this behavior when we figure out how to store multi-target project credentials in the db.
|
||||
if (credentialsJsonReader.isCredentialsMap(l)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(
|
||||
r &&
|
||||
l &&
|
||||
r.provisioningProfile === l.provisioningProfile &&
|
||||
r.distributionCertificate?.certP12 === l.distributionCertificate.certP12 &&
|
||||
r.distributionCertificate?.certPassword === l.distributionCertificate.certPassword
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getCredentialsAsync(
|
||||
src: CredentialsSource.LOCAL | CredentialsSource.REMOTE
|
||||
): Promise<IosCredentials> {
|
||||
switch (src) {
|
||||
case CredentialsSource.LOCAL:
|
||||
return await this.getLocalAsync();
|
||||
case CredentialsSource.REMOTE:
|
||||
return await this.getRemoteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async getLocalAsync(): Promise<IosCredentials> {
|
||||
if (this.options.distribution === DistributionType.INTERNAL) {
|
||||
// TODO: add support for using credentials.json for internal distribution
|
||||
throw new Error('Using credentials.json for internal distribution is not supported yet.');
|
||||
}
|
||||
return await credentialsJsonReader.readIosCredentialsAsync(this.ctx.projectDir);
|
||||
}
|
||||
|
||||
private async getRemoteAsync(): Promise<IosCredentials> {
|
||||
if (this.options.skipCredentialsCheck) {
|
||||
log('Skipping credentials check');
|
||||
} else {
|
||||
await new CredentialsManager(this.ctx).runActionAsync(
|
||||
new SetupBuildCredentials(this.options.app, this.options.distribution)
|
||||
);
|
||||
}
|
||||
|
||||
const { distributionCertificate, provisioningProfile } = await this.fetchRemoteAsync();
|
||||
if (!distributionCertificate?.certP12 || !distributionCertificate?.certPassword) {
|
||||
if (this.options.skipCredentialsCheck) {
|
||||
throw new Error(
|
||||
'Distribution certificate is missing and credentials check was skipped. Run without --skip-credentials-check to set it up.'
|
||||
);
|
||||
} else {
|
||||
throw new Error('Distribution certificate is missing');
|
||||
}
|
||||
}
|
||||
if (!provisioningProfile) {
|
||||
if (this.options.skipCredentialsCheck) {
|
||||
throw new Error(
|
||||
'Provisioning profile is missing and credentials check was skipped. Run without --skip-credentials-check to set it up.'
|
||||
);
|
||||
} else {
|
||||
throw new Error('Provisioning profile is missing');
|
||||
}
|
||||
}
|
||||
return {
|
||||
provisioningProfile,
|
||||
distributionCertificate: {
|
||||
certP12: distributionCertificate.certP12,
|
||||
certPassword: distributionCertificate.certPassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchRemoteAsync(): Promise<PartialIosCredentials> {
|
||||
if (this.options.distribution === DistributionType.INTERNAL) {
|
||||
const { app } = this.options;
|
||||
const account = findAccountByName(this.ctx.user.accounts, app.accountName);
|
||||
if (!account) {
|
||||
throw new Error(`You do not have access to the ${app.accountName} account`);
|
||||
}
|
||||
|
||||
const appLookupParams = {
|
||||
account,
|
||||
bundleIdentifier: this.options.app.bundleIdentifier,
|
||||
projectName: this.options.app.projectName,
|
||||
};
|
||||
|
||||
// for now, let's require the user to authenticate with Apple
|
||||
const { team } = await this.ctx.appStore.ensureAuthenticatedAsync();
|
||||
const appleTeam = await this.ctx.newIos.createOrGetExistingAppleTeamAsync(appLookupParams, {
|
||||
appleTeamIdentifier: team.id,
|
||||
appleTeamName: team.name,
|
||||
});
|
||||
const [distCert, provisioningProfile] = await Promise.all([
|
||||
this.ctx.newIos.getDistributionCertificateForAppAsync(
|
||||
appLookupParams,
|
||||
appleTeam,
|
||||
IosDistributionType.AdHoc
|
||||
),
|
||||
this.ctx.newIos.getProvisioningProfileAsync(
|
||||
appLookupParams,
|
||||
appleTeam,
|
||||
IosDistributionType.AdHoc
|
||||
),
|
||||
]);
|
||||
return {
|
||||
provisioningProfile: provisioningProfile?.provisioningProfile ?? undefined,
|
||||
distributionCertificate: {
|
||||
certP12: distCert?.certificateP12 ?? undefined,
|
||||
certPassword: distCert?.certificatePassword ?? undefined,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const [distCert, provisioningProfile] = await Promise.all([
|
||||
this.ctx.ios.getDistributionCertificateAsync(this.options.app),
|
||||
this.ctx.ios.getProvisioningProfileAsync(this.options.app),
|
||||
]);
|
||||
return {
|
||||
provisioningProfile: provisioningProfile?.provisioningProfile,
|
||||
distributionCertificate: {
|
||||
certP12: distCert?.certP12,
|
||||
certPassword: distCert?.certPassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -31,7 +32,7 @@ export class ConfigureProvisioningProfile implements Action {
|
||||
public async runAsync(manager: CredentialsManager, ctx: Context): Promise<void> {
|
||||
const profile = await ctx.ios.getProvisioningProfileAsync(this.app);
|
||||
if (!profile) {
|
||||
throw new Error('No Provisioning Profile to configure.');
|
||||
throw new Error('No provisioning profile to configure');
|
||||
}
|
||||
|
||||
if (!profile.provisioningProfileId) {
|
||||
@@ -42,7 +43,7 @@ export class ConfigureProvisioningProfile implements Action {
|
||||
if (ctx.appStore.authCtx) {
|
||||
const distCert = await ctx.ios.getDistributionCertificateAsync(this.app);
|
||||
if (!distCert) {
|
||||
log.warn('There is no Distribution Certificate assigned to this app.');
|
||||
log.warn('There is no distribution certificate for this app.');
|
||||
return;
|
||||
}
|
||||
const profilesFromApple = await ctx.appStore.listProvisioningProfilesAsync(
|
||||
@@ -58,9 +59,9 @@ export class ConfigureProvisioningProfile implements Action {
|
||||
await this.configureAndUpdateAsync(ctx, this.app, distCert, profilesWithMatchingId[0]);
|
||||
} else {
|
||||
log.warn(
|
||||
"Without access to your Apple account we can't configure Provisioning Profile for you."
|
||||
"Without access to your Apple account we can't configure provisioning profiles for you."
|
||||
);
|
||||
log.warn('Make sure to recreate it if you switched to new Distribution Certificate.');
|
||||
log.warn('Make sure to recreate the profile if you selected a new distribution certificate.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,20 +77,19 @@ export class ConfigureProvisioningProfile implements Action {
|
||||
profileFromApple,
|
||||
distCert
|
||||
);
|
||||
log(
|
||||
chalk.green(
|
||||
`Successfully configured Provisioning Profile ${
|
||||
profileFromApple.provisioningProfileId
|
||||
} on Apple Servers with Distribution Certificate ${distCert.certId || ''}`
|
||||
)
|
||||
);
|
||||
|
||||
// Update profile on expo servers
|
||||
await ctx.ios.updateProvisioningProfileAsync(app, updatedProfile);
|
||||
log(
|
||||
chalk.green(
|
||||
`Successfully updated Provisioning Profile for @${app.accountName}/${app.projectName} (${app.bundleIdentifier})`
|
||||
)
|
||||
);
|
||||
const bundleIdTag = `(${app.bundleIdentifier})`;
|
||||
const slugTag = `@${app.accountName}/${app.projectName}`;
|
||||
const projectTag = `${chalk.bold(slugTag)} ${chalk.dim(bundleIdTag)}`;
|
||||
|
||||
const spinner = ora(`Updating Expo profile for ${projectTag}`).start();
|
||||
try {
|
||||
// Update profile on EAS servers
|
||||
await ctx.ios.updateProvisioningProfileAsync(app, updatedProfile);
|
||||
spinner.succeed(`Updated Expo profile for ${projectTag}`);
|
||||
} catch (error) {
|
||||
spinner.fail();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import log from '../../../log';
|
||||
import { Action, CredentialsManager } from '../../CredentialsManager';
|
||||
@@ -41,6 +40,6 @@ export class CreateDistributionCertificate implements Action {
|
||||
this.accountName,
|
||||
distCert
|
||||
);
|
||||
log(chalk.green('Successfully created Distribution Certificate.'));
|
||||
log.succeed('Created distribution certificate');
|
||||
}
|
||||
}
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user