mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
37
Commits
v0.29.1
...
@dsokal/esm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b36ae0ef8 | ||
|
|
1a4cb107af | ||
|
|
7576d9195d | ||
|
|
838c1508be | ||
|
|
7941683435 | ||
|
|
91f2311a50 | ||
|
|
45d10d9b83 | ||
|
|
f5d5093922 | ||
|
|
fa4687b0f8 | ||
|
|
e78c475118 | ||
|
|
def91af01f | ||
|
|
5eb0fb75ac | ||
|
|
de8c69f766 | ||
|
|
5879ac424d | ||
|
|
e30f62ec12 | ||
|
|
dc4e36e8a8 | ||
|
|
12ded8c9a9 | ||
|
|
7378c6fd0e | ||
|
|
77a8b08834 | ||
|
|
79db9eb482 | ||
|
|
59f6d36114 | ||
|
|
c36c26f5a9 | ||
|
|
d6692f20b5 | ||
|
|
6e37f596df | ||
|
|
d326891ef8 | ||
|
|
46a34327d9 | ||
|
|
97242af30f | ||
|
|
ca0a8f79cd | ||
|
|
67eac59585 | ||
|
|
8cf1f0dc22 | ||
|
|
51702e4bda | ||
|
|
426a3d1ef3 | ||
|
|
e8d71a52ec | ||
|
|
ccd0cb19a4 | ||
|
|
9f85178d71 | ||
|
|
085ee52305 | ||
|
|
22a71275b2 |
No files matched your search
@@ -34,6 +34,7 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
# Checklist
|
||||
|
||||
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary.
|
||||
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary. You can comment this pull request with `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]` and CHANGELOG.md will be updated automatically.
|
||||
- [ ] I've tagged the changelog entry with `[EAS BUILD API]` if it's a part of a breaking change to EAS Build API (only possible when updating `@expo/eas-build-job` package).
|
||||
|
||||
# Why
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
name: Changelog
|
||||
|
||||
jobs:
|
||||
pr-commented:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Checkout Pull Request
|
||||
run: hub pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Add changelog entry
|
||||
working-directory: ./scripts
|
||||
run: |
|
||||
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
|
||||
if [[ ! "$PARAMS" =~ " " ]]; then
|
||||
echo "Invalid /changelog-entry command"
|
||||
exit 1
|
||||
fi
|
||||
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | xargs )
|
||||
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | xargs )
|
||||
yarn changelog-entry "$CATEGORY" "$MESSAGE"
|
||||
env:
|
||||
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# use the last commit author name and email
|
||||
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
|
||||
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
|
||||
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git push
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- 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
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
draft: true
|
||||
@@ -113,3 +113,70 @@ jobs:
|
||||
asset_path: packages/eas-cli/dist/win/${{ steps.x86.outputs.filename }}
|
||||
asset_name: ${{ steps.x86.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
publish-to-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-mac, build-windows]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
node-version: '16'
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Build
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-to-npm
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
|
||||
- name: Publish release
|
||||
run: hub release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
|
||||
npx slackmd /tmp/changelog.md
|
||||
echo 'SLACK_CHANGELOG<<EOF' >> $GITHUB_ENV
|
||||
cat /tmp/changelog.slack.md >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Slack Notification
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
SLACK_ICON: https://github.com/expo-ci.png?size=48
|
||||
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
|
||||
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
|
||||
SLACK_USERNAME: 'Expo CI'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ''
|
||||
@@ -8,8 +8,59 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix printing App Store Connect URL after submission. ([#683](https://github.com/expo/eas-cli/pull/683) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Enable no-underscore-dangle eslint rule. ([#686](https://github.com/expo/eas-cli/pull/686) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.31.1](https://github.com/expo/eas-cli/releases/tag/0.31.1) - 2021-10-08
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Google Service Account Keys: Fix non-interactive bug in credentials service workflow ([#682](https://github.com/expo/eas-cli/pull/682) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.31.0](https://github.com/expo/eas-cli/releases/tag/0.31.0) - 2021-10-08
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Don't resolve the iOS builder image on the client side. EAS Build will use the appropriate iOS image for a given Expo SDK version unless the `image` is defined. This only applies to managed projects. ([#675](https://github.com/expo/eas-cli/pull/675) by [@wkozyra95](https://github.com/wkozyra95) + [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Integrate credentials service with Android submissions. ([#664](https://github.com/expo/eas-cli/pull/664) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add option to review ad-hoc devices when reusing provisioning profile. ([#673](https://github.com/expo/eas-cli/pull/673) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.30.1](https://github.com/expo/eas-cli/releases/tag/0.30.1) - 2021-10-06
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `--json` flag when running EAS CLI on GitHub actions. ([#669](https://github.com/expo/eas-cli/pull/669) by [@dsokal](https://github.com/dsokal))
|
||||
- Fix `"ios: mods.ios.infoPlist: Failed to find Info.plist linked to Xcode project."` warning when running `eas build` in a managed project.([#670](https://github.com/expo/eas-cli/pull/670) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Fix building monorepo projects on Windows. ([#671](https://github.com/expo/eas-cli/pull/671) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.30.0](https://github.com/expo/eas-cli/releases/tag/v0.30.0) - 2021-10-05
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Enable auto-incrementing on Android. ([#645](https://github.com/expo/eas-cli/pull/645) by [@jkhales](https://github.com/jkhales))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Ports detection for Google Service Account Keys into the credentials service. ([#660](https://github.com/expo/eas-cli/pull/660) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Improve iOS credentials printing. ([#657](https://github.com/expo/eas-cli/pull/657) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Automate `eas-cli` releases. ([#668](https://github.com/expo/eas-cli/pull/668) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [0.29.1](https://github.com/expo/eas-cli/releases/tag/v0.29.1) - 2021-09-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- More upload support for Google Service Account Keys. ([#649](https://github.com/expo/eas-cli/pull/649) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Allow the user to assign an existing Google Service Account Key to their project. ([#650](https://github.com/expo/eas-cli/pull/650) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Allow the user to remove a Google Service Account Key from their account. ([#658](https://github.com/expo/eas-cli/pull/658) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Adds setup support for Google Service Account Keys. ([#659](https://github.com/expo/eas-cli/pull/659) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -18,6 +69,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🧹 Chores
|
||||
|
||||
- Always use async `fs` functions. ([#652](https://github.com/expo/eas-cli/pull/652) by [@dsokal](https://github.com/dsokal))
|
||||
- Improve Android credentials printing. ([#656](https://github.com/expo/eas-cli/pull/656) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [0.29.0](https://github.com/expo/eas-cli/releases/tag/v0.29.0) - 2021-09-28
|
||||
|
||||
|
||||
+3
-32
@@ -1,37 +1,8 @@
|
||||
## Releasing a new version
|
||||
|
||||
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. Run `yarn lerna publish from-git` in the repository root folder. This will publish the packages to npm.
|
||||
|
||||
- This command works only if `HEAD` is on the release commit, e.g. when `git log -1` prints:
|
||||
|
||||
```
|
||||
commit cbb2de9e118bfbcb5a597b261d2dbef0bb5d4b0b (tag: v0.11.1)
|
||||
Author: Brent Vatne <[email protected]>
|
||||
Date: Tue Apr 20 15:00:15 2021 -0700
|
||||
|
||||
v0.11.1
|
||||
```
|
||||
|
||||
- If you run `yarn lerna publish from-git` on any other commit, it'll print something like:
|
||||
|
||||
```
|
||||
lerna notice from-git No tagged release found
|
||||
lerna success No changed packages to publish
|
||||
```
|
||||
|
||||
- If you ever run into the above issue, use `git checkout RELEASE_COMMIT_HASH` to checkout to the release commit. Later, when you've published the packages to npm, run `git checkout main` to go back to the latest commit.
|
||||
|
||||
1. Update `CHANGELOG.md`.
|
||||
- Add the new section with the release version in the title.
|
||||
- Leave the `main` section empty (without any changelog entries).
|
||||
1. Once the release is ready to go, you can go to https://github.com/expo/eas-cli/releases and mark it as not draft.
|
||||
- Remember to copy the changelog entries from `CHANGELOG.md`.
|
||||
- Before hitting the submit button, make sure there are 5 assets available under the release entry.
|
||||

|
||||
1. If any of the changelog entries have been tagged with `[EAS BUILD API]`
|
||||
1. Run `yarn release` in the repository root folder.
|
||||
2. Wait, the `release` workflow on GitHub Actions is going to take care of the rest (including publishing packages to npm and updating the changelog).
|
||||
3. Check the changelog. If any of the entries have been tagged with `[EAS BUILD API]`
|
||||
- Open `package.json` and note the version of `@expo/eas-build-job` used in the eas-cli.
|
||||
- Find the `Publish` commit in [expo/eas-build](https://github.com/expo/eas-build/commit/) for that version. In most cases, it will be the latest `Publish` commit.
|
||||
- Find the `eas-cli-local-build-plugin` version published in that commit.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.29.1"
|
||||
"version": "0.31.1"
|
||||
}
|
||||
+5
-6
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"build:tarballs:linux": "yarn build && yarn workspace eas-cli oclif-dev pack --targets linux-x64",
|
||||
@@ -12,7 +10,8 @@
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "eslint . --ext .ts --ext .js",
|
||||
"release": "lerna version",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"clean": "lerna run clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/oclif-dev-cli": "1.24.0-expo",
|
||||
@@ -30,7 +29,7 @@
|
||||
"typescript": "4.4.2"
|
||||
},
|
||||
"volta": {
|
||||
"node": "16.8.0",
|
||||
"yarn": "1.22.11"
|
||||
"node": "16.10.0",
|
||||
"yarn": "1.22.15"
|
||||
}
|
||||
}
|
||||
+26
-26
@@ -73,7 +73,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/login.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/account/login.js)_
|
||||
_See code: [src/commands/account/login.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/account/login.js)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -87,7 +87,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/logout.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/account/logout.js)_
|
||||
_See code: [src/commands/account/logout.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/account/logout.js)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -101,7 +101,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [src/commands/account/view.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/account/view.js)_
|
||||
_See code: [src/commands/account/view.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/account/view.js)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -112,7 +112,7 @@ USAGE
|
||||
$ eas analytics [STATUS]
|
||||
```
|
||||
|
||||
_See code: [src/commands/analytics.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/analytics.js)_
|
||||
_See code: [src/commands/analytics.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/analytics.js)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -145,7 +145,7 @@ OPTIONS
|
||||
--[no-]wait Wait for build(s) to complete
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/index.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/build/index.js)_
|
||||
_See code: [src/commands/build/index.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/build/index.js)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -156,7 +156,7 @@ USAGE
|
||||
$ eas build:cancel [BUILD_ID]
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/cancel.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/build/cancel.js)_
|
||||
_See code: [src/commands/build/cancel.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/build/cancel.js)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -170,7 +170,7 @@ OPTIONS
|
||||
-p, --platform=(android|ios|all) Platform to configure
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/configure.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/build/configure.js)_
|
||||
_See code: [src/commands/build/configure.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/build/configure.js)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -203,7 +203,7 @@ OPTIONS
|
||||
--status=(new|in-queue|in-progress|errored|finished|canceled)
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/list.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/build/list.js)_
|
||||
_See code: [src/commands/build/list.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/build/list.js)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -217,7 +217,7 @@ OPTIONS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr
|
||||
```
|
||||
|
||||
_See code: [src/commands/build/view.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/build/view.js)_
|
||||
_See code: [src/commands/build/view.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/build/view.js)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -232,7 +232,7 @@ OPTIONS
|
||||
--profile=profile
|
||||
```
|
||||
|
||||
_See code: [src/commands/config.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/config.js)_
|
||||
_See code: [src/commands/config.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/config.js)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -243,7 +243,7 @@ USAGE
|
||||
$ eas credentials
|
||||
```
|
||||
|
||||
_See code: [src/commands/credentials.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/credentials.js)_
|
||||
_See code: [src/commands/credentials.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/credentials.js)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -254,7 +254,7 @@ USAGE
|
||||
$ eas device:create
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/create.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/device/create.js)_
|
||||
_See code: [src/commands/device/create.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/device/create.js)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -268,7 +268,7 @@ OPTIONS
|
||||
--apple-team-id=apple-team-id
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/list.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/device/list.js)_
|
||||
_See code: [src/commands/device/list.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/device/list.js)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -279,7 +279,7 @@ USAGE
|
||||
$ eas device:view [UDID]
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/view.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/device/view.js)_
|
||||
_See code: [src/commands/device/view.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/device/view.js)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -290,7 +290,7 @@ USAGE
|
||||
$ eas diagnostics
|
||||
```
|
||||
|
||||
_See code: [src/commands/diagnostics.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/diagnostics.js)_
|
||||
_See code: [src/commands/diagnostics.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/diagnostics.js)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -318,7 +318,7 @@ USAGE
|
||||
$ eas project:info
|
||||
```
|
||||
|
||||
_See code: [src/commands/project/info.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/project/info.js)_
|
||||
_See code: [src/commands/project/info.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/project/info.js)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -332,7 +332,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [src/commands/project/init.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/project/init.js)_
|
||||
_See code: [src/commands/project/init.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/project/init.js)_
|
||||
|
||||
## `eas secret:create`
|
||||
|
||||
@@ -349,7 +349,7 @@ OPTIONS
|
||||
--value=value Value of the secret
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/create.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/secret/create.js)_
|
||||
_See code: [src/commands/secret/create.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/secret/create.js)_
|
||||
|
||||
## `eas secret:delete`
|
||||
|
||||
@@ -366,7 +366,7 @@ DESCRIPTION
|
||||
Unsure where to find the secret's ID? Run eas secrets:list
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/delete.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/secret/delete.js)_
|
||||
_See code: [src/commands/secret/delete.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/secret/delete.js)_
|
||||
|
||||
## `eas secret:list`
|
||||
|
||||
@@ -377,7 +377,7 @@ USAGE
|
||||
$ eas secret:list
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/list.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/secret/list.js)_
|
||||
_See code: [src/commands/secret/list.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/secret/list.js)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -410,7 +410,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [src/commands/submit.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/submit.js)_
|
||||
_See code: [src/commands/submit.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/submit.js)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -429,7 +429,7 @@ OPTIONS
|
||||
--url=url Webhook URL
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/create.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/webhook/create.js)_
|
||||
_See code: [src/commands/webhook/create.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/webhook/create.js)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -443,7 +443,7 @@ ARGUMENTS
|
||||
ID ID of the webhook to delete
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/delete.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/webhook/delete.js)_
|
||||
_See code: [src/commands/webhook/delete.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/webhook/delete.js)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -457,7 +457,7 @@ OPTIONS
|
||||
--event=(BUILD) Event type that triggers the webhook
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/list.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/webhook/list.js)_
|
||||
_See code: [src/commands/webhook/list.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/webhook/list.js)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -477,7 +477,7 @@ OPTIONS
|
||||
--url=url Webhook URL
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/update.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/webhook/update.js)_
|
||||
_See code: [src/commands/webhook/update.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/webhook/update.js)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -491,5 +491,5 @@ ARGUMENTS
|
||||
ID ID of the webhook to view
|
||||
```
|
||||
|
||||
_See code: [src/commands/webhook/view.js](https://github.com/expo/eas-cli/blob/v0.29.1/packages/eas-cli/src/commands/webhook/view.js)_
|
||||
_See code: [src/commands/webhook/view.js](https://github.com/expo/eas-cli/blob/v0.31.1/packages/eas-cli/src/commands/webhook/view.js)_
|
||||
<!-- commandsstop -->
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"type": "module",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.29.1",
|
||||
"version": "0.31.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
@@ -9,15 +10,15 @@
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "0.0.0-alpha.25",
|
||||
"@expo/config": "5.0.9",
|
||||
"@expo/config-plugins": "3.1.0",
|
||||
"@expo/eas-build-job": "0.2.48",
|
||||
"@expo/eas-json": "^0.29.0",
|
||||
"@expo/config": "6.0.2",
|
||||
"@expo/config-plugins": "4.0.2",
|
||||
"@expo/eas-build-job": "0.2.52",
|
||||
"@expo/eas-json": "^0.31.0",
|
||||
"@expo/json-file": "8.2.33",
|
||||
"@expo/pkcs12": "0.0.4",
|
||||
"@expo/plist": "0.0.14",
|
||||
"@expo/plugin-warn-if-update-available": "1.7.0",
|
||||
"@expo/prebuild-config": "2.1.0",
|
||||
"@expo/prebuild-config": "3.0.2",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.0",
|
||||
"@expo/sdk-runtime-versions": "1.0.0",
|
||||
@@ -61,6 +62,7 @@
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.3.5",
|
||||
"slash": "3.0.0",
|
||||
"strip-ansi": "6.0.0",
|
||||
"tar": "6.1.11",
|
||||
"terminal-link": "2.1.1",
|
||||
@@ -112,7 +114,6 @@
|
||||
"cli"
|
||||
],
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"oclif": {
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
@@ -176,6 +177,7 @@
|
||||
"typecheck": "tsc",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif-dev readme && node patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"clean": "rm -rf build node_modules"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { maybeResolveVersionsAsync } from '../version';
|
||||
import { getAppBuildGradleAsync, resolveConfigValue } from '../../../project/android/gradleUtils';
|
||||
import {
|
||||
BumpStrategy,
|
||||
bumpVersionAsync,
|
||||
bumpVersionInAppJsonAsync,
|
||||
maybeResolveVersionsAsync,
|
||||
} from '../version';
|
||||
|
||||
const fsReal = jest.requireActual('fs').promises as typeof fs;
|
||||
jest.mock('fs');
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -22,21 +31,174 @@ beforeEach(async () => {
|
||||
await fs.mkdirp(os.tmpdir());
|
||||
});
|
||||
|
||||
// generic workflow
|
||||
describe(bumpVersionAsync, () => {
|
||||
it('throws an informative error when multiple flavors are detected in the android project', async () => {
|
||||
await initProjectWithGradleFileAsync(
|
||||
/*buildGradlePath*/ path.join(
|
||||
__dirname,
|
||||
'../../../project/android/__tests__/fixtures/multiflavor-build.gradle'
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.VERSION_CODE,
|
||||
projectDir: '/multiflavor',
|
||||
exp: {} as any,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Automatic version bumping is not supported for multi-flavor Android projects.'
|
||||
);
|
||||
});
|
||||
it('throws an informative error when multiple flavor dimensions are defined in an android project', async () => {
|
||||
await initProjectWithGradleFileAsync(
|
||||
/*buildGradlePath*/ path.join(
|
||||
__dirname,
|
||||
'../../../project/android/__tests__/fixtures/multiflavor-with-dimensions-build.gradle'
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.VERSION_CODE,
|
||||
projectDir: '/multiflavor',
|
||||
exp: {} as any,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Automatic version bumping is not supported for multi-flavor Android projects.'
|
||||
);
|
||||
});
|
||||
it('bumps expo.android.versionCode and buildGradle versionCode when strategy = BumpStrategy.VERSION_CODE', async () => {
|
||||
const nativeVersionCode = 100; // this should be overwritten by bumpVersionAsync
|
||||
const fakeExp = initGenericProject({ versionCode: nativeVersionCode });
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.VERSION_CODE,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('3.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(124);
|
||||
expect(appJSON.expo.version).toBe('3.0.0');
|
||||
expect(appJSON.expo.android.versionCode).toBe(124);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync('/repo');
|
||||
assert(buildGradle);
|
||||
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('124');
|
||||
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
|
||||
});
|
||||
|
||||
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.SHORT_VERSION', async () => {
|
||||
const nativeVersionName = '1.0.0'; // this should be overwritten by bumpVersionAsync
|
||||
const fakeExp = initGenericProject({ versionName: nativeVersionName });
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('3.0.1');
|
||||
expect(fakeExp.android?.versionCode).toBe(123);
|
||||
expect(appJSON.expo.version).toBe('3.0.1');
|
||||
expect(appJSON.expo.android.versionCode).toBe(123);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync('/repo');
|
||||
assert(buildGradle);
|
||||
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
|
||||
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.1');
|
||||
});
|
||||
|
||||
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
|
||||
const fakeExp = initGenericProject();
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('3.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(123);
|
||||
expect(appJSON.expo.version).toBe('3.0.0');
|
||||
expect(appJSON.expo.android.versionCode).toBe(123);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync('/repo');
|
||||
assert(buildGradle);
|
||||
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
|
||||
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
// managed workflow
|
||||
describe(bumpVersionInAppJsonAsync, () => {
|
||||
it('bumps expo.android.versionCode when strategy = BumpStrategy.VERSION_CODE', async () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.VERSION_CODE,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('5.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(127);
|
||||
expect(appJSON.expo.version).toBe('5.0.0');
|
||||
expect(appJSON.expo.android.versionCode).toBe(127);
|
||||
});
|
||||
|
||||
it('bumps expo.version when strategy = BumpStrategy.SHORT_VERSION', async () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('5.0.1');
|
||||
expect(fakeExp.android?.versionCode).toBe(126);
|
||||
expect(appJSON.expo.version).toBe('5.0.1');
|
||||
expect(appJSON.expo.android.versionCode).toBe(126);
|
||||
});
|
||||
|
||||
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
expect(fakeExp.version).toBe('5.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(126);
|
||||
expect(appJSON.expo.version).toBe('5.0.0');
|
||||
expect(appJSON.expo.android.versionCode).toBe(126);
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeResolveVersionsAsync, () => {
|
||||
describe('generic project', () => {
|
||||
it('reads the version code from native code', async () => {
|
||||
it('reads the versions from native code', async () => {
|
||||
const exp = initGenericProject();
|
||||
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
|
||||
'/repo',
|
||||
exp,
|
||||
{} as BuildProfile<Platform.ANDROID>
|
||||
);
|
||||
expect(appVersion).toBe('2.0');
|
||||
expect(appVersion).toBe('3.0.0');
|
||||
expect(appBuildVersion).toBe('123');
|
||||
});
|
||||
});
|
||||
describe('managed project', () => {
|
||||
it('reads the version code from expo config', async () => {
|
||||
it('reads the versions from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
|
||||
'/repo',
|
||||
@@ -49,22 +211,31 @@ describe(maybeResolveVersionsAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
function initGenericProject(): ExpoConfig {
|
||||
function initGenericProject({
|
||||
versionCode = 123,
|
||||
versionName = '3.0.0',
|
||||
}: {
|
||||
versionCode?: number;
|
||||
versionName?: string;
|
||||
} = {}): ExpoConfig {
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '3.0.0',
|
||||
android: {
|
||||
versionCode: 123,
|
||||
},
|
||||
};
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
android: {
|
||||
versionCode: 1,
|
||||
},
|
||||
},
|
||||
expo: fakeExp,
|
||||
}),
|
||||
'./android/app/build.gradle': `android {
|
||||
defaultConfig {
|
||||
applicationId "com.expo.testapp"
|
||||
versionCode 123
|
||||
versionName "2.0"
|
||||
versionCode ${versionCode}
|
||||
versionName "${versionName}"
|
||||
}
|
||||
}`,
|
||||
'./android/app/src/main/AndroidManifest.xml': 'fake',
|
||||
@@ -72,32 +243,10 @@ function initGenericProject(): ExpoConfig {
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '3.0.0',
|
||||
android: {
|
||||
versionCode: 124,
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
|
||||
function initManagedProject(): ExpoConfig {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '4.0.0',
|
||||
android: {
|
||||
versionCode: 125,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
@@ -106,5 +255,26 @@ function initManagedProject(): ExpoConfig {
|
||||
versionCode: 126,
|
||||
},
|
||||
};
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: fakeExp,
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
}
|
||||
|
||||
async function initProjectWithGradleFileAsync(gradleFilePath: string): Promise<void> {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'android/app/build.gradle': await fsReal.readFile(gradleFilePath, 'utf-8'),
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {} as any,
|
||||
}),
|
||||
},
|
||||
'/multiflavor'
|
||||
);
|
||||
}
|
||||
@@ -64,9 +64,11 @@ This means that it will most likely produce an AAB and you will not be able to i
|
||||
return await ensureAndroidCredentialsAsync(ctx, gradleContext);
|
||||
},
|
||||
ensureProjectConfiguredAsync: async () => {
|
||||
if (ctx.workflow === Workflow.GENERIC) {
|
||||
await validateAndSyncProjectConfigurationAsync(ctx.projectDir, ctx.exp);
|
||||
}
|
||||
await validateAndSyncProjectConfigurationAsync({
|
||||
projectDir: ctx.projectDir,
|
||||
exp: ctx.exp,
|
||||
buildProfile,
|
||||
});
|
||||
},
|
||||
getMetadataContext: () => ({
|
||||
gradleContext,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AndroidBuildProfile, AndroidVersionAutoIncrement } from '@expo/eas-json';
|
||||
|
||||
import Log from '../../log';
|
||||
import {
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync,
|
||||
warnIfAndroidPackageDefinedInAppConfigForGenericProject,
|
||||
} from '../../project/android/applicationId';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import vcs from '../../vcs';
|
||||
import { ConfigureContext } from '../context';
|
||||
import { isExpoUpdatesInstalled } from '../utils/updates';
|
||||
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
|
||||
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
|
||||
|
||||
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
|
||||
if (!ctx.hasAndroidNativeProject) {
|
||||
@@ -30,16 +34,42 @@ export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void
|
||||
Log.withTick('Android project configured');
|
||||
}
|
||||
|
||||
export async function validateAndSyncProjectConfigurationAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig
|
||||
): Promise<void> {
|
||||
if (!(await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(projectDir))) {
|
||||
throw new Error(
|
||||
'Project is not configured. Please run "eas build:configure" to configure the project.'
|
||||
);
|
||||
export async function validateAndSyncProjectConfigurationAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
buildProfile,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
buildProfile: AndroidBuildProfile;
|
||||
}): Promise<void> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
|
||||
const { autoIncrement } = buildProfile;
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
|
||||
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
if (!(await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(projectDir))) {
|
||||
throw new Error(
|
||||
'Project is not configured. Please run "eas build:configure" to configure the project.'
|
||||
);
|
||||
}
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
|
||||
} else {
|
||||
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
|
||||
}
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
}
|
||||
|
||||
function resolveVersionBumpStrategy(autoIncrement: AndroidVersionAutoIncrement): BumpStrategy {
|
||||
if (autoIncrement === true) {
|
||||
return BumpStrategy.VERSION_CODE;
|
||||
} else if (autoIncrement === false) {
|
||||
return BumpStrategy.NOOP;
|
||||
} else if (autoIncrement === 'versionCode') {
|
||||
return BumpStrategy.VERSION_CODE;
|
||||
} else {
|
||||
return BumpStrategy.APP_VERSION;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Android, ArchiveSource, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
|
||||
import { AndroidBuildProfile } from '@expo/eas-json';
|
||||
import path from 'path';
|
||||
import slash from 'slash';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
@@ -25,7 +26,8 @@ export async function prepareJobAsync(
|
||||
): Promise<Job> {
|
||||
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
|
||||
const buildProfile: AndroidBuildProfile = ctx.buildProfile;
|
||||
const projectRootDirectory = path.relative(await vcs.getRootPathAsync(), ctx.projectDir) || '.';
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await vcs.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
const { credentials } = jobData;
|
||||
const buildCredentials = credentials
|
||||
? {
|
||||
@@ -51,7 +53,7 @@ export async function prepareJobAsync(
|
||||
projectRootDirectory,
|
||||
projectArchive: jobData.projectArchive,
|
||||
builderEnvironment: {
|
||||
image: buildProfile.image ?? 'default',
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
yarn: buildProfile.yarn,
|
||||
ndk: buildProfile.ndk,
|
||||
|
||||
@@ -2,13 +2,89 @@ import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import Log from '../../log';
|
||||
import {
|
||||
getAppBuildGradleAsync,
|
||||
parseGradleCommand,
|
||||
resolveConfigValue,
|
||||
} from '../../project/android/gradleUtils';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { updateAppJsonConfigAsync } from '../utils/appJson';
|
||||
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
|
||||
|
||||
export enum BumpStrategy {
|
||||
APP_VERSION,
|
||||
VERSION_CODE,
|
||||
NOOP,
|
||||
}
|
||||
|
||||
export async function bumpVersionAsync({
|
||||
bumpStrategy,
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
bumpStrategy: BumpStrategy;
|
||||
}): Promise<void> {
|
||||
if (bumpStrategy === BumpStrategy.NOOP) {
|
||||
return;
|
||||
}
|
||||
ensureStaticConfigExists(projectDir);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync(projectDir);
|
||||
const isMultiFlavor =
|
||||
buildGradle.android?.productFlavors || buildGradle.android?.flavorDimensions;
|
||||
if (isMultiFlavor) {
|
||||
throw new Error(
|
||||
'Automatic version bumping is not supported for multi-flavor Android projects.'
|
||||
);
|
||||
}
|
||||
|
||||
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
|
||||
Log.log('Updated versions in app.json');
|
||||
await writeVersionsToBuildGradleAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
});
|
||||
Log.log('Synchronized versions with build gradle');
|
||||
}
|
||||
|
||||
export async function bumpVersionInAppJsonAsync({
|
||||
bumpStrategy,
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
bumpStrategy: BumpStrategy;
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
}): Promise<void> {
|
||||
if (bumpStrategy === BumpStrategy.NOOP) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureStaticConfigExists(projectDir);
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
if (bumpStrategy === BumpStrategy.APP_VERSION) {
|
||||
const appVersion = AndroidConfig.Version.getVersionName(exp) ?? '1.0.0';
|
||||
await bumpAppVersionAsync({ appVersion, projectDir, exp });
|
||||
} else {
|
||||
const versionCode = AndroidConfig.Version.getVersionCode(exp);
|
||||
const bumpedVersionCode = versionCode + 1;
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.android.versionCode')} from ${chalk.bold(
|
||||
versionCode
|
||||
)} to ${chalk.bold(bumpedVersionCode)}`
|
||||
);
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.android = { ...config.android, versionCode: bumpedVersionCode };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeResolveVersionsAsync(
|
||||
projectDir: string,
|
||||
@@ -39,3 +115,39 @@ export async function maybeResolveVersionsAsync(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeVersionsToBuildGradleAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
}): Promise<string> {
|
||||
const buildGradle = await readBuildGradleAsync(projectDir);
|
||||
if (!buildGradle) {
|
||||
throw new Error('This project is missing a build.gradle file.');
|
||||
}
|
||||
let updatedBuildGradle = AndroidConfig.Version.setVersionName(exp, buildGradle);
|
||||
updatedBuildGradle = AndroidConfig.Version.setVersionCode(exp, updatedBuildGradle);
|
||||
await writeBuildGradleAsync({ projectDir, buildGradle: updatedBuildGradle });
|
||||
return updatedBuildGradle;
|
||||
}
|
||||
|
||||
async function readBuildGradleAsync(projectDir: string): Promise<string | undefined> {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
|
||||
if (!(await fs.pathExists(buildGradlePath))) {
|
||||
return undefined;
|
||||
}
|
||||
return await fs.readFile(buildGradlePath, 'utf8');
|
||||
}
|
||||
|
||||
async function writeBuildGradleAsync({
|
||||
projectDir,
|
||||
buildGradle,
|
||||
}: {
|
||||
projectDir: string;
|
||||
buildGradle: string;
|
||||
}): Promise<void> {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
|
||||
await fs.writeFile(buildGradlePath, buildGradle);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ describe(bumpVersionAsync, () => {
|
||||
const fakeExp = initGenericProject();
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.SHORT_VERSION,
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
buildSettings: {},
|
||||
@@ -164,7 +164,7 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
const fakeExp = initManagedProject();
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.SHORT_VERSION,
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
exp: fakeExp,
|
||||
});
|
||||
@@ -215,12 +215,12 @@ describe(readShortVersionAsync, () => {
|
||||
describe('generic project', () => {
|
||||
it('reads the short version from native code', async () => {
|
||||
const exp = initGenericProject();
|
||||
const shortVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
expect(shortVersion).toBe('1.0.0');
|
||||
const appVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
expect(appVersion).toBe('1.0.0');
|
||||
});
|
||||
it('evaluates interpolated build number', async () => {
|
||||
const exp = initGenericProject({
|
||||
shortVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
appVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
});
|
||||
const buildNumber = await readShortVersionAsync('/repo', exp, {
|
||||
CURRENT_PROJECT_VERSION: '1.0.0',
|
||||
@@ -232,8 +232,8 @@ describe(readShortVersionAsync, () => {
|
||||
describe('managed project', () => {
|
||||
it('reads the version from app config', async () => {
|
||||
const exp = initGenericProject();
|
||||
const shortVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
expect(shortVersion).toBe('1.0.0');
|
||||
const appVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
expect(appVersion).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -287,26 +287,29 @@ describe(getInfoPlistPath, () => {
|
||||
});
|
||||
|
||||
function initGenericProject({
|
||||
shortVersion = '1.0.0',
|
||||
appVersion = '1.0.0',
|
||||
version = '1',
|
||||
infoPlistName = 'Info.plist',
|
||||
}: { shortVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
|
||||
}: { appVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
};
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
},
|
||||
expo: fakeExp,
|
||||
}),
|
||||
[`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${shortVersion}</string>
|
||||
<string>${appVersion}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${version}</string>
|
||||
</dict>
|
||||
@@ -315,6 +318,10 @@ function initGenericProject({
|
||||
'/repo'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
}
|
||||
|
||||
function initManagedProject(): ExpoConfig {
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
@@ -323,31 +330,15 @@ function initGenericProject({
|
||||
buildNumber: '1',
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
|
||||
function initManagedProject(): ExpoConfig {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: {
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
},
|
||||
expo: fakeExp,
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
);
|
||||
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
},
|
||||
};
|
||||
return fakeExp;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { IosBuildProfile, VersionAutoIncrement } from '@expo/eas-json';
|
||||
import { IosBuildProfile, IosVersionAutoIncrement } from '@expo/eas-json';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
|
||||
import Log from '../../log';
|
||||
@@ -53,7 +53,7 @@ export async function validateAndSyncProjectConfigurationAsync({
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVersionBumpStrategy(autoIncrement: VersionAutoIncrement): BumpStrategy {
|
||||
function resolveVersionBumpStrategy(autoIncrement: IosVersionAutoIncrement): BumpStrategy {
|
||||
if (autoIncrement === true) {
|
||||
return BumpStrategy.BUILD_NUMBER;
|
||||
} else if (autoIncrement === false) {
|
||||
@@ -61,6 +61,6 @@ function resolveVersionBumpStrategy(autoIncrement: VersionAutoIncrement): BumpSt
|
||||
} else if (autoIncrement === 'buildNumber') {
|
||||
return BumpStrategy.BUILD_NUMBER;
|
||||
} else {
|
||||
return BumpStrategy.SHORT_VERSION;
|
||||
return BumpStrategy.APP_VERSION;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArchiveSource, Ios, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import slash from 'slash';
|
||||
|
||||
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
@@ -24,7 +24,8 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
const projectRootDirectory = path.relative(await vcs.getRootPathAsync(), ctx.projectDir) || '.';
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await vcs.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
|
||||
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
|
||||
if (jobData.credentials) {
|
||||
@@ -41,7 +42,7 @@ export async function prepareJobAsync(
|
||||
projectRootDirectory,
|
||||
distribution: ctx.buildProfile.simulator ? 'simulator' : ctx.buildProfile.distribution,
|
||||
builderEnvironment: {
|
||||
image: resolveImage(ctx),
|
||||
image: ctx.buildProfile.image,
|
||||
node: ctx.buildProfile.node,
|
||||
yarn: ctx.buildProfile.yarn,
|
||||
bundler: ctx.buildProfile.bundler,
|
||||
@@ -73,17 +74,6 @@ export async function prepareJobAsync(
|
||||
return sanitizeJob(job);
|
||||
}
|
||||
|
||||
function resolveImage(ctx: BuildContext<Platform.IOS>): Ios.BuilderEnvironment['image'] {
|
||||
// see https://linear.app/expo/issue/ENG-1396/make-default-image-dependent-on-sdk-version
|
||||
if (!ctx.buildProfile.image && ctx.exp.sdkVersion) {
|
||||
const majorSdkVersion = semver.major(ctx.exp.sdkVersion);
|
||||
if (majorSdkVersion <= 41) {
|
||||
return 'macos-catalina-10.15-xcode-12.4';
|
||||
}
|
||||
}
|
||||
return ctx.buildProfile.image ?? 'default';
|
||||
}
|
||||
|
||||
function prepareTargetCredentials(targetCredentials: TargetCredentials): Ios.TargetCredentials {
|
||||
return {
|
||||
provisioningProfileBase64: targetCredentials.provisioningProfile,
|
||||
|
||||
@@ -2,19 +2,18 @@ import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
|
||||
import Log from '../../log';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { updateAppJsonConfigAsync } from '../utils/appJson';
|
||||
import { bumpAppVersionAsync } from '../utils/version';
|
||||
import { readPlistAsync, writePlistAsync } from './plist';
|
||||
|
||||
export enum BumpStrategy {
|
||||
SHORT_VERSION,
|
||||
APP_VERSION,
|
||||
BUILD_NUMBER,
|
||||
NOOP,
|
||||
}
|
||||
@@ -55,29 +54,9 @@ export async function bumpVersionInAppJsonAsync({
|
||||
}
|
||||
ensureStaticConfigExists(projectDir);
|
||||
Log.addNewLineIfNone();
|
||||
if (bumpStrategy === BumpStrategy.SHORT_VERSION) {
|
||||
const shortVersion = IOSConfig.Version.getVersion(exp);
|
||||
if (semver.valid(shortVersion)) {
|
||||
const bumpedShortVersion = nullthrows(semver.inc(shortVersion, 'patch'));
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.version')} from ${chalk.bold(shortVersion)} to ${chalk.bold(
|
||||
bumpedShortVersion
|
||||
)}`
|
||||
);
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.version = bumpedShortVersion;
|
||||
});
|
||||
} else {
|
||||
Log.log(`${chalk.bold('expo.version')} = ${chalk.bold(shortVersion)} is not a valid semver`);
|
||||
const { bumpedShortVersion } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'bumpedShortVersion',
|
||||
message: 'What is the next version?',
|
||||
});
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.version = bumpedShortVersion;
|
||||
});
|
||||
}
|
||||
if (bumpStrategy === BumpStrategy.APP_VERSION) {
|
||||
const appVersion = IOSConfig.Version.getVersion(exp);
|
||||
await bumpAppVersionAsync({ appVersion, projectDir, exp });
|
||||
} else {
|
||||
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
|
||||
if (buildNumber.match(/^\d+(\.\d+)*$/)) {
|
||||
|
||||
@@ -24,7 +24,9 @@ export function printLogsUrls(builds: BuildFragment[]): void {
|
||||
builds.forEach(build => {
|
||||
const logsUrl = getBuildLogsUrl(build);
|
||||
Log.log(
|
||||
`${appPlatformDisplayNames[build.platform]} build details: ${chalk.underline(logsUrl)}`
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build details: ${chalk.underline(logsUrl)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ExpoConfig, getPackageJson } from '@expo/config';
|
||||
|
||||
export function isExpoUpdatesInstalled(projectDir: string): boolean {
|
||||
const packageJson = getPackageJson(projectDir);
|
||||
return packageJson.dependencies && 'expo-updates' in packageJson.dependencies;
|
||||
return !!(packageJson.dependencies && 'expo-updates' in packageJson.dependencies);
|
||||
}
|
||||
|
||||
export function ensureValidVersions(exp: ExpoConfig): void {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import semver from 'semver';
|
||||
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { updateAppJsonConfigAsync } from './appJson';
|
||||
|
||||
export function ensureStaticConfigExists(projectDir: string): void {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
throw new Error('autoIncrement option is not supported when using app.config.js');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bumpAppVersionAsync({
|
||||
appVersion,
|
||||
projectDir,
|
||||
exp,
|
||||
}: {
|
||||
appVersion: string;
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
}): Promise<void> {
|
||||
let bumpedAppVersion: string;
|
||||
if (semver.valid(appVersion)) {
|
||||
bumpedAppVersion = nullthrows(semver.inc(appVersion, 'patch'));
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.version')} from ${chalk.bold(appVersion)} to ${chalk.bold(
|
||||
bumpedAppVersion
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
Log.log(`${chalk.bold('expo.version')} = ${chalk.bold(appVersion)} is not a valid semver`);
|
||||
bumpedAppVersion = (
|
||||
await promptAsync({
|
||||
type: 'text',
|
||||
name: 'bumpedAppVersion',
|
||||
message: 'What is the next version?',
|
||||
})
|
||||
).bumpedAppVersion;
|
||||
}
|
||||
await updateAppJsonConfigAsync({ projectDir, exp }, config => {
|
||||
config.version = bumpedAppVersion;
|
||||
});
|
||||
}
|
||||
@@ -75,11 +75,7 @@ export default class BranchCreate extends EasCommand {
|
||||
flags,
|
||||
} = this.parse(BranchCreate);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
@@ -98,10 +98,7 @@ export default class BranchDelete extends EasCommand {
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(BranchDelete);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
@@ -67,10 +67,7 @@ export default class BranchList extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = this.parse(BranchList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const branches = await listBranchesAsync({ projectId });
|
||||
|
||||
@@ -162,11 +162,7 @@ export default class BranchPublish extends EasCommand {
|
||||
// If a group was specified, that means we are republishing it.
|
||||
republish = group ? true : republish;
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, {
|
||||
skipSDKVersionRequirement: true,
|
||||
isPublicConfig: true,
|
||||
|
||||
@@ -74,10 +74,7 @@ export default class BranchRename extends EasCommand {
|
||||
flags: { json: jsonFlag, from: currentName, to: newName },
|
||||
} = this.parse(BranchRename);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
@@ -90,11 +90,7 @@ export default class BranchView extends EasCommand {
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(BranchView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export default class BuildCancel extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { BUILD_ID: buildIdFromArg } = this.parse(BuildCancel).args;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
|
||||
@@ -31,7 +31,7 @@ export default class BuildConfigure extends EasCommand {
|
||||
|
||||
await configureAsync({
|
||||
platform,
|
||||
projectDir: (await findProjectRootAsync()) ?? process.cwd(),
|
||||
projectDir: await findProjectRootAsync(),
|
||||
});
|
||||
|
||||
Log.newLine();
|
||||
|
||||
@@ -143,7 +143,7 @@ export default class Build extends EasCommand {
|
||||
const flags = await this.sanitizeFlagsAsync(rawFlags);
|
||||
const { requestedPlatform } = flags;
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
|
||||
|
||||
await vcs.ensureRepoExistsAsync();
|
||||
|
||||
@@ -76,7 +76,7 @@ export default class BuildList extends EasCommand {
|
||||
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
|
||||
const graphqlBuildDistribution = toGraphQLBuildDistribution(buildDistribution);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default class BuildView extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectName = await getProjectFullNameAsync(exp);
|
||||
|
||||
@@ -84,10 +84,7 @@ export default class ChannelCreate extends EasCommand {
|
||||
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 projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -121,10 +121,7 @@ export default class ChannelEdit extends EasCommand {
|
||||
flags: { branch: branchName, json: jsonFlag },
|
||||
} = this.parse(ChannelEdit);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -80,10 +80,7 @@ export default class ChannelList extends EasCommand {
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -346,10 +346,7 @@ export default class ChannelRollout extends EasCommand {
|
||||
flags: { branch: branchName, percent },
|
||||
} = this.parse(ChannelRollout);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const fullName = await getProjectFullNameAsync(exp);
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
@@ -215,10 +215,7 @@ export default class ChannelView extends EasCommand {
|
||||
flags: { json: jsonFlag },
|
||||
} = this.parse(ChannelView);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class Config extends EasCommand {
|
||||
profile?: string;
|
||||
};
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
await handleDeprecatedEasJsonAsync(projectDir, false);
|
||||
|
||||
const reader = new EasJsonReader(projectDir);
|
||||
|
||||
@@ -22,7 +22,7 @@ export default class BuildList extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
let appleTeamIdentifier = this.parse(BuildList).flags['apple-team-id'];
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ If you are not sure what is the UDID of the device you are looking for, run:
|
||||
throw new Error('Device UDID is missing');
|
||||
}
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
|
||||
@@ -34,10 +34,7 @@ export default class ProjectInfo extends EasCommand {
|
||||
static description = 'information about the current project';
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const { app } = await projectInfoByIdAsync(projectId);
|
||||
|
||||
@@ -10,10 +10,7 @@ export default class ProjectInit extends EasCommand {
|
||||
static aliases = ['init'];
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
if (exp.extra?.eas?.projectId) {
|
||||
|
||||
@@ -50,7 +50,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
|
||||
flags: { name, value: secretValue, scope, force },
|
||||
} = this.parse(EnvironmentSecretCreate);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const accountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectAccountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class EnvironmentSecretList extends EasCommand {
|
||||
static description = 'Lists environment secrets available for your current app';
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectAccountName = await getProjectAccountNameAsync(exp);
|
||||
|
||||
@@ -7,7 +7,7 @@ import chalk from 'chalk';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { SubmissionFragment } from '../graphql/generated';
|
||||
import { toAppPlatform } from '../graphql/types/AppPlatform';
|
||||
import Log, { learnMore } from '../log';
|
||||
import Log, { link } from '../log';
|
||||
import {
|
||||
RequestedPlatform,
|
||||
appPlatformDisplayNames,
|
||||
@@ -44,9 +44,7 @@ interface Flags {
|
||||
|
||||
export default class Submit extends EasCommand {
|
||||
static description = `submit build archive to app store
|
||||
See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/submit/eas-json/', {
|
||||
learnMoreMessage: '',
|
||||
})}`;
|
||||
See how to configure submits with eas.json: ${link('https://docs.expo.dev/submit/eas-json/')}`;
|
||||
static aliases = ['build:submit'];
|
||||
|
||||
static flags = {
|
||||
@@ -93,7 +91,7 @@ See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/s
|
||||
const { flags: rawFlags } = this.parse(Submit);
|
||||
const flags = await this.sanitizeFlagsAsync(rawFlags);
|
||||
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@ export default class WebhookCreate extends EasCommand {
|
||||
const { flags } = this.parse(WebhookCreate);
|
||||
const webhookInputParams = await prepareInputParamsAsync(flags);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -29,10 +29,7 @@ export default class WebhookDelete extends EasCommand {
|
||||
args: { ID: webhookId },
|
||||
} = this.parse(WebhookDelete);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
|
||||
|
||||
@@ -29,10 +29,7 @@ export default class WebhookList extends EasCommand {
|
||||
flags: { event },
|
||||
} = this.parse(WebhookList);
|
||||
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
const projectId = await getProjectIdAsync(exp);
|
||||
const projectFullName = await getProjectFullNameAsync(exp);
|
||||
|
||||
@@ -143,6 +143,7 @@ export function getNewAndroidApiMock(): { [key in keyof typeof AndroidGraphqlCli
|
||||
createOrUpdateAndroidAppBuildCredentialsByNameAsync: jest.fn(),
|
||||
createKeystoreAsync: jest.fn(),
|
||||
createGoogleServiceAccountKeyAsync: jest.fn(),
|
||||
deleteGoogleServiceAccountKeyAsync: jest.fn(),
|
||||
getGoogleServiceAccountKeysForAccountAsync: jest.fn(),
|
||||
createFcmAsync: jest.fn(),
|
||||
deleteKeystoreAsync: jest.fn(),
|
||||
|
||||
@@ -4,7 +4,10 @@ import { promptAsync } from '../../../prompts';
|
||||
import { Account } from '../../../user/Account';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { GoogleServiceAccountKey } from '../credentials';
|
||||
import { readAndValidateServiceAccountKey } from '../utils/googleServiceAccountKey';
|
||||
import {
|
||||
detectGoogleServiceAccountKeyPathAsync,
|
||||
readAndValidateServiceAccountKey,
|
||||
} from '../utils/googleServiceAccountKey';
|
||||
|
||||
export class CreateGoogleServiceAccountKey {
|
||||
constructor(private account: Account) {}
|
||||
@@ -13,7 +16,7 @@ export class CreateGoogleServiceAccountKey {
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error(`New Google Service Account Key cannot be created in non-interactive mode.`);
|
||||
}
|
||||
const jsonKeyObject = await this.provideAsync();
|
||||
const jsonKeyObject = await this.provideAsync(ctx);
|
||||
const gsaKeyFragment = await ctx.android.createGoogleServiceAccountKeyAsync(
|
||||
this.account,
|
||||
jsonKeyObject
|
||||
@@ -22,20 +25,30 @@ export class CreateGoogleServiceAccountKey {
|
||||
return gsaKeyFragment;
|
||||
}
|
||||
|
||||
private async provideAsync(): Promise<GoogleServiceAccountKey> {
|
||||
private async provideAsync(ctx: CredentialsContext): Promise<GoogleServiceAccountKey> {
|
||||
try {
|
||||
const { keyJsonPath } = await promptAsync([
|
||||
{
|
||||
type: 'text',
|
||||
name: 'keyJsonPath',
|
||||
message: 'Path to Google Service Account Key JSON file:',
|
||||
validate: (value: string) => value.length > 0 || "Path can't be empty",
|
||||
},
|
||||
]);
|
||||
const keyJsonPath = await this.provideKeyJsonPathAsync(ctx);
|
||||
return readAndValidateServiceAccountKey(keyJsonPath);
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
return await this.provideAsync();
|
||||
return await this.provideAsync(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async provideKeyJsonPathAsync(ctx: CredentialsContext): Promise<string> {
|
||||
const detectedPath = await detectGoogleServiceAccountKeyPathAsync(ctx.projectDir);
|
||||
if (detectedPath) {
|
||||
return detectedPath;
|
||||
}
|
||||
|
||||
const { keyJsonPath } = await promptAsync([
|
||||
{
|
||||
type: 'text',
|
||||
name: 'keyJsonPath',
|
||||
message: 'Path to Google Service Account Key JSON file:',
|
||||
validate: (value: string) => value.length > 0 || "Path can't be empty",
|
||||
},
|
||||
]);
|
||||
return keyJsonPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { Account } from '../../../user/Account';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { selectGoogleServiceAccountKeyAsync } from '../utils/googleServiceAccountKey';
|
||||
|
||||
export class SelectAndRemoveGoogleServiceAccountKey {
|
||||
constructor(private account: Account) {}
|
||||
|
||||
async runAsync(ctx: CredentialsContext): Promise<void> {
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error(
|
||||
`Cannot select and remove Google Service Account Keys in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
const gsaKeyFragments = await ctx.android.getGoogleServiceAccountKeysForAccountAsync(
|
||||
this.account
|
||||
);
|
||||
if (gsaKeyFragments.length === 0) {
|
||||
Log.warn("There aren't any Google Service Account Keys associated with your account.");
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = await selectGoogleServiceAccountKeyAsync(gsaKeyFragments);
|
||||
await new RemoveGoogleServiceAccountKey(selected).runAsync(ctx);
|
||||
Log.succeed('Removed Google Service Account Key.');
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveGoogleServiceAccountKey {
|
||||
constructor(private googleServiceAccountKey: GoogleServiceAccountKeyFragment) {}
|
||||
|
||||
public async runAsync(ctx: CredentialsContext): Promise<void> {
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error(`Cannot remove Google Service Account Keys in non-interactive mode`);
|
||||
}
|
||||
|
||||
// TODO(quin): add an extra edge on GoogleServiceAccountKey to find the apps using it
|
||||
const confirm = await confirmAsync({
|
||||
message: `Deleting this Google Service Account Key may affect your projects that rely on it. Do you want to continue?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
Log.log('Aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log('Removing Google Service Account Key.');
|
||||
await ctx.android.deleteGoogleServiceAccountKeyAsync(this.googleServiceAccountKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
CommonAndroidAppCredentialsFragment,
|
||||
GoogleServiceAccountKeyFragment,
|
||||
} from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { AppLookupParams } from '../api/GraphqlClient';
|
||||
import { AssignGoogleServiceAccountKey } from './AssignGoogleServiceAccountKey';
|
||||
import { CreateGoogleServiceAccountKey } from './CreateGoogleServiceAccountKey';
|
||||
import { UseExistingGoogleServiceAccountKey } from './UseExistingGoogleServiceAccountKey';
|
||||
|
||||
export class SetupGoogleServiceAccountKey {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
public async runAsync(ctx: CredentialsContext): Promise<CommonAndroidAppCredentialsFragment> {
|
||||
const isKeySetup = await this.isGoogleServiceAccountKeySetupAsync(ctx);
|
||||
if (isKeySetup) {
|
||||
Log.succeed('Google Service Account Key already set up.');
|
||||
return nullthrows(
|
||||
await ctx.android.getAndroidAppCredentialsWithCommonFieldsAsync(this.app),
|
||||
'androidAppCredentials cannot be null if google service account key is already set up'
|
||||
);
|
||||
}
|
||||
if (ctx.nonInteractive) {
|
||||
throw new MissingCredentialsNonInteractiveError(
|
||||
'Google Service Account Keys cannot be set up in --non-interactive mode.'
|
||||
);
|
||||
}
|
||||
|
||||
const keysForAccount = await ctx.android.getGoogleServiceAccountKeysForAccountAsync(
|
||||
this.app.account
|
||||
);
|
||||
let googleServiceAccountKey = null;
|
||||
if (keysForAccount.length === 0) {
|
||||
googleServiceAccountKey = await new CreateGoogleServiceAccountKey(this.app.account).runAsync(
|
||||
ctx
|
||||
);
|
||||
} else {
|
||||
googleServiceAccountKey = await this.createOrUseExistingKeyAsync(ctx);
|
||||
}
|
||||
return await new AssignGoogleServiceAccountKey(this.app).runAsync(ctx, googleServiceAccountKey);
|
||||
}
|
||||
|
||||
private async isGoogleServiceAccountKeySetupAsync(ctx: CredentialsContext): Promise<boolean> {
|
||||
const appCredentials = await ctx.android.getAndroidAppCredentialsWithCommonFieldsAsync(
|
||||
this.app
|
||||
);
|
||||
return !!appCredentials?.googleServiceAccountKeyForSubmissions;
|
||||
}
|
||||
|
||||
private async createOrUseExistingKeyAsync(
|
||||
ctx: CredentialsContext
|
||||
): Promise<GoogleServiceAccountKeyFragment> {
|
||||
const { action } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'action',
|
||||
message: 'Select the Google Service Account Key to use for your project:',
|
||||
choices: [
|
||||
{
|
||||
title: '[Choose an existing key]',
|
||||
value: 'CHOOSE_EXISTING',
|
||||
},
|
||||
{ title: '[Upload a new service account key]', value: 'GENERATE' },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'GENERATE') {
|
||||
return await new CreateGoogleServiceAccountKey(this.app.account).runAsync(ctx);
|
||||
}
|
||||
return (
|
||||
(await new UseExistingGoogleServiceAccountKey(this.app.account).runAsync(ctx)) ??
|
||||
(await this.createOrUseExistingKeyAsync(ctx))
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-46
@@ -1,11 +1,8 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { Account } from '../../../user/Account';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { selectGoogleServiceAccountKeyAsync } from '../utils/googleServiceAccountKey';
|
||||
|
||||
export class UseExistingGoogleServiceAccountKey {
|
||||
constructor(private account: Account) {}
|
||||
@@ -23,47 +20,6 @@ export class UseExistingGoogleServiceAccountKey {
|
||||
Log.error("There aren't any Google Service Account Keys associated with your account.");
|
||||
return null;
|
||||
}
|
||||
return await this.selectGoogleServiceAccountKeyAsync(gsaKeyFragments);
|
||||
}
|
||||
|
||||
private async selectGoogleServiceAccountKeyAsync(
|
||||
keys: GoogleServiceAccountKeyFragment[]
|
||||
): Promise<GoogleServiceAccountKeyFragment | null> {
|
||||
const sortedKeys = this.sortGoogleServiceAccountKeysByUpdatedAtDesc(keys);
|
||||
const { chosenKey } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'chosenKey',
|
||||
message: 'Select a Google Service Account Key:',
|
||||
choices: sortedKeys.map(key => ({
|
||||
title: this.formatGoogleServiceAccountKey(key),
|
||||
value: key,
|
||||
})),
|
||||
});
|
||||
return chosenKey;
|
||||
}
|
||||
|
||||
private sortGoogleServiceAccountKeysByUpdatedAtDesc(
|
||||
keys: GoogleServiceAccountKeyFragment[]
|
||||
): GoogleServiceAccountKeyFragment[] {
|
||||
return keys.sort(
|
||||
(keyA, keyB) =>
|
||||
new Date(keyB.updatedAt).getMilliseconds() - new Date(keyA.updatedAt).getMilliseconds()
|
||||
);
|
||||
}
|
||||
|
||||
private formatGoogleServiceAccountKey({
|
||||
projectIdentifier,
|
||||
privateKeyIdentifier,
|
||||
clientEmail,
|
||||
clientIdentifier,
|
||||
updatedAt,
|
||||
}: GoogleServiceAccountKeyFragment): string {
|
||||
let line: string = '';
|
||||
line += `Client Email: ${clientEmail}, Project Id: ${projectIdentifier}`;
|
||||
line += chalk.gray(
|
||||
`\n Client Id: ${clientIdentifier}, Private Key Id: ${privateKeyIdentifier}`
|
||||
);
|
||||
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago,`);
|
||||
return line;
|
||||
return await selectGoogleServiceAccountKeyAsync(gsaKeyFragments);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -19,7 +19,11 @@ beforeEach(() => {
|
||||
describe(CreateGoogleServiceAccountKey, () => {
|
||||
it('creates a Google Service Account Key in Interactive Mode', async () => {
|
||||
vol.fromJSON({
|
||||
'/google-service-account-key.json': JSON.stringify({ private_key: 'super secret' }),
|
||||
'/google-service-account-key.json': JSON.stringify({
|
||||
type: 'service_account',
|
||||
private_key: 'super secret',
|
||||
client_email: '[email protected]',
|
||||
}),
|
||||
});
|
||||
|
||||
const ctx = createCtxMock({ nonInteractive: false });
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { asMock } from '../../../../__tests__/utils';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import {
|
||||
getNewAndroidApiMock,
|
||||
testGoogleServiceAccountKeyFragment,
|
||||
} from '../../../__tests__/fixtures-android';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
import { RemoveGoogleServiceAccountKey } from '../RemoveGoogleServiceAccountKey';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
asMock(confirmAsync).mockImplementation(() => true);
|
||||
|
||||
describe(RemoveGoogleServiceAccountKey, () => {
|
||||
it('removes an Google Service Account Key', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
android: {
|
||||
...getNewAndroidApiMock(),
|
||||
},
|
||||
});
|
||||
const removeGoogleServiceAccountKeyAction = new RemoveGoogleServiceAccountKey(
|
||||
testGoogleServiceAccountKeyFragment
|
||||
);
|
||||
await removeGoogleServiceAccountKeyAction.runAsync(ctx);
|
||||
expect(ctx.android.deleteGoogleServiceAccountKeyAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('errors in Non-Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({ nonInteractive: true });
|
||||
const removeGoogleServiceAccountKeyAction = new RemoveGoogleServiceAccountKey(
|
||||
testGoogleServiceAccountKeyFragment
|
||||
);
|
||||
await expect(removeGoogleServiceAccountKeyAction.runAsync(ctx)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { asMock } from '../../../../__tests__/utils';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import {
|
||||
getNewAndroidApiMock,
|
||||
testAndroidAppCredentialsFragment,
|
||||
testGoogleServiceAccountKeyFragment,
|
||||
} from '../../../__tests__/fixtures-android';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../../errors';
|
||||
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
|
||||
import { SetupGoogleServiceAccountKey } from '../SetupGoogleServiceAccountKey';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('fs');
|
||||
asMock(promptAsync).mockImplementation(() => ({
|
||||
keyJsonPath: '/google-service-account-key.json',
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
describe(SetupGoogleServiceAccountKey, () => {
|
||||
it('skips setup when there is a Google Service Account Key already assigned to the project', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
android: {
|
||||
...getNewAndroidApiMock(),
|
||||
getAndroidAppCredentialsWithCommonFieldsAsync: jest.fn(
|
||||
() => testAndroidAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
|
||||
const setupGoogleServiceAccountKeyAction = new SetupGoogleServiceAccountKey(appLookupParams);
|
||||
await setupGoogleServiceAccountKeyAction.runAsync(ctx);
|
||||
|
||||
expect(ctx.android.createGoogleServiceAccountKeyAsync).not.toHaveBeenCalled();
|
||||
expect(ctx.android.updateAndroidAppCredentialsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('sets up a Google Service Account Key when there is none already setup', async () => {
|
||||
vol.fromJSON({
|
||||
'/google-service-account-key.json': JSON.stringify({
|
||||
type: 'service_account',
|
||||
private_key: 'super secret',
|
||||
client_email: '[email protected]',
|
||||
}),
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
android: {
|
||||
...getNewAndroidApiMock(),
|
||||
createGoogleServiceAccountKeyAsync: jest.fn(() => testGoogleServiceAccountKeyFragment),
|
||||
getGoogleServiceAccountKeysForAccountAsync: jest.fn(() => []),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
|
||||
const setupGoogleServiceAccountKeyAction = new SetupGoogleServiceAccountKey(appLookupParams);
|
||||
await setupGoogleServiceAccountKeyAction.runAsync(ctx);
|
||||
|
||||
expect(ctx.android.createGoogleServiceAccountKeyAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.android.updateAndroidAppCredentialsAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('errors in Non-Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
|
||||
const setupGoogleServiceAccountKeyAction = new SetupGoogleServiceAccountKey(appLookupParams);
|
||||
await expect(setupGoogleServiceAccountKeyAction.runAsync(ctx)).rejects.toThrowError(
|
||||
MissingCredentialsNonInteractiveError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -252,6 +252,14 @@ export async function createGoogleServiceAccountKeyAsync(
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteGoogleServiceAccountKeyAsync(
|
||||
googleServiceAccountKey: GoogleServiceAccountKeyFragment
|
||||
): Promise<void> {
|
||||
return await GoogleServiceAccountKeyMutation.deleteGoogleServiceAccountKeyAsync(
|
||||
googleServiceAccountKey.id
|
||||
);
|
||||
}
|
||||
|
||||
export async function getGoogleServiceAccountKeysForAccountAsync(
|
||||
account: Account
|
||||
): Promise<GoogleServiceAccountKeyFragment[]> {
|
||||
|
||||
+24
@@ -5,6 +5,7 @@ import gql from 'graphql-tag';
|
||||
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
|
||||
import {
|
||||
CreateGoogleServiceAccountKeyMutation,
|
||||
DeleteGoogleServiceAccountKeyMutation,
|
||||
GoogleServiceAccountKeyFragment,
|
||||
GoogleServiceAccountKeyInput,
|
||||
} from '../../../../../graphql/generated';
|
||||
@@ -48,4 +49,27 @@ export const GoogleServiceAccountKeyMutation = {
|
||||
);
|
||||
return data.googleServiceAccountKey.createGoogleServiceAccountKey;
|
||||
},
|
||||
async deleteGoogleServiceAccountKeyAsync(googleServiceAccountKeyId: string): Promise<void> {
|
||||
await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteGoogleServiceAccountKeyMutation>(
|
||||
gql`
|
||||
mutation DeleteGoogleServiceAccountKeyMutation($googleServiceAccountKeyId: ID!) {
|
||||
googleServiceAccountKey {
|
||||
deleteGoogleServiceAccountKey(id: $googleServiceAccountKeyId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
googleServiceAccountKeyId,
|
||||
},
|
||||
{
|
||||
additionalTypenames: ['GoogleServiceAccountKey', 'AndroidAppCredentials'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -19,6 +19,8 @@ export type KeystoreWithType = Keystore & {
|
||||
export type GoogleServiceAccountKey = {
|
||||
[key: string]: any;
|
||||
private_key: string;
|
||||
type: string;
|
||||
client_email: string;
|
||||
};
|
||||
|
||||
export type AndroidCredentials = {
|
||||
|
||||
+10
-2
@@ -1,9 +1,17 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`print credentials prints the AndroidAppCredentials fragment 1`] = `
|
||||
"Android Credentials Project: testApp Application Identifier: test.com.app Push Notifications (FCM): Key: abcd...efgh Updated 0 second agoGoogle Service Account Key For Submissions Project ID sdf.sdf.sdf
|
||||
"Android Credentials
|
||||
Project testApp
|
||||
Application Identifier test.com.appPush Notifications (FCM) Key abcd...efgh
|
||||
Updated 0 second agoGoogle Service Account Key For Submissions Project ID sdf.sdf.sdf
|
||||
Client Email [email protected]
|
||||
Client ID test-client-identifier
|
||||
Private Key ID test-private-key-identifier
|
||||
Updated 0 second ago Configuration: legacy (Default) Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago"
|
||||
Updated 0 second agoConfiguration: legacy (Default) Keystore Type JKS
|
||||
Key Alias QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA==
|
||||
MD5 Fingerprint TE:ST:-M:D5
|
||||
SHA1 Fingerprint TE:ST:-S:HA:1
|
||||
SHA256 Fingerprint TE:ST:-S:HA:25:6
|
||||
Updated 0 second ago"
|
||||
`;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { asMock } from '../../../../__tests__/utils';
|
||||
import { confirmAsync, promptAsync } from '../../../../prompts';
|
||||
import { detectGoogleServiceAccountKeyPathAsync } from '../googleServiceAccountKey';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
beforeAll(() => {
|
||||
const mockDetectableServiceAccountJson = JSON.stringify({
|
||||
type: 'service_account',
|
||||
private_key: 'super secret',
|
||||
client_email: '[email protected]',
|
||||
});
|
||||
|
||||
vol.fromJSON({
|
||||
'/project_dir/subdir/service-account.json': mockDetectableServiceAccountJson,
|
||||
'/project_dir/another-service-account.json': mockDetectableServiceAccountJson,
|
||||
'/other_dir/invalid_file.txt': 'this is not even a JSON',
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
asMock(promptAsync).mockClear();
|
||||
asMock(confirmAsync).mockClear();
|
||||
});
|
||||
|
||||
describe('Google Service Account Key path detection', () => {
|
||||
it('detects a single google-services file and prompts for confirmation', async () => {
|
||||
asMock(confirmAsync).mockResolvedValueOnce(true);
|
||||
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir/subdir');
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Would you like to use this file?' })
|
||||
);
|
||||
expect(serviceAccountPath).toBe('/project_dir/subdir/service-account.json');
|
||||
});
|
||||
|
||||
it('returns null, when no valid files are found in the dir', async () => {
|
||||
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/other_dir'); // no valid files in that dir
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(serviceAccountPath).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null, when user rejects to use detected file', async () => {
|
||||
asMock(confirmAsync).mockResolvedValueOnce(false);
|
||||
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir/subdir');
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(1);
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Would you like to use this file?' })
|
||||
);
|
||||
expect(serviceAccountPath).toBe(null);
|
||||
});
|
||||
|
||||
it('displays a chooser, when multiple files are found', async () => {
|
||||
asMock(promptAsync).mockResolvedValueOnce({
|
||||
selectedPath: '/project_dir/another-service-account.json',
|
||||
});
|
||||
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir'); // should find 2 files here
|
||||
|
||||
const expectedChoices = expect.arrayContaining([
|
||||
expect.objectContaining({ value: '/project_dir/another-service-account.json' }),
|
||||
expect.objectContaining({ value: '/project_dir/subdir/service-account.json' }),
|
||||
expect.objectContaining({ value: null }),
|
||||
]);
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'select',
|
||||
choices: expectedChoices,
|
||||
})
|
||||
);
|
||||
expect(serviceAccountPath).toBe('/project_dir/another-service-account.json');
|
||||
});
|
||||
|
||||
it('returns null, when user selects the "None of above"', async () => {
|
||||
asMock(promptAsync).mockResolvedValueOnce({
|
||||
selectedPath: false,
|
||||
});
|
||||
|
||||
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir'); // should find 2 files here
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'select' }));
|
||||
expect(serviceAccountPath).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,30 @@
|
||||
import JsonFile from '@expo/json-file';
|
||||
import chalk from 'chalk';
|
||||
import glob from 'fast-glob';
|
||||
import Joi from 'joi';
|
||||
import path from 'path';
|
||||
|
||||
import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import { GoogleServiceAccountKey } from '../credentials';
|
||||
|
||||
export const MinimalGoogleServiceAccountKeySchema = Joi.object({
|
||||
type: Joi.string().required(),
|
||||
private_key: Joi.string().required(),
|
||||
client_email: Joi.string().required(),
|
||||
});
|
||||
|
||||
function fileIsServiceAccountKey(keyJsonPath: string): boolean {
|
||||
try {
|
||||
readAndValidateServiceAccountKey(keyJsonPath);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readAndValidateServiceAccountKey(keyJsonPath: string): GoogleServiceAccountKey {
|
||||
try {
|
||||
const jsonKeyObject = JsonFile.read(keyJsonPath);
|
||||
@@ -34,3 +51,108 @@ export function readAndValidateServiceAccountKey(keyJsonPath: string): GoogleSer
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectGoogleServiceAccountKeyAsync(
|
||||
keys: GoogleServiceAccountKeyFragment[]
|
||||
): Promise<GoogleServiceAccountKeyFragment> {
|
||||
const sortedKeys = sortGoogleServiceAccountKeysByUpdatedAtDesc(keys);
|
||||
const { chosenKey } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'chosenKey',
|
||||
message: 'Select a Google Service Account Key:',
|
||||
choices: sortedKeys.map(key => ({
|
||||
title: formatGoogleServiceAccountKey(key),
|
||||
value: key,
|
||||
})),
|
||||
});
|
||||
return chosenKey;
|
||||
}
|
||||
|
||||
function sortGoogleServiceAccountKeysByUpdatedAtDesc(
|
||||
keys: GoogleServiceAccountKeyFragment[]
|
||||
): GoogleServiceAccountKeyFragment[] {
|
||||
return keys.sort(
|
||||
(keyA, keyB) => new Date(keyB.updatedAt).getTime() - new Date(keyA.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
function formatGoogleServiceAccountKey({
|
||||
projectIdentifier,
|
||||
privateKeyIdentifier,
|
||||
clientEmail,
|
||||
clientIdentifier,
|
||||
updatedAt,
|
||||
}: GoogleServiceAccountKeyFragment): string {
|
||||
let line = `Client Email: ${clientEmail}, Project Id: ${projectIdentifier}`;
|
||||
line += chalk.gray(
|
||||
`\n Client Id: ${clientIdentifier}, Private Key Id: ${privateKeyIdentifier}`
|
||||
);
|
||||
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago,`);
|
||||
return line;
|
||||
}
|
||||
|
||||
export async function detectGoogleServiceAccountKeyPathAsync(
|
||||
projectDir: string
|
||||
): Promise<string | null> {
|
||||
const foundFilePaths = await glob('**/*.json', {
|
||||
cwd: projectDir,
|
||||
ignore: ['app.json', 'package*.json', 'tsconfig.json', 'node_modules'],
|
||||
});
|
||||
|
||||
const googleServiceFiles = foundFilePaths
|
||||
.map(file => path.join(projectDir, file))
|
||||
.filter(fileIsServiceAccountKey);
|
||||
|
||||
if (googleServiceFiles.length > 1) {
|
||||
const selectedPath = await displayPathChooserAsync(googleServiceFiles, projectDir);
|
||||
|
||||
if (selectedPath) {
|
||||
return selectedPath;
|
||||
}
|
||||
} else if (googleServiceFiles.length === 1) {
|
||||
const [detectedPath] = googleServiceFiles;
|
||||
|
||||
if (await confirmDetectedPathAsync(detectedPath)) {
|
||||
return detectedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function displayPathChooserAsync(
|
||||
paths: string[],
|
||||
projectDir: string
|
||||
): Promise<string | null> {
|
||||
const choices = paths.map<{ title: string; value: string | null }>(f => ({
|
||||
value: f,
|
||||
title: f.startsWith(projectDir) ? path.relative(projectDir, f) : f,
|
||||
}));
|
||||
|
||||
choices.push({
|
||||
title: 'None of the above',
|
||||
value: null,
|
||||
});
|
||||
|
||||
Log.log(
|
||||
'Multiple Google Service Account JSON keys have been found inside your project directory.'
|
||||
);
|
||||
const { selectedPath } = await promptAsync({
|
||||
name: 'selectedPath',
|
||||
type: 'select',
|
||||
message: 'Choose the key you want to use:',
|
||||
choices,
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
return selectedPath;
|
||||
}
|
||||
|
||||
async function confirmDetectedPathAsync(path: string): Promise<boolean> {
|
||||
Log.log(`A Google Service Account JSON key has been found at\n ${chalk.underline(path)}`);
|
||||
const confirm = await confirmAsync({
|
||||
message: 'Would you like to use this file?',
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
return confirm;
|
||||
}
|
||||
@@ -16,32 +16,42 @@ import { AppLookupParams } from '../api/GraphqlClient';
|
||||
|
||||
export function displayEmptyAndroidCredentials(appLookupParams: AppLookupParams): void {
|
||||
const { projectName, androidApplicationIdentifier } = appLookupParams;
|
||||
Log.log(chalk.bold(`Android Credentials`));
|
||||
Log.log(` Project: ${projectName}`);
|
||||
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
|
||||
Log.log(` No credentials set up yet!`);
|
||||
const fields = [
|
||||
{ label: 'Android Credentials', value: '' },
|
||||
{ label: 'Project', value: projectName },
|
||||
{ label: 'Application Identifier', value: androidApplicationIdentifier },
|
||||
];
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
Log.log(formatFields([{ label: 'No credentials set up yet!', value: '' }]));
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
function displayAndroidFcmCredentials(appCredentials: CommonAndroidAppCredentialsFragment): void {
|
||||
const maybeFcm = appCredentials.androidFcm;
|
||||
Log.log(` Push Notifications (FCM):`);
|
||||
Log.log(
|
||||
formatFields([{ label: 'Push Notifications (FCM)', value: '' }], {
|
||||
labelFormat: chalk.cyan.bold,
|
||||
})
|
||||
);
|
||||
if (!maybeFcm) {
|
||||
Log.log(` None assigned yet`);
|
||||
Log.log(formatFields([{ label: '', value: 'None assigned yet' }]));
|
||||
Log.newLine();
|
||||
return;
|
||||
}
|
||||
const { snippet, version, updatedAt } = maybeFcm;
|
||||
const fields = [];
|
||||
if (version === AndroidFcmVersion.Legacy) {
|
||||
const { firstFourCharacters, lastFourCharacters } = snippet as FcmSnippetLegacy;
|
||||
Log.log(` Key: ${firstFourCharacters}...${lastFourCharacters}`);
|
||||
fields.push({ label: 'Key', value: `${firstFourCharacters}...${lastFourCharacters}` });
|
||||
} else if (version === AndroidFcmVersion.V1) {
|
||||
const { projectId, serviceAccountEmail, clientId, keyId } = snippet as FcmSnippetV1;
|
||||
Log.log(` Project Id: ${projectId}`);
|
||||
Log.log(` Service Account: ${serviceAccountEmail}`);
|
||||
Log.log(` Client Id: ${clientId}`);
|
||||
Log.log(` Key Id: ${keyId}`);
|
||||
fields.push({ label: 'Project ID', value: projectId });
|
||||
fields.push({ label: 'Client Email', value: serviceAccountEmail });
|
||||
fields.push({ label: 'Client ID', value: clientId ?? 'Unknown' });
|
||||
fields.push({ label: 'Private Key ID', value: keyId });
|
||||
}
|
||||
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
|
||||
fields.push({ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` });
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
@@ -55,11 +65,7 @@ function displayGoogleServiceAccountKeyForSubmissions(
|
||||
})
|
||||
);
|
||||
if (!maybeGsaKey) {
|
||||
Log.log(
|
||||
formatFields([{ label: '', value: 'None assigned yet' }], {
|
||||
labelFormat: chalk.cyan.bold,
|
||||
})
|
||||
);
|
||||
Log.log(formatFields([{ label: '', value: 'None assigned yet' }]));
|
||||
Log.newLine();
|
||||
return;
|
||||
}
|
||||
@@ -107,20 +113,25 @@ function formatFingerprint(fingerprint: string | null): string {
|
||||
return bytes.join(':');
|
||||
}
|
||||
function displayAndroidBuildCredentials(
|
||||
buildCredentials: AndroidAppBuildCredentialsFragment,
|
||||
skipConfigurationDisplay?: boolean
|
||||
buildCredentials: AndroidAppBuildCredentialsFragment
|
||||
): void {
|
||||
if (!skipConfigurationDisplay) {
|
||||
const { isDefault, name } = buildCredentials;
|
||||
Log.log(chalk.bold(` Configuration: ${name}${isDefault ? ' (Default)' : ''}`));
|
||||
}
|
||||
const { isDefault, name } = buildCredentials;
|
||||
Log.log(
|
||||
formatFields([{ label: `Configuration: ${name}${isDefault ? ' (Default)' : ''}`, value: '' }], {
|
||||
labelFormat: chalk.cyan.bold,
|
||||
})
|
||||
);
|
||||
|
||||
const maybeKeystore = buildCredentials.androidKeystore;
|
||||
Log.log(` Keystore:`);
|
||||
Log.log(
|
||||
formatFields([{ label: 'Keystore', value: '' }], {
|
||||
labelFormat: chalk.cyan.bold,
|
||||
})
|
||||
);
|
||||
if (maybeKeystore) {
|
||||
displayAndroidKeystore(maybeKeystore);
|
||||
} else {
|
||||
Log.log(` None assigned yet`);
|
||||
Log.log(formatFields([{ label: '', value: 'None assigned yet' }]));
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
@@ -134,13 +145,15 @@ export function displayAndroidKeystore(keystore: AndroidKeystoreFragment): void
|
||||
sha256CertificateFingerprint,
|
||||
updatedAt,
|
||||
} = keystore;
|
||||
Log.log(` Type: ${type}`);
|
||||
Log.log(` Key Alias: ${keyAlias}`);
|
||||
|
||||
Log.log(` MD5 Fingerprint: ${formatFingerprint(md5CertificateFingerprint ?? null)}`);
|
||||
Log.log(` SHA1 Fingerprint: ${formatFingerprint(sha1CertificateFingerprint ?? null)}`);
|
||||
Log.log(` SHA256 Fingerprint: ${formatFingerprint(sha256CertificateFingerprint ?? null)}`);
|
||||
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
|
||||
const fields = [
|
||||
{ label: 'Type', value: type },
|
||||
{ label: 'Key Alias', value: keyAlias },
|
||||
{ label: 'MD5 Fingerprint', value: formatFingerprint(md5CertificateFingerprint ?? null) },
|
||||
{ label: 'SHA1 Fingerprint', value: formatFingerprint(sha1CertificateFingerprint ?? null) },
|
||||
{ label: 'SHA256 Fingerprint', value: formatFingerprint(sha256CertificateFingerprint ?? null) },
|
||||
{ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` },
|
||||
];
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
}
|
||||
|
||||
export function displayAndroidAppCredentials({
|
||||
@@ -151,9 +164,12 @@ export function displayAndroidAppCredentials({
|
||||
appCredentials: CommonAndroidAppCredentialsFragment | null;
|
||||
}): void {
|
||||
const { projectName, androidApplicationIdentifier } = appLookupParams;
|
||||
Log.log(chalk.bold(`Android Credentials`));
|
||||
Log.log(` Project: ${projectName}`);
|
||||
Log.log(` Application Identifier: ${androidApplicationIdentifier}`);
|
||||
const fields = [
|
||||
{ label: 'Android Credentials', value: '' },
|
||||
{ label: 'Project', value: projectName },
|
||||
{ label: 'Application Identifier', value: androidApplicationIdentifier },
|
||||
];
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
Log.newLine();
|
||||
|
||||
if (appCredentials) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export class CredentialsContext {
|
||||
public readonly user: Actor;
|
||||
|
||||
private shouldAskAuthenticateAppStore: boolean = true;
|
||||
private _exp?: ExpoConfig;
|
||||
private resolvedExp?: ExpoConfig;
|
||||
|
||||
constructor(
|
||||
private options: {
|
||||
@@ -33,10 +33,10 @@ export class CredentialsContext {
|
||||
this.user = options.user;
|
||||
this.nonInteractive = options.nonInteractive ?? false;
|
||||
|
||||
this._exp = options.exp;
|
||||
if (!this._exp) {
|
||||
this.resolvedExp = options.exp;
|
||||
if (!this.resolvedExp) {
|
||||
try {
|
||||
this._exp = getExpoConfig(options.projectDir);
|
||||
this.resolvedExp = getExpoConfig(options.projectDir);
|
||||
} catch (error) {
|
||||
// ignore error, context might be created outside of expo project
|
||||
}
|
||||
@@ -44,12 +44,12 @@ export class CredentialsContext {
|
||||
}
|
||||
|
||||
get hasProjectContext(): boolean {
|
||||
return !!this._exp;
|
||||
return !!this.resolvedExp;
|
||||
}
|
||||
|
||||
get exp(): ExpoConfig {
|
||||
this.ensureProjectContext();
|
||||
return this._exp!;
|
||||
return this.resolvedExp!;
|
||||
}
|
||||
|
||||
public ensureProjectContext(): void {
|
||||
|
||||
@@ -61,7 +61,7 @@ export function formatDistributionCertificate(
|
||||
return line;
|
||||
}
|
||||
|
||||
async function _selectDistributionCertificateAsync(
|
||||
async function selectDistributionCertificateAsync(
|
||||
distCerts: AppleDistributionCertificateFragment[],
|
||||
validDistributionCertificates?: AppleDistributionCertificateFragment[]
|
||||
): Promise<AppleDistributionCertificateFragment | null> {
|
||||
@@ -93,7 +93,7 @@ export async function selectDistributionCertificateWithDependenciesAsync(
|
||||
return null;
|
||||
}
|
||||
if (!ctx.appStore.authCtx) {
|
||||
return _selectDistributionCertificateAsync(distCertsForAccount);
|
||||
return selectDistributionCertificateAsync(distCertsForAccount);
|
||||
}
|
||||
|
||||
// get valid certs on the developer portal
|
||||
@@ -103,7 +103,7 @@ export async function selectDistributionCertificateWithDependenciesAsync(
|
||||
certInfoFromApple
|
||||
);
|
||||
|
||||
return _selectDistributionCertificateAsync(distCertsForAccount, validDistCerts);
|
||||
return selectDistributionCertificateAsync(distCertsForAccount, validDistCerts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +121,7 @@ export async function selectValidDistributionCertificateAsync(
|
||||
return null;
|
||||
}
|
||||
if (!ctx.appStore.authCtx) {
|
||||
return _selectDistributionCertificateAsync(distCertsForAccount);
|
||||
return selectDistributionCertificateAsync(distCertsForAccount);
|
||||
}
|
||||
|
||||
// filter by apple team
|
||||
@@ -140,7 +140,7 @@ export async function selectValidDistributionCertificateAsync(
|
||||
Log.log(
|
||||
`${validDistCerts.length}/${distCertsForAccount.length} Distribution Certificates are currently valid for Apple Team ${ctx.appStore.authCtx?.team.id}.`
|
||||
);
|
||||
return _selectDistributionCertificateAsync(validDistCerts);
|
||||
return selectDistributionCertificateAsync(validDistCerts);
|
||||
}
|
||||
|
||||
const APPLE_DIST_CERTS_TOO_MANY_GENERATED_ERROR = `
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync, pressAnyKeyToContinueAsync } from '../../../prompts';
|
||||
import { confirmAsync, pressAnyKeyToContinueAsync, promptAsync } from '../../../prompts';
|
||||
import differenceBy from '../../../utils/expodash/differenceBy';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
@@ -23,6 +23,12 @@ import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCr
|
||||
import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils';
|
||||
import { SetupDistributionCertificate } from './SetupDistributionCertificate';
|
||||
|
||||
enum ReuseAction {
|
||||
Yes,
|
||||
ShowDevices,
|
||||
No,
|
||||
}
|
||||
|
||||
export class SetupAdhocProvisioningProfile {
|
||||
constructor(private app: AppLookupParams) {}
|
||||
|
||||
@@ -184,10 +190,23 @@ export class SetupAdhocProvisioningProfile {
|
||||
);
|
||||
|
||||
if (allRegisteredDevicesAreProvisioned) {
|
||||
return await confirmAsync({
|
||||
message: `All your registered devices are present in the Provisioning Profile. Would you like to reuse it?`,
|
||||
initial: true,
|
||||
});
|
||||
const reuseAction = await this.promptForReuseActionAsync();
|
||||
if (reuseAction === ReuseAction.Yes) {
|
||||
return true;
|
||||
} else if (reuseAction === ReuseAction.No) {
|
||||
return false;
|
||||
} else {
|
||||
Log.newLine();
|
||||
Log.log('Devices registered in the Provisioning Profile:');
|
||||
for (const device of provisionedDevices) {
|
||||
Log.log(`- ${formatDeviceLabel(device)}`);
|
||||
}
|
||||
Log.newLine();
|
||||
return (
|
||||
(await this.promptForReuseActionAsync({ showShowDevicesOption: false })) ===
|
||||
ReuseAction.Yes
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const missingDevices = differenceBy(registeredAppleDevices, provisionedDevices, 'identifier');
|
||||
Log.warn(`The provisioning profile is missing the following devices:`);
|
||||
@@ -201,6 +220,36 @@ export class SetupAdhocProvisioningProfile {
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForReuseActionAsync({
|
||||
showShowDevicesOption = true,
|
||||
} = {}): Promise<ReuseAction> {
|
||||
const { selected } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selected',
|
||||
message: `${
|
||||
showShowDevicesOption
|
||||
? 'All your registered devices are present in the Provisioning Profile. '
|
||||
: ''
|
||||
}Would you like to reuse the profile?`,
|
||||
choices: [
|
||||
{ title: 'Yes', value: ReuseAction.Yes },
|
||||
...(showShowDevicesOption
|
||||
? [
|
||||
{
|
||||
title: 'Show devices and ask me again',
|
||||
value: ReuseAction.ShowDevices,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'No, let me choose devices again',
|
||||
value: ReuseAction.No,
|
||||
},
|
||||
],
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
private async registerDevicesAsync(
|
||||
ctx: CredentialsContext,
|
||||
appleTeam: AppleTeamFragment
|
||||
|
||||
@@ -15,8 +15,8 @@ export function formatDistributionCertificate({
|
||||
created,
|
||||
ownerName,
|
||||
}: DistributionCertificateStoreInfo): string {
|
||||
const expiresDate = _formatTimestamp(expires);
|
||||
const createdDate = _formatTimestamp(created);
|
||||
const expiresDate = formatTimestamp(expires);
|
||||
const createdDate = formatTimestamp(created);
|
||||
return `${name} (${status}) - ID: ${id} - expires: ${expiresDate} (created: ${createdDate}) - owner: ${ownerName}`;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ export function isPushKey(obj: { [key: string]: any }): obj is PushKey {
|
||||
);
|
||||
}
|
||||
|
||||
function _formatTimestamp(timestamp: number): string {
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return dateformat(new Date(timestamp * 1000));
|
||||
}
|
||||
+46
-1
@@ -1,3 +1,48 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`print credentials prints the IosAppCredentials map 1`] = `"iOS Credentials Project: @quinlanj/test52 Bundle Identifier: com.quinlanj.test52 Apple Team: test-apple-identifier Push Key: Developer Portal ID: 9839M6AY8W Apple Team: 77KQ969CHE Updated 6 months ago Configuration: App Store Distribution Certificate: Serial Number: test-serial Expiration Date: Wed, 13 Oct 2021 18:28:44 UTC Apple Team: test-apple-identifier Updated 6 months ago Provisioning Profile: Developer Portal ID: test-profile-id Status: active Expiration Date: Wed, 13 Oct 2021 18:28:44 UTC Apple Team: test-apple-identifier Updated 5 months ago Configuration: Ad Hoc Distribution Certificate: Serial Number: test-serial Expiration Date: Wed, 13 Oct 2021 18:28:44 UTC Apple Team: test-apple-identifier Updated 6 months ago Provisioning Profile: Developer Portal ID: test-profile-id Status: active Expiration Date: Wed, 13 Oct 2021 18:28:44 UTC Apple Team: test-apple-identifier Updated 5 months ago"`;
|
||||
exports[`print credentials prints the IosAppCredentials map 1`] = `
|
||||
Array [
|
||||
"iOS Credentials
|
||||
Project @quinlanj/test52
|
||||
Bundle Identifier com.quinlanj.test52
|
||||
Apple Team test-apple-identifier
|
||||
|
||||
Push Key
|
||||
Developer Portal ID 9839M6AY8W
|
||||
Apple Team 77KQ969CHE
|
||||
Updated 6 months ago
|
||||
|
||||
|
||||
App Store Configuration
|
||||
|
||||
Distribution Certificate
|
||||
Serial Number test-serial
|
||||
Expiration Date Wed, 13 Oct 2021 18:28:44 UTC
|
||||
Apple Team test-apple-identifier
|
||||
Updated 6 months ago
|
||||
|
||||
Provisioning Profile
|
||||
Developer Portal ID test-profile-id
|
||||
Status active
|
||||
Expiration Wed, 13 Oct 2021 18:28:44 UTC
|
||||
Apple Team test-apple-identifier
|
||||
Updated 5 months ago
|
||||
|
||||
|
||||
Ad Hoc Configuration
|
||||
|
||||
Distribution Certificate
|
||||
Serial Number test-serial
|
||||
Expiration Date Wed, 13 Oct 2021 18:28:44 UTC
|
||||
Apple Team test-apple-identifier
|
||||
Updated 6 months ago
|
||||
|
||||
Provisioning Profile
|
||||
Developer Portal ID test-profile-id
|
||||
Status active
|
||||
Expiration Wed, 13 Oct 2021 18:28:44 UTC
|
||||
Apple Team test-apple-identifier
|
||||
Updated 5 months ago
|
||||
",
|
||||
]
|
||||
`;
|
||||
@@ -8,7 +8,7 @@ import { App, Target } from '../../types';
|
||||
import { displayIosCredentials } from '../printCredentials';
|
||||
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('chalk', () => ({ bold: jest.fn(log => log) }));
|
||||
jest.mock('chalk', () => ({ bold: jest.fn(log => log), cyan: { bold: jest.fn(log => log) } }));
|
||||
|
||||
jest.mock('../../api/graphql/queries/IosAppCredentialsQuery');
|
||||
mockdate.set(new Date('4/20/2021'));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { APPLE_DEVICE_CLASS_LABELS } from '../../../graphql/types/credentials/AppleDevice';
|
||||
import Log from '../../../log';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import formatFields from '../../../utils/formatFields';
|
||||
import { AppLookupParams } from '../api/GraphqlClient';
|
||||
import { App, IosAppBuildCredentialsMap, IosAppCredentialsMap, Target } from '../types';
|
||||
|
||||
@@ -30,10 +31,14 @@ function prettyIosDistributionType(distributionType: IosDistributionType): strin
|
||||
|
||||
export function displayEmptyIosCredentials(appLookupParams: AppLookupParams): void {
|
||||
const { projectName, bundleIdentifier } = appLookupParams;
|
||||
Log.log(chalk.bold(`iOS Credentials`));
|
||||
Log.log(` Project: ${projectName}`);
|
||||
Log.log(` Bundle Identifier: ${bundleIdentifier}`);
|
||||
Log.log(` No credentials set up yet!`);
|
||||
const fields = [
|
||||
{ label: 'iOS Credentials', value: '' },
|
||||
{ label: 'Project', value: projectName },
|
||||
{ label: 'Bundle Identifier', value: bundleIdentifier },
|
||||
];
|
||||
fields.push({ label: '', value: 'No credentials set up yet!' });
|
||||
fields.push({ label: '', value: '' });
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,42 +71,48 @@ export function displayIosCredentials(
|
||||
const projectFullName = `@${app.account.name}/${app.projectName}`;
|
||||
const isMultitarget = targets.length > 1;
|
||||
|
||||
Log.log(chalk.bold(`iOS Credentials`));
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(` Project: ${chalk.bold(projectFullName)}`);
|
||||
const fields = [
|
||||
{ label: 'iOS Credentials', value: '' },
|
||||
{ label: 'Project', value: projectFullName },
|
||||
];
|
||||
|
||||
for (const { targetName, bundleIdentifier } of targets) {
|
||||
if (isMultitarget) {
|
||||
Log.newLine();
|
||||
Log.log(` Target: ${chalk.bold(targetName)}`);
|
||||
fields.push({ label: '', value: '' });
|
||||
fields.push({ label: 'Target', value: targetName });
|
||||
}
|
||||
Log.log(` Bundle Identifier: ${chalk.bold(bundleIdentifier)}`);
|
||||
fields.push({ label: 'Bundle Identifier', value: bundleIdentifier });
|
||||
|
||||
const targetAppCredentials = appCredentialsMap[targetName];
|
||||
if (!targetAppCredentials) {
|
||||
Log.newLine();
|
||||
Log.log(` No credentials set up yet!`);
|
||||
Log.newLine();
|
||||
fields.push({ label: '', value: '' });
|
||||
fields.push({ label: '', value: 'No credentials set up yet!' });
|
||||
fields.push({ label: '', value: '' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const { appleTeam, pushKey } = targetAppCredentials;
|
||||
if (appleTeam) {
|
||||
const { appleTeamIdentifier, appleTeamName } = appleTeam;
|
||||
Log.log(` Apple Team: ${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`);
|
||||
fields.push({
|
||||
label: 'Apple Team',
|
||||
value: `${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`,
|
||||
});
|
||||
}
|
||||
Log.newLine();
|
||||
fields.push({ label: '', value: '' });
|
||||
|
||||
if (pushKey) {
|
||||
displayApplePushKey(pushKey);
|
||||
displayApplePushKey(pushKey, fields);
|
||||
}
|
||||
|
||||
const sortedIosAppBuildCredentialsList = sortBuildCredentialsByDistributionType(
|
||||
targetAppCredentials.iosAppBuildCredentialsList
|
||||
);
|
||||
for (const iosAppBuildCredentials of sortedIosAppBuildCredentialsList) {
|
||||
displayIosAppBuildCredentials(iosAppBuildCredentials);
|
||||
displayIosAppBuildCredentials(iosAppBuildCredentials, fields);
|
||||
}
|
||||
}
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
}
|
||||
|
||||
export function displayProjectCredentials(
|
||||
@@ -117,88 +128,106 @@ export function displayProjectCredentials(
|
||||
const isMultitarget = targets.length > 1;
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Project Credentials Configuration:'));
|
||||
Log.log(` Project: ${chalk.bold(projectFullName)}`);
|
||||
Log.log(chalk.cyan.bold('Project Credentials Configuration'));
|
||||
Log.newLine();
|
||||
const fields = [{ label: 'Project', value: projectFullName }];
|
||||
for (const [targetName, buildCredentials] of Object.entries(appBuildCredentials)) {
|
||||
if (isMultitarget) {
|
||||
Log.newLine();
|
||||
Log.log(` Target: ${chalk.bold(targetName)}`);
|
||||
fields.push({ label: '', value: '' });
|
||||
fields.push({ label: 'Target', value: targetName });
|
||||
}
|
||||
Log.log(` Bundle Identifier: ${chalk.bold(targetToBundleId[targetName])}`);
|
||||
displayIosAppBuildCredentials(buildCredentials);
|
||||
fields.push({ label: 'Bundle Identifier', value: targetToBundleId[targetName] });
|
||||
displayIosAppBuildCredentials(buildCredentials, fields);
|
||||
}
|
||||
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
|
||||
}
|
||||
|
||||
function displayIosAppBuildCredentials(buildCredentials: IosAppBuildCredentialsFragment): void {
|
||||
Log.log(
|
||||
` Configuration: ${chalk.bold(
|
||||
prettyIosDistributionType(buildCredentials.iosDistributionType)
|
||||
)}`
|
||||
);
|
||||
Log.newLine();
|
||||
function displayIosAppBuildCredentials(
|
||||
buildCredentials: IosAppBuildCredentialsFragment,
|
||||
fields: { label: string; value: string }[]
|
||||
): void {
|
||||
fields.push({ label: '', value: '' });
|
||||
fields.push({
|
||||
label: `${prettyIosDistributionType(buildCredentials.iosDistributionType)} Configuration`,
|
||||
value: '',
|
||||
});
|
||||
fields.push({ label: '', value: '' });
|
||||
const maybeDistCert = buildCredentials.distributionCertificate;
|
||||
Log.log(` Distribution Certificate:`);
|
||||
fields.push({ label: 'Distribution Certificate', value: '' });
|
||||
if (maybeDistCert) {
|
||||
const { serialNumber, updatedAt, validityNotAfter, appleTeam } = maybeDistCert;
|
||||
Log.log(` Serial Number: ${serialNumber}`);
|
||||
Log.log(` Expiration Date: ${dateformat(validityNotAfter, 'expiresHeaderFormat')}`);
|
||||
fields.push({ label: 'Serial Number', value: serialNumber });
|
||||
fields.push({
|
||||
label: 'Expiration Date',
|
||||
value: dateformat(validityNotAfter, 'expiresHeaderFormat'),
|
||||
});
|
||||
if (appleTeam) {
|
||||
const { appleTeamIdentifier, appleTeamName } = appleTeam;
|
||||
Log.log(
|
||||
` Apple Team: ${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`
|
||||
);
|
||||
fields.push({
|
||||
label: 'Apple Team',
|
||||
value: `${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`,
|
||||
});
|
||||
}
|
||||
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
|
||||
fields.push({ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` });
|
||||
} else {
|
||||
Log.log(` None assigned yet`);
|
||||
fields.push({ label: '', value: 'None assigned yet' });
|
||||
}
|
||||
Log.newLine();
|
||||
fields.push({ label: '', value: '' });
|
||||
|
||||
const maybeProvProf = buildCredentials.provisioningProfile;
|
||||
Log.log(` Provisioning Profile:`);
|
||||
fields.push({ label: 'Provisioning Profile', value: '' });
|
||||
if (maybeProvProf) {
|
||||
const { expiration, updatedAt, status, developerPortalIdentifier, appleTeam, appleDevices } =
|
||||
maybeProvProf;
|
||||
if (developerPortalIdentifier) {
|
||||
Log.log(` Developer Portal ID: ${developerPortalIdentifier}`);
|
||||
fields.push({ label: 'Developer Portal ID', value: developerPortalIdentifier });
|
||||
}
|
||||
Log.log(` Status: ${status}`);
|
||||
Log.log(` Expiration Date: ${dateformat(expiration, 'expiresHeaderFormat')}`);
|
||||
fields.push({ label: 'Status', value: status });
|
||||
fields.push({ label: 'Expiration', value: dateformat(expiration, 'expiresHeaderFormat') });
|
||||
if (appleTeam) {
|
||||
const { appleTeamIdentifier, appleTeamName } = appleTeam;
|
||||
Log.log(
|
||||
` Apple Team: ${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`
|
||||
);
|
||||
fields.push({
|
||||
label: 'Apple Team',
|
||||
value: `${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`,
|
||||
});
|
||||
}
|
||||
if (appleDevices && appleDevices.length > 0) {
|
||||
Log.log(` Provisioned devices:`);
|
||||
for (const appleDevice of appleDevices) {
|
||||
Log.log(` - ${formatAppleDevice(appleDevice)}`);
|
||||
const [firstAppleDevice, ...rest] = appleDevices;
|
||||
fields.push({
|
||||
label: 'Provisioned devices',
|
||||
value: `- ${formatAppleDevice(firstAppleDevice)}`,
|
||||
});
|
||||
for (const appleDevice of rest) {
|
||||
fields.push({ label: '', value: `- ${formatAppleDevice(appleDevice)}` });
|
||||
}
|
||||
}
|
||||
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
|
||||
fields.push({ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` });
|
||||
} else {
|
||||
Log.log(` None assigned yet`);
|
||||
fields.push({ label: '', value: 'None assigned yet' });
|
||||
}
|
||||
Log.newLine();
|
||||
fields.push({ label: '', value: '' });
|
||||
}
|
||||
|
||||
function displayApplePushKey(maybePushKey: ApplePushKeyFragment | null): void {
|
||||
Log.log(` Push Key:`);
|
||||
function displayApplePushKey(
|
||||
maybePushKey: ApplePushKeyFragment | null,
|
||||
fields: { label: string; value: string }[]
|
||||
): void {
|
||||
fields.push({ label: 'Push Key', value: '' });
|
||||
if (maybePushKey) {
|
||||
const { keyIdentifier, appleTeam, updatedAt } = maybePushKey;
|
||||
Log.log(` Developer Portal ID: ${keyIdentifier}`);
|
||||
fields.push({ label: 'Developer Portal ID', value: keyIdentifier });
|
||||
if (appleTeam) {
|
||||
const { appleTeamIdentifier, appleTeamName } = appleTeam;
|
||||
Log.log(
|
||||
` Apple Team: ${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`
|
||||
);
|
||||
fields.push({
|
||||
label: 'Apple Team',
|
||||
value: `${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`,
|
||||
});
|
||||
}
|
||||
Log.log(` Updated ${fromNow(new Date(updatedAt))} ago`);
|
||||
fields.push({ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` });
|
||||
} else {
|
||||
Log.log(` None assigned yet`);
|
||||
fields.push({ label: '', value: 'None assigned yet' });
|
||||
}
|
||||
Log.newLine();
|
||||
fields.push({ label: '', value: '' });
|
||||
}
|
||||
|
||||
function formatAppleDevice(device: AppleDeviceFragment): string {
|
||||
|
||||
@@ -23,8 +23,10 @@ import { CreateGoogleServiceAccountKey } from '../android/actions/CreateGoogleSe
|
||||
import { CreateKeystore } from '../android/actions/CreateKeystore';
|
||||
import { DownloadKeystore } from '../android/actions/DownloadKeystore';
|
||||
import { RemoveFcm } from '../android/actions/RemoveFcm';
|
||||
import { SelectAndRemoveGoogleServiceAccountKey } from '../android/actions/RemoveGoogleServiceAccountKey';
|
||||
import { RemoveKeystore } from '../android/actions/RemoveKeystore';
|
||||
import { SetupBuildCredentialsFromCredentialsJson } from '../android/actions/SetupBuildCredentialsFromCredentialsJson';
|
||||
import { SetupGoogleServiceAccountKey } from '../android/actions/SetupGoogleServiceAccountKey';
|
||||
import { UpdateCredentialsJson } from '../android/actions/UpdateCredentialsJson';
|
||||
import { UseExistingGoogleServiceAccountKey } from '../android/actions/UseExistingGoogleServiceAccountKey';
|
||||
import {
|
||||
@@ -54,6 +56,8 @@ enum ActionType {
|
||||
RemoveFcm,
|
||||
CreateGsaKey,
|
||||
UseExistingGsaKey,
|
||||
RemoveGsaKey,
|
||||
SetupGsaKey,
|
||||
UpdateCredentialsJson,
|
||||
SetupBuildCredentialsFromCredentialsJson,
|
||||
}
|
||||
@@ -153,6 +157,11 @@ const fcmActions: ActionInfo[] = [
|
||||
];
|
||||
|
||||
const gsaKeyActions: ActionInfo[] = [
|
||||
{
|
||||
value: ActionType.SetupGsaKey,
|
||||
title: 'Setup a Google Service Account Key',
|
||||
scope: Scope.Project,
|
||||
},
|
||||
{
|
||||
value: ActionType.CreateGsaKey,
|
||||
title: 'Upload a Google Service Account Key',
|
||||
@@ -163,6 +172,11 @@ const gsaKeyActions: ActionInfo[] = [
|
||||
title: 'Use an existing Google Service Account Key',
|
||||
scope: Scope.Project,
|
||||
},
|
||||
{
|
||||
value: ActionType.RemoveGsaKey,
|
||||
title: 'Delete a Google Service Account Key',
|
||||
scope: Scope.Project,
|
||||
},
|
||||
{
|
||||
value: ActionType.GoBackToHighLevelActions,
|
||||
title: 'Go back',
|
||||
@@ -346,6 +360,10 @@ export class ManageAndroid {
|
||||
if (gsaKey) {
|
||||
await new AssignGoogleServiceAccountKey(appLookupParams).runAsync(ctx, gsaKey);
|
||||
}
|
||||
} else if (action === ActionType.RemoveGsaKey) {
|
||||
await new SelectAndRemoveGoogleServiceAccountKey(appLookupParams.account).runAsync(ctx);
|
||||
} else if (action === ActionType.SetupGsaKey) {
|
||||
await new SetupGoogleServiceAccountKey(appLookupParams).runAsync(ctx);
|
||||
} else if (action === ActionType.UpdateCredentialsJson) {
|
||||
const buildCredentials = await new SelectExistingAndroidBuildCredentials(
|
||||
appLookupParams
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function createContextAsync({
|
||||
cwd?: string;
|
||||
user: Actor;
|
||||
}): Promise<DeviceManagerContext> {
|
||||
const projectDir = await findProjectRootAsync(cwd);
|
||||
const projectDir = await findProjectRootAsync({ cwd });
|
||||
let exp: ExpoConfig | null = null;
|
||||
if (projectDir) {
|
||||
const config = getConfig(projectDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
+37
-21
@@ -4034,6 +4034,22 @@ export type CreateGoogleServiceAccountKeyMutation = (
|
||||
) }
|
||||
);
|
||||
|
||||
export type DeleteGoogleServiceAccountKeyMutationVariables = Exact<{
|
||||
googleServiceAccountKeyId: Scalars['ID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteGoogleServiceAccountKeyMutation = (
|
||||
{ __typename?: 'RootMutation' }
|
||||
& { googleServiceAccountKey: (
|
||||
{ __typename?: 'GoogleServiceAccountKeyMutation' }
|
||||
& { deleteGoogleServiceAccountKey: (
|
||||
{ __typename?: 'DeleteGoogleServiceAccountKeyResult' }
|
||||
& Pick<DeleteGoogleServiceAccountKeyResult, 'id'>
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type CommonAndroidAppCredentialsWithBuildCredentialsByApplicationIdentifierQueryVariables = Exact<{
|
||||
projectFullName: Scalars['String'];
|
||||
applicationIdentifier?: Maybe<Scalars['String']>;
|
||||
@@ -4057,6 +4073,27 @@ export type CommonAndroidAppCredentialsWithBuildCredentialsByApplicationIdentifi
|
||||
)> }
|
||||
);
|
||||
|
||||
export type GoogleServiceAccountKeyByAccountQueryVariables = Exact<{
|
||||
accountName: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GoogleServiceAccountKeyByAccountQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { account: (
|
||||
{ __typename?: 'AccountQuery' }
|
||||
& { byName: (
|
||||
{ __typename?: 'Account' }
|
||||
& Pick<Account, 'id'>
|
||||
& { googleServiceAccountKeys: Array<(
|
||||
{ __typename?: 'GoogleServiceAccountKey' }
|
||||
& Pick<GoogleServiceAccountKey, 'id'>
|
||||
& GoogleServiceAccountKeyFragment
|
||||
)> }
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type CreateAppleAppIdentifierMutationVariables = Exact<{
|
||||
appleAppIdentifierInput: AppleAppIdentifierInput;
|
||||
accountId: Scalars['ID'];
|
||||
@@ -4637,27 +4674,6 @@ export type AppleTeamByIdentifierQuery = (
|
||||
) }
|
||||
);
|
||||
|
||||
export type GoogleServiceAccountKeyByAccountQueryVariables = Exact<{
|
||||
accountName: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GoogleServiceAccountKeyByAccountQuery = (
|
||||
{ __typename?: 'RootQuery' }
|
||||
& { account: (
|
||||
{ __typename?: 'AccountQuery' }
|
||||
& { byName: (
|
||||
{ __typename?: 'Account' }
|
||||
& Pick<Account, 'id'>
|
||||
& { googleServiceAccountKeys: Array<(
|
||||
{ __typename?: 'GoogleServiceAccountKey' }
|
||||
& Pick<GoogleServiceAccountKey, 'id'>
|
||||
& GoogleServiceAccountKeyFragment
|
||||
)> }
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type IosAppBuildCredentialsByAppleAppIdentiferAndDistributionQueryVariables = Exact<{
|
||||
projectFullName: Scalars['String'];
|
||||
appleAppIdentifierId: Scalars['String'];
|
||||
|
||||
+22
-11
@@ -91,10 +91,30 @@ export default class Log {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a link for given URL, using text if provided, otherwise text is just the URL.
|
||||
* Format links as dim (unless disabled) and with an underline.
|
||||
*
|
||||
* @example https://expo.dev
|
||||
*/
|
||||
export function link(
|
||||
url: string,
|
||||
{ text = url, dim = true }: { text?: string; dim?: boolean } = {}
|
||||
): string {
|
||||
let output: string;
|
||||
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
|
||||
if (terminalLink.isSupported) {
|
||||
output = terminalLink(text, url);
|
||||
} else {
|
||||
output = `${text === url ? '' : text + ': '}${chalk.underline(url)}`;
|
||||
}
|
||||
return dim ? chalk.dim(output) : output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a consistent "Learn more" link experience.
|
||||
* Format links as dim (unless disabled) with an underline.
|
||||
*
|
||||
* @example Learn more: https://expo.dev
|
||||
* @param url
|
||||
*/
|
||||
export function learnMore(
|
||||
url: string,
|
||||
@@ -103,14 +123,5 @@ export function learnMore(
|
||||
dim = true,
|
||||
}: { learnMoreMessage?: string; dim?: boolean } = {}
|
||||
): string {
|
||||
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
|
||||
if (terminalLink.isSupported) {
|
||||
const text = terminalLink(maybeLearnMoreMessage ?? 'Learn more.', url);
|
||||
return dim ? chalk.dim(text) : text;
|
||||
}
|
||||
const learnMoreMessage = maybeLearnMoreMessage ?? 'Learn more';
|
||||
const text = `${
|
||||
learnMoreMessage === '' ? learnMoreMessage : `${learnMoreMessage}: `
|
||||
}${chalk.underline(url)}`;
|
||||
return dim ? chalk.dim(text) : text;
|
||||
return link(url, { text: maybeLearnMoreMessage ?? 'Learn more', dim });
|
||||
}
|
||||
@@ -26,15 +26,32 @@ describe(findProjectRootAsync, () => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('returns null if not inside the project directory', async () => {
|
||||
it('throws if not inside the project directory', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./README.md': '1',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const projectRoot = await findProjectRootAsync('/app');
|
||||
expect(projectRoot).toBeNull();
|
||||
await expect(findProjectRootAsync({ cwd: '/app' })).rejects.toThrow(
|
||||
'Please run this command inside a project directory.'
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to process.cwd() if defaultToProcessCwd = true', async () => {
|
||||
const spy = jest.spyOn(process, 'cwd').mockReturnValue('/this/is/fake/process/cwd');
|
||||
try {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./README.md': '1',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const projectDir = await findProjectRootAsync({ cwd: '/app', defaultToProcessCwd: true });
|
||||
expect(projectDir).toBe('/this/is/fake/process/cwd');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the root directory of the project', async () => {
|
||||
@@ -46,7 +63,7 @@ describe(findProjectRootAsync, () => {
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const projectRoot = await findProjectRootAsync('/app/src');
|
||||
const projectRoot = await findProjectRootAsync({ cwd: '/app/src' });
|
||||
expect(projectRoot).toBe('/app');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,9 +52,23 @@ export async function getProjectAccountNameAsync(exp: ExpoConfig): Promise<strin
|
||||
return getProjectAccountName(exp, user);
|
||||
}
|
||||
|
||||
export async function findProjectRootAsync(cwd?: string): Promise<string | null> {
|
||||
export async function findProjectRootAsync({
|
||||
cwd,
|
||||
defaultToProcessCwd = false,
|
||||
}: {
|
||||
cwd?: string;
|
||||
defaultToProcessCwd?: boolean;
|
||||
} = {}): Promise<string> {
|
||||
const projectRootDir = await pkgDir(cwd);
|
||||
return projectRootDir ?? null;
|
||||
if (!projectRootDir) {
|
||||
if (!defaultToProcessCwd) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
} else {
|
||||
return process.cwd();
|
||||
}
|
||||
} else {
|
||||
return projectRootDir;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setProjectIdAsync(
|
||||
@@ -129,7 +143,7 @@ export async function getProjectIdAsync(
|
||||
}
|
||||
|
||||
// Set the project ID if it is missing.
|
||||
const projectDir = await findProjectRootAsync(process.cwd());
|
||||
const projectDir = await findProjectRootAsync();
|
||||
if (!projectDir) {
|
||||
throw new Error('Please run this command inside a project directory.');
|
||||
}
|
||||
|
||||
@@ -144,12 +144,9 @@ export default class AndroidSubmitCommand {
|
||||
sourceType: ServiceAccountSourceType.path,
|
||||
path: serviceAccountKeyPath,
|
||||
});
|
||||
} else if (this.ctx.nonInteractive) {
|
||||
return result(new Error('Set serviceAccountKeyPath in the submit profile (eas.json).'));
|
||||
} else {
|
||||
return result({
|
||||
sourceType: ServiceAccountSourceType.detect,
|
||||
});
|
||||
}
|
||||
return result({
|
||||
sourceType: ServiceAccountSourceType.credentialsService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
AndroidSubmissionConfigInput,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SubmissionFragment,
|
||||
} from '../../graphql/generated';
|
||||
import { SubmissionMutation } from '../../graphql/mutations/SubmissionMutation';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { Archive, ArchiveSource, getArchiveAsync } from '../ArchiveSource';
|
||||
import BaseSubmitter, { SubmissionInput } from '../BaseSubmitter';
|
||||
import {
|
||||
@@ -16,7 +17,11 @@ import {
|
||||
printSummary,
|
||||
} from '../utils/summary';
|
||||
import { AndroidPackageSource, getAndroidPackageAsync } from './AndroidPackageSource';
|
||||
import { ServiceAccountSource, getServiceAccountAsync } from './ServiceAccountSource';
|
||||
import {
|
||||
ServiceAccountKeyResult,
|
||||
ServiceAccountSource,
|
||||
getServiceAccountKeyResultAsync,
|
||||
} from './ServiceAccountSource';
|
||||
|
||||
export interface AndroidSubmissionOptions
|
||||
extends Pick<
|
||||
@@ -32,7 +37,7 @@ export interface AndroidSubmissionOptions
|
||||
interface ResolvedSourceOptions {
|
||||
androidPackage: string;
|
||||
archive: Archive;
|
||||
serviceAccountPath: string;
|
||||
serviceAccountKeyResult: ServiceAccountKeyResult;
|
||||
}
|
||||
|
||||
export default class AndroidSubmitter extends BaseSubmitter<
|
||||
@@ -73,19 +78,22 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
private async resolveSourceOptionsAsync(): Promise<ResolvedSourceOptions> {
|
||||
const androidPackage = await getAndroidPackageAsync(this.options.androidPackageSource);
|
||||
const archive = await getArchiveAsync(this.options.archiveSource);
|
||||
const serviceAccountPath = await getServiceAccountAsync(this.options.serviceAccountSource);
|
||||
const serviceAccountKeyResult = await getServiceAccountKeyResultAsync(
|
||||
this.ctx,
|
||||
this.options.serviceAccountSource,
|
||||
androidPackage
|
||||
);
|
||||
return {
|
||||
androidPackage,
|
||||
archive,
|
||||
serviceAccountPath,
|
||||
serviceAccountKeyResult,
|
||||
};
|
||||
}
|
||||
|
||||
private async formatSubmissionConfigAsync(
|
||||
options: AndroidSubmissionOptions,
|
||||
{ archive, androidPackage, serviceAccountPath }: ResolvedSourceOptions
|
||||
{ archive, androidPackage, serviceAccountKeyResult }: ResolvedSourceOptions
|
||||
): Promise<AndroidSubmissionConfigInput> {
|
||||
const serviceAccount = await fs.readFile(serviceAccountPath, 'utf-8');
|
||||
const { track, releaseStatus, changesNotSentForReview } = options;
|
||||
return {
|
||||
applicationIdentifier: androidPackage,
|
||||
@@ -93,13 +101,13 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
track,
|
||||
changesNotSentForReview,
|
||||
releaseStatus,
|
||||
googleServiceAccountKeyJson: serviceAccount,
|
||||
...serviceAccountKeyResult.result,
|
||||
};
|
||||
}
|
||||
|
||||
private prepareSummaryData(
|
||||
options: AndroidSubmissionOptions,
|
||||
{ archive, androidPackage, serviceAccountPath }: ResolvedSourceOptions
|
||||
{ archive, androidPackage, serviceAccountKeyResult }: ResolvedSourceOptions
|
||||
): SummaryData {
|
||||
const { projectId, track, releaseStatus, changesNotSentForReview } = options;
|
||||
|
||||
@@ -110,7 +118,7 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
track,
|
||||
changesNotSentForReview: changesNotSentForReview ?? undefined,
|
||||
releaseStatus: releaseStatus ?? undefined,
|
||||
serviceAccountPath,
|
||||
formattedServiceAccount: formatServiceAccountSummary(serviceAccountKeyResult),
|
||||
...formatArchiveSourceSummary(archive),
|
||||
};
|
||||
}
|
||||
@@ -119,9 +127,9 @@ export default class AndroidSubmitter extends BaseSubmitter<
|
||||
type SummaryData = {
|
||||
androidPackage: string;
|
||||
changesNotSentForReview?: boolean;
|
||||
formattedServiceAccount: string;
|
||||
projectId: string;
|
||||
releaseStatus?: SubmissionAndroidReleaseStatus;
|
||||
serviceAccountPath: string;
|
||||
track: SubmissionAndroidTrack;
|
||||
} & ArchiveSourceSummaryFields;
|
||||
|
||||
@@ -131,8 +139,43 @@ const SummaryHumanReadableKeys: Record<keyof SummaryData, string> = {
|
||||
archiveUrl: 'Download URL',
|
||||
changesNotSentForReview: 'Changes not sent for a review',
|
||||
formattedBuild: 'Build',
|
||||
formattedServiceAccount: 'Google Service Account Key',
|
||||
projectId: 'Project ID',
|
||||
releaseStatus: 'Release status',
|
||||
serviceAccountPath: 'Google Service Key',
|
||||
track: 'Release track',
|
||||
};
|
||||
|
||||
function formatServiceAccountSummary({ summary }: ServiceAccountKeyResult): string {
|
||||
const {
|
||||
email: serviceAccountEmail,
|
||||
path: serviceAccountKeyPath,
|
||||
source: serviceAccountKeySource,
|
||||
} = summary;
|
||||
|
||||
const fields = [
|
||||
{
|
||||
label: 'Key Source',
|
||||
value: serviceAccountKeySource,
|
||||
},
|
||||
{
|
||||
label: 'Key Path',
|
||||
value: serviceAccountKeyPath,
|
||||
},
|
||||
{
|
||||
label: 'Account E-mail',
|
||||
value: serviceAccountEmail,
|
||||
},
|
||||
];
|
||||
|
||||
const filteredFields = fields.filter(({ value }) => value !== undefined && value !== null) as {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
return (
|
||||
'\n' +
|
||||
formatFields(filteredFields, {
|
||||
labelFormat: label => ` ${chalk.dim(label)}:`,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import glob from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { SetupGoogleServiceAccountKey } from '../../credentials/android/actions/SetupGoogleServiceAccountKey';
|
||||
import { readAndValidateServiceAccountKey } from '../../credentials/android/utils/googleServiceAccountKey';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { findProjectRootAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { filterAsync } from '../../utils/filterAsync';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import { SubmissionContext } from '../context';
|
||||
import { isExistingFileAsync } from '../utils/files';
|
||||
|
||||
export enum ServiceAccountSourceType {
|
||||
path,
|
||||
prompt,
|
||||
detect,
|
||||
// credentialsService,
|
||||
// ...
|
||||
credentialsService,
|
||||
}
|
||||
|
||||
interface ServiceAccountSourceBase {
|
||||
@@ -30,66 +30,116 @@ interface ServiceAccountPromptSource extends ServiceAccountSourceBase {
|
||||
sourceType: ServiceAccountSourceType.prompt;
|
||||
}
|
||||
|
||||
interface ServiceAccountDetectSource extends ServiceAccountSourceBase {
|
||||
sourceType: ServiceAccountSourceType.detect;
|
||||
export interface ServiceAccountCredentialsServiceSource extends ServiceAccountSourceBase {
|
||||
sourceType: ServiceAccountSourceType.credentialsService;
|
||||
}
|
||||
|
||||
export type ServiceAccountKeyResult = {
|
||||
result: ServiceAccountKeyFile | ServiceAccountKeyFromExpoServers;
|
||||
summary: ServiceAccountKeySummary;
|
||||
};
|
||||
|
||||
type ServiceAccountKeySummary = {
|
||||
source: 'local' | 'EAS servers';
|
||||
path?: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type ServiceAccountKeyFile = {
|
||||
googleServiceAccountKeyJson: string;
|
||||
};
|
||||
|
||||
type ServiceAccountKeyFromExpoServers = {
|
||||
googleServiceAccountKeyId: string;
|
||||
};
|
||||
|
||||
export type ServiceAccountSource =
|
||||
| ServiceAccountPathSource
|
||||
| ServiceAccountPromptSource
|
||||
| ServiceAccountDetectSource;
|
||||
| ServiceAccountCredentialsServiceSource;
|
||||
|
||||
export async function getServiceAccountAsync(source: ServiceAccountSource): Promise<string> {
|
||||
export async function getServiceAccountKeyResultAsync(
|
||||
ctx: SubmissionContext<Platform.ANDROID>,
|
||||
source: ServiceAccountSource,
|
||||
androidApplicationIdentifier: string
|
||||
): Promise<ServiceAccountKeyResult> {
|
||||
if (source.sourceType === ServiceAccountSourceType.credentialsService) {
|
||||
return await getServiceAccountFromCredentialsServiceAsync(ctx, androidApplicationIdentifier);
|
||||
} else {
|
||||
return await getServiceAccountLocallyAsync(source);
|
||||
}
|
||||
}
|
||||
|
||||
async function getServiceAccountLocallyAsync(
|
||||
source: ServiceAccountSource
|
||||
): Promise<ServiceAccountKeyResult> {
|
||||
const serviceAccountKeyPath = await getServiceAccountKeyPathAsync(source);
|
||||
const serviceAccountKey = readAndValidateServiceAccountKey(serviceAccountKeyPath);
|
||||
return {
|
||||
result: { googleServiceAccountKeyJson: await fs.readFile(serviceAccountKeyPath, 'utf-8') },
|
||||
summary: {
|
||||
source: 'local',
|
||||
path: serviceAccountKeyPath,
|
||||
email: serviceAccountKey.client_email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getServiceAccountKeyPathAsync(source: ServiceAccountSource): Promise<string> {
|
||||
switch (source.sourceType) {
|
||||
case ServiceAccountSourceType.path:
|
||||
return await handlePathSourceAsync(source);
|
||||
case ServiceAccountSourceType.prompt:
|
||||
return await handlePromptSourceAsync(source);
|
||||
case ServiceAccountSourceType.detect:
|
||||
return await handleDetectSourceAsync(source);
|
||||
case ServiceAccountSourceType.credentialsService: {
|
||||
throw new Error(`ServiceAccountSource ${source} does not return a path.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServiceAccountFromCredentialsServiceAsync(
|
||||
ctx: SubmissionContext<Platform.ANDROID>,
|
||||
androidApplicationIdentifier: string
|
||||
): Promise<ServiceAccountKeyResult> {
|
||||
const appLookupParams = {
|
||||
account: nullthrows(
|
||||
findAccountByName(ctx.user.accounts, ctx.accountName),
|
||||
`You do not have access to account: ${ctx.accountName}`
|
||||
),
|
||||
projectName: ctx.projectName,
|
||||
androidApplicationIdentifier,
|
||||
};
|
||||
const setupGoogleServiceAccountKeyAction = new SetupGoogleServiceAccountKey(appLookupParams);
|
||||
const androidAppCredentials = await setupGoogleServiceAccountKeyAction.runAsync(
|
||||
ctx.credentialsCtx
|
||||
);
|
||||
const googleServiceAccountKey = nullthrows(
|
||||
androidAppCredentials.googleServiceAccountKeyForSubmissions,
|
||||
'Credentials Service must provide a valid GoogleServiceAccountKey'
|
||||
);
|
||||
|
||||
return {
|
||||
result: {
|
||||
googleServiceAccountKeyId: googleServiceAccountKey.id,
|
||||
},
|
||||
summary: {
|
||||
source: 'EAS servers',
|
||||
email: googleServiceAccountKey.clientEmail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePathSourceAsync(source: ServiceAccountPathSource): Promise<string> {
|
||||
if (!(await isExistingFileAsync(source.path))) {
|
||||
Log.warn(`File ${source.path} doesn't exist.`);
|
||||
return await getServiceAccountAsync({ sourceType: ServiceAccountSourceType.prompt });
|
||||
return await getServiceAccountKeyPathAsync({ sourceType: ServiceAccountSourceType.prompt });
|
||||
}
|
||||
return source.path;
|
||||
}
|
||||
|
||||
async function handleDetectSourceAsync(_source: ServiceAccountDetectSource): Promise<string> {
|
||||
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
|
||||
const foundFilePaths = await glob('**/*.json', {
|
||||
cwd: projectDir,
|
||||
ignore: ['app.json', 'package*.json', 'tsconfig.json', 'node_modules'],
|
||||
});
|
||||
|
||||
const googleServiceFiles = await filterAsync(
|
||||
foundFilePaths.map(file => path.join(projectDir, file)),
|
||||
fileIsGoogleServicesAsync
|
||||
);
|
||||
|
||||
if (googleServiceFiles.length > 1) {
|
||||
const selectedPath = await displayPathChooserAsync(googleServiceFiles, projectDir);
|
||||
|
||||
if (selectedPath !== false) {
|
||||
return selectedPath;
|
||||
}
|
||||
} else if (googleServiceFiles.length === 1) {
|
||||
const [detectedPath] = googleServiceFiles;
|
||||
|
||||
if (await confirmDetectedPathAsync(detectedPath)) {
|
||||
return detectedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return await getServiceAccountAsync({ sourceType: ServiceAccountSourceType.prompt });
|
||||
}
|
||||
|
||||
async function handlePromptSourceAsync(_source: ServiceAccountPromptSource): Promise<string> {
|
||||
const path = await askForServiceAccountPathAsync();
|
||||
return await getServiceAccountAsync({
|
||||
return await getServiceAccountKeyPathAsync({
|
||||
sourceType: ServiceAccountSourceType.path,
|
||||
path,
|
||||
});
|
||||
@@ -124,53 +174,3 @@ async function askForServiceAccountPathAsync(): Promise<string> {
|
||||
});
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function displayPathChooserAsync(
|
||||
paths: string[],
|
||||
projectDir: string
|
||||
): Promise<string | false> {
|
||||
const choices = paths.map<{ title: string; value: string | false }>(f => ({
|
||||
value: f,
|
||||
title: f.startsWith(projectDir) ? path.relative(projectDir, f) : f,
|
||||
}));
|
||||
|
||||
choices.push({
|
||||
title: 'None of the above',
|
||||
value: false,
|
||||
});
|
||||
|
||||
Log.log(
|
||||
'Multiple Google Service Account JSON keys have been found inside your project directory.'
|
||||
);
|
||||
const { selectedPath } = await promptAsync({
|
||||
name: 'selectedPath',
|
||||
type: 'select',
|
||||
message: 'Choose the key you want to use for this submission:',
|
||||
choices,
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
return selectedPath;
|
||||
}
|
||||
|
||||
async function confirmDetectedPathAsync(path: string): Promise<boolean> {
|
||||
Log.log(`A Google Service Account JSON key has been found at\n ${chalk.underline(path)}`);
|
||||
const { confirmed } = await promptAsync({
|
||||
name: 'confirmed',
|
||||
type: 'confirm',
|
||||
message: 'Would you like to use this file?',
|
||||
initial: true,
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
async function fileIsGoogleServicesAsync(path: string): Promise<boolean> {
|
||||
try {
|
||||
const jsonFile = await fs.readJson(path);
|
||||
return jsonFile.type === 'service_account';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,11 @@ describe(AndroidSubmitCommand, () => {
|
||||
|
||||
const fakeFiles: Record<string, string> = {
|
||||
'/apks/fake.apk': 'fake apk',
|
||||
'/google-service-account.json': JSON.stringify({ service: 'account' }),
|
||||
'/google-service-account.json': JSON.stringify({
|
||||
type: 'service_account',
|
||||
private_key: 'super secret',
|
||||
client_email: '[email protected]',
|
||||
}),
|
||||
};
|
||||
|
||||
const fakeBuildFragment: Partial<BuildFragment> = {
|
||||
|
||||
@@ -1,39 +1,70 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { AndroidReleaseStatus, AndroidReleaseTrack } from '@expo/eas-json';
|
||||
import { vol } from 'memfs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { asMock } from '../../../__tests__/utils';
|
||||
import { findProjectRootAsync } from '../../../project/projectUtils';
|
||||
import { testAndroidAppCredentialsFragment } from '../../../credentials/__tests__/fixtures-android';
|
||||
import { jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { SetupGoogleServiceAccountKey } from '../../../credentials/android/actions/SetupGoogleServiceAccountKey';
|
||||
import { createTestProject } from '../../../project/__tests__/project-utils';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { createSubmissionContextAsync } from '../../context';
|
||||
import {
|
||||
ServiceAccountSource,
|
||||
ServiceAccountSourceType,
|
||||
getServiceAccountAsync,
|
||||
getServiceAccountKeyPathAsync,
|
||||
getServiceAccountKeyResultAsync,
|
||||
} from '../ServiceAccountSource';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../../credentials/android/actions/SetupGoogleServiceAccountKey');
|
||||
jest.mock('../../../user/User', () => ({
|
||||
getUserAsync: jest.fn(() => mockJester),
|
||||
}));
|
||||
jest.mock('../../../user/Account', () => ({
|
||||
findAccountByName: jest.fn(() => mockJester.accounts[0]),
|
||||
}));
|
||||
const testProject = createTestProject(mockJester, {
|
||||
android: {
|
||||
package: 'com.expo.test.project',
|
||||
},
|
||||
});
|
||||
const mockManifest = { exp: testProject.appJSON.expo };
|
||||
jest.mock('@expo/config', () => ({
|
||||
getConfig: jest.fn(() => mockManifest),
|
||||
}));
|
||||
|
||||
describe(getServiceAccountAsync, () => {
|
||||
beforeAll(() => {
|
||||
const mockDetectableServiceAccountJson = JSON.stringify({
|
||||
type: 'service_account',
|
||||
});
|
||||
const projectId = uuidv4();
|
||||
const mockDetectableServiceAccountJson = JSON.stringify({
|
||||
type: 'service_account',
|
||||
private_key: 'super secret',
|
||||
client_email: '[email protected]',
|
||||
});
|
||||
|
||||
vol.fromJSON({
|
||||
'/google-service-account.json': JSON.stringify({ service: 'account' }),
|
||||
'/project_dir/subdir/service-account.json': mockDetectableServiceAccountJson,
|
||||
'/project_dir/another-service-account.json': mockDetectableServiceAccountJson,
|
||||
'/other_dir/invalid_file.txt': 'this is not even a JSON',
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
vol.reset();
|
||||
beforeAll(() => {
|
||||
vol.fromJSON({
|
||||
'/google-service-account.json': mockDetectableServiceAccountJson,
|
||||
'/project_dir/subdir/service-account.json': mockDetectableServiceAccountJson,
|
||||
'/project_dir/another-service-account.json': mockDetectableServiceAccountJson,
|
||||
'/other_dir/invalid_file.txt': 'this is not even a JSON',
|
||||
});
|
||||
jest
|
||||
.spyOn(SetupGoogleServiceAccountKey.prototype, 'runAsync')
|
||||
.mockImplementation(async () => testAndroidAppCredentialsFragment);
|
||||
});
|
||||
afterAll(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
asMock(promptAsync).mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
asMock(promptAsync).mockClear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe(getServiceAccountKeyPathAsync, () => {
|
||||
describe('when source is ServiceAccountSourceType.path', () => {
|
||||
it("prompts for path if the provided file doesn't exist", async () => {
|
||||
asMock(promptAsync).mockImplementationOnce(() => ({
|
||||
@@ -43,7 +74,7 @@ describe(getServiceAccountAsync, () => {
|
||||
sourceType: ServiceAccountSourceType.path,
|
||||
path: '/doesnt-exist.json',
|
||||
};
|
||||
const serviceAccountPath = await getServiceAccountAsync(source);
|
||||
const serviceAccountPath = await getServiceAccountKeyPathAsync(source);
|
||||
expect(promptAsync).toHaveBeenCalled();
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
});
|
||||
@@ -53,7 +84,7 @@ describe(getServiceAccountAsync, () => {
|
||||
sourceType: ServiceAccountSourceType.path,
|
||||
path: '/google-service-account.json',
|
||||
};
|
||||
await getServiceAccountAsync(source);
|
||||
await getServiceAccountKeyPathAsync(source);
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -62,7 +93,7 @@ describe(getServiceAccountAsync, () => {
|
||||
sourceType: ServiceAccountSourceType.path,
|
||||
path: '/google-service-account.json',
|
||||
};
|
||||
const serviceAccountPath = await getServiceAccountAsync(source);
|
||||
const serviceAccountPath = await getServiceAccountKeyPathAsync(source);
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
});
|
||||
});
|
||||
@@ -75,7 +106,7 @@ describe(getServiceAccountAsync, () => {
|
||||
const source: ServiceAccountSource = {
|
||||
sourceType: ServiceAccountSourceType.prompt,
|
||||
};
|
||||
const serviceAccountPath = await getServiceAccountAsync(source);
|
||||
const serviceAccountPath = await getServiceAccountKeyPathAsync(source);
|
||||
expect(promptAsync).toHaveBeenCalled();
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
});
|
||||
@@ -94,105 +125,105 @@ describe(getServiceAccountAsync, () => {
|
||||
const source: ServiceAccountSource = {
|
||||
sourceType: ServiceAccountSourceType.prompt,
|
||||
};
|
||||
const serviceAccountPath = await getServiceAccountAsync(source);
|
||||
const serviceAccountPath = await getServiceAccountKeyPathAsync(source);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when source is ServiceAccountSourceType.detect', () => {
|
||||
it('detects a single google-services file and prompts for confirmation', async () => {
|
||||
asMock(findProjectRootAsync).mockResolvedValueOnce('/project_dir/subdir'); // should find 1 file
|
||||
asMock(promptAsync).mockResolvedValueOnce({
|
||||
confirmed: true,
|
||||
});
|
||||
|
||||
const serviceAccountPath = await getServiceAccountAsync({
|
||||
sourceType: ServiceAccountSourceType.detect,
|
||||
});
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'confirm' }));
|
||||
expect(serviceAccountPath).toBe('/project_dir/subdir/service-account.json');
|
||||
describe(getServiceAccountKeyResultAsync, () => {
|
||||
it('returns a local Service Account Key file with a ServiceAccountSourceType.path source', async () => {
|
||||
const ctx = await createSubmissionContextAsync({
|
||||
platform: Platform.ANDROID,
|
||||
projectDir: testProject.projectRoot,
|
||||
projectId,
|
||||
archiveFlags: {
|
||||
url: 'http://expo.dev/fake.apk',
|
||||
},
|
||||
profile: {
|
||||
track: AndroidReleaseTrack.internal,
|
||||
releaseStatus: AndroidReleaseStatus.draft,
|
||||
changesNotSentForReview: false,
|
||||
},
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
it('falls back to prompt, when no valid files are found in the dir', async () => {
|
||||
asMock(findProjectRootAsync).mockResolvedValueOnce('/other_dir'); // no valid files in that dir
|
||||
asMock(promptAsync).mockResolvedValueOnce({
|
||||
filePath: '/google-service-account.json',
|
||||
});
|
||||
|
||||
const serviceAccountPath = await getServiceAccountAsync({
|
||||
sourceType: ServiceAccountSourceType.detect,
|
||||
});
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'text' }));
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
const source: ServiceAccountSource = {
|
||||
sourceType: ServiceAccountSourceType.path,
|
||||
path: '/project_dir/subdir/service-account.json',
|
||||
};
|
||||
const serviceAccountResult = await getServiceAccountKeyResultAsync(ctx, source, 'test.sdf');
|
||||
expect(serviceAccountResult).toMatchObject({
|
||||
result: {
|
||||
googleServiceAccountKeyJson: mockDetectableServiceAccountJson,
|
||||
},
|
||||
summary: {
|
||||
source: 'local',
|
||||
path: '/project_dir/subdir/service-account.json',
|
||||
email: '[email protected]serviceaccount.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to prompt, when user rejects to use detected file', async () => {
|
||||
asMock(findProjectRootAsync).mockResolvedValueOnce('/project_dir/subdir'); // should find 1 file
|
||||
asMock(promptAsync)
|
||||
.mockResolvedValueOnce({
|
||||
confirmed: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
filePath: '/google-service-account.json',
|
||||
});
|
||||
const source: ServiceAccountSource = {
|
||||
sourceType: ServiceAccountSourceType.detect,
|
||||
};
|
||||
|
||||
const serviceAccountPath = await getServiceAccountAsync(source);
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'confirm' }));
|
||||
expect(promptAsync).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'text' }));
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
it('returns a local Service Account Key file with a ServiceAccountSourceType.prompt source', async () => {
|
||||
asMock(promptAsync).mockImplementationOnce(() => ({
|
||||
filePath: '/project_dir/subdir/service-account.json',
|
||||
}));
|
||||
const ctx = await createSubmissionContextAsync({
|
||||
platform: Platform.ANDROID,
|
||||
projectDir: testProject.projectRoot,
|
||||
projectId,
|
||||
archiveFlags: {
|
||||
url: 'http://expo.dev/fake.apk',
|
||||
},
|
||||
profile: {
|
||||
track: AndroidReleaseTrack.internal,
|
||||
releaseStatus: AndroidReleaseStatus.draft,
|
||||
changesNotSentForReview: false,
|
||||
},
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
it('displays a chooser, when multiple files are found', async () => {
|
||||
asMock(findProjectRootAsync).mockResolvedValueOnce('/project_dir'); // should find 2 files here
|
||||
asMock(promptAsync).mockResolvedValueOnce({
|
||||
selectedPath: '/project_dir/another-service-account.json',
|
||||
});
|
||||
|
||||
const serviceAccountPath = await getServiceAccountAsync({
|
||||
sourceType: ServiceAccountSourceType.detect,
|
||||
});
|
||||
|
||||
const expectedChoices = expect.arrayContaining([
|
||||
expect.objectContaining({ value: '/project_dir/another-service-account.json' }),
|
||||
expect.objectContaining({ value: '/project_dir/subdir/service-account.json' }),
|
||||
expect.objectContaining({ value: false }),
|
||||
]);
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'select',
|
||||
choices: expectedChoices,
|
||||
})
|
||||
);
|
||||
expect(serviceAccountPath).toBe('/project_dir/another-service-account.json');
|
||||
const source: ServiceAccountSource = {
|
||||
sourceType: ServiceAccountSourceType.prompt,
|
||||
};
|
||||
const serviceAccountResult = await getServiceAccountKeyResultAsync(ctx, source, 'test.sdf');
|
||||
expect(serviceAccountResult).toMatchObject({
|
||||
result: {
|
||||
googleServiceAccountKeyJson: mockDetectableServiceAccountJson,
|
||||
},
|
||||
summary: {
|
||||
source: 'local',
|
||||
path: '/project_dir/subdir/service-account.json',
|
||||
email: '[email protected]',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to prompt, when user selects the "None of above"', async () => {
|
||||
asMock(findProjectRootAsync).mockResolvedValueOnce('/project_dir'); // should find 2 files here
|
||||
asMock(promptAsync)
|
||||
.mockResolvedValueOnce({
|
||||
selectedPath: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
filePath: '/google-service-account.json',
|
||||
});
|
||||
|
||||
const serviceAccountPath = await getServiceAccountAsync({
|
||||
sourceType: ServiceAccountSourceType.detect,
|
||||
});
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'select' }));
|
||||
expect(promptAsync).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'text' }));
|
||||
expect(serviceAccountPath).toBe('/google-service-account.json');
|
||||
it('returns a remote Service Account Key file with a ServiceAccountSourceType.credentialService source', async () => {
|
||||
const ctx = await createSubmissionContextAsync({
|
||||
platform: Platform.ANDROID,
|
||||
projectDir: testProject.projectRoot,
|
||||
projectId,
|
||||
archiveFlags: {
|
||||
url: 'http://expo.dev/fake.apk',
|
||||
},
|
||||
profile: {
|
||||
track: AndroidReleaseTrack.internal,
|
||||
releaseStatus: AndroidReleaseStatus.draft,
|
||||
changesNotSentForReview: false,
|
||||
},
|
||||
nonInteractive: true,
|
||||
});
|
||||
const serviceAccountResult = await getServiceAccountKeyResultAsync(
|
||||
ctx,
|
||||
{
|
||||
sourceType: ServiceAccountSourceType.credentialsService,
|
||||
},
|
||||
'test.application.identiifer'
|
||||
);
|
||||
expect(serviceAccountResult).toMatchObject({
|
||||
result: { googleServiceAccountKeyId: 'test-id' },
|
||||
summary: { source: 'EAS servers', email: '[email protected]' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,12 @@ import { SubmitProfile } from '@expo/eas-json';
|
||||
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { getExpoConfig } from '../project/expoConfig';
|
||||
import { getProjectAccountName } from '../project/projectUtils';
|
||||
import { Actor } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
|
||||
export interface SubmissionContext<T extends Platform> {
|
||||
accountName: string;
|
||||
archiveFlags: SubmitArchiveFlags;
|
||||
credentialsCtx: CredentialsContext;
|
||||
exp: ExpoConfig;
|
||||
@@ -15,6 +18,8 @@ export interface SubmissionContext<T extends Platform> {
|
||||
profile: SubmitProfile<T>;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
user: Actor;
|
||||
}
|
||||
|
||||
export interface SubmitArchiveFlags {
|
||||
@@ -37,14 +42,19 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
|
||||
const { projectDir, nonInteractive } = params;
|
||||
const exp = getExpoConfig(projectDir, { env: params.env });
|
||||
const { env, ...rest } = params;
|
||||
const user = await ensureLoggedInAsync();
|
||||
const projectName = exp.slug;
|
||||
const accountName = getProjectAccountName(exp, user);
|
||||
let credentialsCtx: CredentialsContext | undefined = params.credentialsCtx;
|
||||
if (!credentialsCtx) {
|
||||
const user = await ensureLoggedInAsync();
|
||||
credentialsCtx = new CredentialsContext({ projectDir, user, exp, nonInteractive });
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
accountName,
|
||||
credentialsCtx,
|
||||
exp,
|
||||
projectName,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Platform } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppPlatform, SubmissionFragment, SubmissionStatus } from '../graphql/generated';
|
||||
import Log, { learnMore } from '../log';
|
||||
import Log, { link } from '../log';
|
||||
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
|
||||
import AndroidSubmitCommand from './android/AndroidSubmitCommand';
|
||||
import { SubmissionContext } from './context';
|
||||
@@ -66,9 +66,8 @@ function printInstructionsForIosSubmission(submission: SubmissionFragment): void
|
||||
'- It usually takes about 5-10 minutes depending on how busy Apple servers are.',
|
||||
// ascAppIdentifier should be always available for ios submissions but check it anyway
|
||||
submission.iosConfig?.ascAppIdentifier &&
|
||||
`- When it’s done, you can see your build here: ${learnMore(
|
||||
`https://appstoreconnect.apple.com/apps/${submission.iosConfig?.ascAppIdentifier}/appstore/ios`,
|
||||
{ learnMoreMessage: '' }
|
||||
`- When it’s done, you can see your build here: ${link(
|
||||
`https://appstoreconnect.apple.com/apps/${submission.iosConfig?.ascAppIdentifier}/appstore/ios`
|
||||
)}`,
|
||||
].join('\n');
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -9,9 +9,7 @@ export function enableJsonOutput(): void {
|
||||
return;
|
||||
}
|
||||
stdoutWrite = process.stdout.write;
|
||||
process.stdout.write = (...args: any) => {
|
||||
return process.stderr.write.call(null, args);
|
||||
};
|
||||
process.stdout.write = process.stderr.write.bind(process.stderr);
|
||||
}
|
||||
|
||||
export function printJsonOnlyOutput(value: object): void {
|
||||
@@ -20,9 +18,7 @@ export function printJsonOnlyOutput(value: object): void {
|
||||
process.stdout.write = stdoutWrite;
|
||||
Log.log(JSON.stringify(sanitizeValue(value), null, 2));
|
||||
} finally {
|
||||
process.stdout.write = (...args: any) => {
|
||||
return process.stderr.write.call(null, args);
|
||||
};
|
||||
process.stdout.write = process.stderr.write.bind(process.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"importHelpers": true,
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"module": "ES2020",
|
||||
"noImplicitReturns": true,
|
||||
"strict": true,
|
||||
"target": "ES2019",
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"typeRoots": [
|
||||
"../../node_modules/@types",
|
||||
"../../ts-declarations",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"type": "module",
|
||||
"description": "A library for interacting with the eas.json",
|
||||
"version": "0.29.0",
|
||||
"version": "0.31.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "0.2.48",
|
||||
"@expo/eas-build-job": "0.2.52",
|
||||
"@expo/json-file": "8.2.33",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "10.0.0",
|
||||
@@ -21,14 +22,16 @@
|
||||
},
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"exports": "./build/index.js",
|
||||
"types": "./build/index.d.ts",
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf build && yarn build",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"clean": "rm -rf build node_modules"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -9,7 +9,9 @@ export type DistributionType = 'store' | 'internal';
|
||||
|
||||
export type IosEnterpriseProvisioning = 'adhoc' | 'universal';
|
||||
|
||||
export type VersionAutoIncrement = boolean | 'version' | 'buildNumber';
|
||||
export type VersionAutoIncrement = boolean | 'version';
|
||||
export type IosVersionAutoIncrement = VersionAutoIncrement | 'buildNumber';
|
||||
export type AndroidVersionAutoIncrement = VersionAutoIncrement | 'versionCode';
|
||||
|
||||
export interface CommonBuildProfile {
|
||||
credentialsSource: CredentialsSource;
|
||||
@@ -29,6 +31,7 @@ export interface AndroidBuildProfile extends CommonBuildProfile {
|
||||
withoutCredentials?: boolean;
|
||||
image?: Android.BuilderEnvironment['image'];
|
||||
ndk?: string;
|
||||
autoIncrement?: AndroidVersionAutoIncrement;
|
||||
|
||||
buildType?: Android.BuildType.APK | Android.BuildType.APP_BUNDLE;
|
||||
|
||||
@@ -38,7 +41,7 @@ export interface AndroidBuildProfile extends CommonBuildProfile {
|
||||
|
||||
export interface IosBuildProfile extends CommonBuildProfile {
|
||||
enterpriseProvisioning?: IosEnterpriseProvisioning;
|
||||
autoIncrement?: VersionAutoIncrement;
|
||||
autoIncrement?: IosVersionAutoIncrement;
|
||||
simulator?: boolean;
|
||||
image?: Ios.BuilderEnvironment['image'];
|
||||
bundler?: string;
|
||||
|
||||
@@ -38,6 +38,10 @@ const AndroidBuildProfileSchema = CommonBuildProfileSchema.concat(
|
||||
|
||||
image: Joi.string().valid(...Android.builderBaseImages),
|
||||
ndk: Joi.string().empty(null).custom(semverSchemaCheck),
|
||||
autoIncrement: Joi.alternatives().try(
|
||||
Joi.boolean(),
|
||||
Joi.string().valid('version', 'versionCode')
|
||||
),
|
||||
|
||||
artifactPath: Joi.string(),
|
||||
gradleCommand: Joi.string(),
|
||||
|
||||
@@ -13,7 +13,8 @@ export {
|
||||
DistributionType,
|
||||
IosBuildProfile,
|
||||
IosEnterpriseProvisioning,
|
||||
VersionAutoIncrement,
|
||||
IosVersionAutoIncrement,
|
||||
AndroidVersionAutoIncrement,
|
||||
} from './EasBuild.types';
|
||||
export { EasJsonReader } from './EasJsonReader';
|
||||
export { hasMismatchedExtendsAsync, isUsingDeprecatedFormatAsync, migrateAsync } from './migrate';
|
||||
@@ -2,13 +2,15 @@
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"importHelpers": true,
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"module": "ES2020",
|
||||
"noImplicitReturns": true,
|
||||
"strict": true,
|
||||
"target": "ES2019",
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"typeRoots": [
|
||||
"../../node_modules/@types",
|
||||
"../../ts-declarations",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const rootEslintrc = require('../.eslintrc.js');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: rootEslintrc.rules['no-restricted-imports'][1].paths.filter(
|
||||
// using lodash in internal scripts is fine
|
||||
({ name }) => name !== 'lodash'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.31.0",
|
||||
"scripts": {
|
||||
"changelog-entry": "ts-node src/changelogEntry.ts",
|
||||
"release-changelog": "ts-node src/releaseChangelog.ts"
|
||||
},
|
||||
"author": "Expo <[email protected]>",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dateformat": "4.5.1",
|
||||
"fs-extra": "10.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "3.0.4",
|
||||
"nullthrows": "1.1.1",
|
||||
"semver": "7.3.5",
|
||||
"tslib": "2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dateformat": "3.0.1",
|
||||
"@types/fs-extra": "9.0.13",
|
||||
"@types/lodash": "4.14.175",
|
||||
"@types/node": "16.7.8",
|
||||
"@types/semver": "7.3.8",
|
||||
"ts-node": "10.2.1",
|
||||
"typescript": "4.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from 'path';
|
||||
|
||||
export enum EntryCategory {
|
||||
BreakingChange = 'breaking-change',
|
||||
NewFeature = 'new-feature',
|
||||
BugFix = 'bug-fix',
|
||||
Chore = 'chore',
|
||||
}
|
||||
|
||||
export const CHANGELOG_PATH = path.join(__dirname, '../../../CHANGELOG.md');
|
||||
|
||||
export const CATEGORY_HEADERS: Record<EntryCategory, string> = {
|
||||
[EntryCategory.BreakingChange]: '🛠 Breaking changes',
|
||||
[EntryCategory.NewFeature]: '🎉 New features',
|
||||
[EntryCategory.BugFix]: '🐛 Bug fixes',
|
||||
[EntryCategory.Chore]: '🧹 Chores',
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import * as markdown from '../markdown';
|
||||
import { CHANGELOG_PATH } from './consts';
|
||||
|
||||
export async function readAndParseChangelogAsync(): Promise<markdown.Tokens> {
|
||||
const contents = await fs.readFile(CHANGELOG_PATH, 'utf8');
|
||||
return markdown.lexify(contents);
|
||||
}
|
||||
|
||||
export async function writeChangelogAsync(tokens: markdown.Tokens): Promise<void> {
|
||||
const newChangelog = markdown.render(tokens);
|
||||
await fs.writeFile(CHANGELOG_PATH, newChangelog);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { CATEGORY_HEADERS, EntryCategory } from './changelog/consts';
|
||||
import { readAndParseChangelogAsync, writeChangelogAsync } from './changelog/file';
|
||||
import * as markdown from './markdown';
|
||||
|
||||
const [rawCategory, ...rest] = process.argv.slice(2);
|
||||
const message = rest.join(' ');
|
||||
|
||||
interface Entry {
|
||||
category: EntryCategory;
|
||||
message: string;
|
||||
author: string;
|
||||
prNumber: number;
|
||||
}
|
||||
|
||||
(async function main(rawCategory: string, rawMessage: string): Promise<void> {
|
||||
const [category, message] = sanitizeInput(rawCategory, rawMessage);
|
||||
|
||||
const entry: Entry = {
|
||||
category,
|
||||
message,
|
||||
author: nullthrows(process.env.GITHUB_PR_AUTHOR),
|
||||
prNumber: Number(nullthrows(process.env.GITHUB_PR_NUMBER)),
|
||||
};
|
||||
|
||||
const tokens = await readAndParseChangelogAsync();
|
||||
addEntry(tokens, entry);
|
||||
await writeChangelogAsync(tokens);
|
||||
})(rawCategory, message).catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function addEntry(tokens: markdown.Tokens, entry: Entry): void {
|
||||
const categoryHeader = CATEGORY_HEADERS[entry.category];
|
||||
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const token = tokens[i];
|
||||
if (
|
||||
token.type === markdown.TokenType.HEADING &&
|
||||
token.depth === 3 &&
|
||||
token.text === categoryHeader
|
||||
) {
|
||||
break;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// i points at the category heading
|
||||
if (tokens[i + 1].type === markdown.TokenType.HEADING) {
|
||||
// no list yet
|
||||
const listToken = markdown.createListToken(0);
|
||||
const spaceToken = markdown.createSpaceToken('\n\n');
|
||||
tokens.splice(i + 1, 0, listToken, spaceToken);
|
||||
}
|
||||
|
||||
// tokens[i + 1] is a list token
|
||||
const listToken = tokens[i + 1] as unknown as markdown.ListToken;
|
||||
const entryMarkdown = formatEntry(entry);
|
||||
const listItemToken = markdown.createListItemToken(entryMarkdown);
|
||||
listToken.items.push(listItemToken);
|
||||
}
|
||||
|
||||
function formatEntry({ message, prNumber, author }: Entry): string {
|
||||
const messageWithDot = message.endsWith('.') ? message : `${message}.`;
|
||||
return `${messageWithDot} ([#${prNumber}](https://github.com/expo/eas-cli/pull/${prNumber}) by [@${author}](https://github.com/${author}))`;
|
||||
}
|
||||
|
||||
function sanitizeInput(
|
||||
rawCategory: string,
|
||||
rawMessage: string
|
||||
): [category: EntryCategory, message: string] {
|
||||
if (!rawCategory || !rawMessage) {
|
||||
throw new Error(
|
||||
'Usage: yarn changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]'
|
||||
);
|
||||
}
|
||||
|
||||
const category = sanitizeCategory(rawCategory.trim());
|
||||
const message = rawMessage.trim();
|
||||
if (message === '') {
|
||||
throw new Error('Message cannot be empty string');
|
||||
}
|
||||
return [category, message];
|
||||
}
|
||||
|
||||
function sanitizeCategory(rawCategory: string): EntryCategory {
|
||||
if (Object.values(EntryCategory).includes(rawCategory as any)) {
|
||||
return rawCategory as EntryCategory;
|
||||
} else {
|
||||
throw new Error(`Invalid changelog entry category: ${rawCategory}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// this is mostly copy-pasted from https://github.com/expo/expo/blob/master/tools/src/Markdown.ts
|
||||
|
||||
import { unescape } from 'lodash';
|
||||
import marked from 'marked';
|
||||
|
||||
export enum TokenType {
|
||||
HEADING = 'heading',
|
||||
LIST = 'list',
|
||||
LIST_ITEM = 'listItem',
|
||||
PARAGRAPH = 'paragraph',
|
||||
TEXT = 'text',
|
||||
BLOCKQUOTE = 'blockquote',
|
||||
SPACE = 'space',
|
||||
CODE = 'code',
|
||||
}
|
||||
|
||||
type SimpleToken<Type> = { type: Type; raw?: string };
|
||||
|
||||
export type TextToken<Type = TokenType.TEXT> = SimpleToken<Type> & { text: string };
|
||||
|
||||
export type HeadingToken = TextToken<TokenType.HEADING> & {
|
||||
depth: number;
|
||||
tokens: Token[];
|
||||
};
|
||||
|
||||
export type ListToken = SimpleToken<TokenType.LIST> & {
|
||||
depth: number;
|
||||
items: ListItemToken[];
|
||||
ordered?: boolean;
|
||||
start?: string;
|
||||
loose?: boolean;
|
||||
};
|
||||
|
||||
export type ListItemToken = TextToken<TokenType.LIST_ITEM> & {
|
||||
depth: number;
|
||||
tokens: Token[];
|
||||
task?: boolean;
|
||||
checked?: boolean;
|
||||
loose?: boolean;
|
||||
};
|
||||
|
||||
export type ParagraphToken = TextToken<TokenType.PARAGRAPH>;
|
||||
|
||||
export type SpaceToken = SimpleToken<TokenType.SPACE>;
|
||||
|
||||
export type CodeToken = TextToken<TokenType.CODE> & {
|
||||
lang: string;
|
||||
};
|
||||
|
||||
export type BlockquoteToken = TextToken<TokenType.BLOCKQUOTE> & {
|
||||
tokens: Token[];
|
||||
};
|
||||
|
||||
export type Token =
|
||||
| HeadingToken
|
||||
| ListToken
|
||||
| ListItemToken
|
||||
| ParagraphToken
|
||||
| TextToken
|
||||
| SpaceToken
|
||||
| CodeToken
|
||||
| BlockquoteToken;
|
||||
|
||||
export interface Tokens extends Array<Token> {
|
||||
links?: any;
|
||||
}
|
||||
|
||||
export interface Renderer {
|
||||
render(tokens: Token[]): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives markdown text and returns an array of tokens.
|
||||
*/
|
||||
export function lexify(text: string): Tokens {
|
||||
const tokens = marked.lexer(text);
|
||||
recursivelyFixTokens(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an array of tokens and renders them to markdown.
|
||||
*/
|
||||
export function render(tokens: Tokens, renderer: Renderer = new MarkdownRenderer()): string {
|
||||
// `marked` module is good enough in terms of lexifying, but its main purpose is to
|
||||
// convert markdown to html, so we need to write our own renderer for changelogs.
|
||||
return unescape(renderer.render(tokens).trim() + EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates heading token with given text and depth.
|
||||
*/
|
||||
export function createHeadingToken(text: string, depth: number = 1): HeadingToken {
|
||||
return {
|
||||
type: TokenType.HEADING,
|
||||
depth,
|
||||
text,
|
||||
tokens: [createTextToken(text)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a token from given text.
|
||||
*/
|
||||
export function createTextToken(text: string): TextToken {
|
||||
return {
|
||||
type: TokenType.TEXT,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
export function createListToken(depth: number = 1): ListToken {
|
||||
return {
|
||||
type: TokenType.LIST,
|
||||
depth,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createListItemToken(text: string, depth: number = 0): ListItemToken {
|
||||
return {
|
||||
type: TokenType.LIST_ITEM,
|
||||
depth,
|
||||
text,
|
||||
tokens: [createTextToken(text)],
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpaceToken(text: string): SpaceToken {
|
||||
return {
|
||||
type: TokenType.SPACE,
|
||||
raw: text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for tokens extending TextToken.
|
||||
*/
|
||||
export function isTextToken(token: Token): token is TextToken {
|
||||
return token?.type === TokenType.TEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for HeadingToken type.
|
||||
*/
|
||||
export function isHeadingToken(token: Token): token is HeadingToken {
|
||||
return token?.type === TokenType.HEADING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for ListToken type.
|
||||
*/
|
||||
export function isListToken(token: Token): token is ListToken {
|
||||
return token?.type === TokenType.LIST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for ListItemToken type.
|
||||
*/
|
||||
export function isListItemToken(token: Token): token is ListItemToken {
|
||||
return token?.type === TokenType.LIST_ITEM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indents subsequent lines in given string.
|
||||
*/
|
||||
export function indentMultilineString(
|
||||
str: string,
|
||||
depth: number = 0,
|
||||
indent: string = ' '
|
||||
): string {
|
||||
return str.replace(/\n/g, '\n' + indent.repeat(depth));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes given tokens in place. We need to know depth of the list
|
||||
*/
|
||||
function recursivelyFixTokens(tokens: Token[], listDepth: number = 0): void {
|
||||
for (const token of tokens) {
|
||||
if (token.type === TokenType.LIST) {
|
||||
token.depth = listDepth;
|
||||
|
||||
for (const item of token.items) {
|
||||
item.type = TokenType.LIST_ITEM;
|
||||
item.depth = listDepth;
|
||||
recursivelyFixTokens(item.tokens, listDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EOL = '\n';
|
||||
|
||||
export type RenderingContext = Partial<{
|
||||
indent: number;
|
||||
orderedList: boolean;
|
||||
itemIndex: number;
|
||||
}>;
|
||||
|
||||
export class MarkdownRenderer implements Renderer {
|
||||
render(tokens: Token[]): string {
|
||||
let output = '';
|
||||
for (const token of tokens) {
|
||||
output += this.renderToken(token, { indent: 0 });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/* helpers */
|
||||
|
||||
renderToken(token: Token, ctx: RenderingContext): string {
|
||||
switch (token.type) {
|
||||
case TokenType.HEADING:
|
||||
return this.heading(token);
|
||||
case TokenType.LIST:
|
||||
return this.list(token, ctx);
|
||||
case TokenType.LIST_ITEM:
|
||||
return this.listItem(token, ctx);
|
||||
case TokenType.PARAGRAPH:
|
||||
return this.paragraph(token, ctx);
|
||||
case TokenType.TEXT:
|
||||
return this.text(token, ctx);
|
||||
case TokenType.SPACE:
|
||||
return this.space(token);
|
||||
case TokenType.CODE:
|
||||
return this.code(token, ctx);
|
||||
case TokenType.BLOCKQUOTE:
|
||||
return this.blockquote(token, ctx);
|
||||
default:
|
||||
// `marked` provides much more tokens, however we don't need to go so deep.
|
||||
// So far we needed only tokens with above types.
|
||||
throw new Error(`Cannot parse token: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
indent(depth?: number, indentStr: string = ' '): string {
|
||||
return depth ? indentStr.repeat(depth) : '';
|
||||
}
|
||||
|
||||
/* tokens */
|
||||
|
||||
heading(token: HeadingToken): string {
|
||||
return this.indent(token.depth, '#') + ' ' + token.text + EOL.repeat(2);
|
||||
}
|
||||
|
||||
list(token: ListToken, ctx: { indent?: number }): string {
|
||||
let output = '';
|
||||
for (let i = 0; i < token.items.length; i++) {
|
||||
output += this.listItem(token.items[i], {
|
||||
...ctx,
|
||||
orderedList: token.ordered,
|
||||
itemIndex: i + 1,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
listItem(token: ListItemToken, ctx: RenderingContext): string {
|
||||
const indent = ctx.indent ?? 0;
|
||||
const bullet = ctx.orderedList ? `${ctx.itemIndex ?? 1}.` : '-';
|
||||
let output = this.indent(indent) + bullet + ' ';
|
||||
|
||||
if (token.tokens[0]) {
|
||||
// `renderToken` result is indented by default (e.g. got TextToken), but when dealing with lists
|
||||
// then list items indents are handled in above code instead
|
||||
output += this.renderToken(token.tokens[0], ctx).trim() + EOL;
|
||||
}
|
||||
|
||||
for (const child of token.tokens.slice(1)) {
|
||||
output += this.renderToken(child, { ...ctx, indent: indent + 1 }).trimRight() + EOL;
|
||||
}
|
||||
return output.trimRight() + EOL;
|
||||
}
|
||||
|
||||
paragraph(token: ParagraphToken, ctx: RenderingContext): string {
|
||||
return this.indent(ctx.indent) + token.text + EOL;
|
||||
}
|
||||
|
||||
text(token: TextToken, ctx: RenderingContext): string {
|
||||
// TextToken may have children which we don't really need - they would render to `text` either way.
|
||||
return this.indent(ctx.indent) + token.text;
|
||||
}
|
||||
|
||||
space(_token: SpaceToken): string {
|
||||
// Actually formatting of other tokens is good enough that we don't need to render additional newlines.
|
||||
return EOL;
|
||||
}
|
||||
|
||||
code(token: CodeToken, ctx: RenderingContext): string {
|
||||
const lines = token.text.split(EOL);
|
||||
const indentStr = this.indent(ctx?.indent);
|
||||
|
||||
lines.unshift('```' + token.lang ?? '');
|
||||
lines.push('```');
|
||||
|
||||
return indentStr + lines.join(EOL + indentStr);
|
||||
}
|
||||
|
||||
blockquote(token: BlockquoteToken, ctx: RenderingContext): string {
|
||||
const indentStr = this.indent(ctx.indent);
|
||||
|
||||
return (
|
||||
indentStr +
|
||||
token.tokens
|
||||
.map(child => '> ' + this.renderToken(child, { ...ctx, indent: 0 }).trimRight())
|
||||
.join(EOL + indentStr)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user