Compare commits

...
37 Commits
Author SHA1 Message Date
Dominik Sokal 9b36ae0ef8 convert to ESM 2021-10-11 16:15:21 +02:00
Dominik Sokal 1a4cb107af add clean script 2021-10-11 16:12:42 +02:00
Dominik Sokal 7576d9195d eslint - enable no-underscore-dangle (#686)
* eslint - enable no-underscore-dangle

* update CHANGELOG.md
2021-10-11 11:10:03 +02:00
Brent VatneandDominik Sokal 838c1508be [eas-cli] Fix printing App Store Connect URL after submission (#683)
* [eas-cli] Fix printing App Store Connect URL after submission

* Update CHANGELOG

* Fix comment

* Remove unnecessary import

* Update other place where we pass in empty string to learnMore

* Apply suggestions from code review

Co-authored-by: Dominik Sokal <[email protected]>
2021-10-11 10:12:03 +02:00
Expo CI 7941683435 update CHANGELOG.md 2021-10-08 23:07:10 +00:00
Quinlan Jung 91f2311a50 v0.31.1 2021-10-08 16:01:17 -07:00
Quinlan Jung 45d10d9b83 [eas-cli] fix non interactive bug for service accounts (#682)
* [eas-cli] fix non interactive bug

* changelog stuff
2021-10-08 15:49:52 -07:00
Dominik Sokal f5d5093922 changelog bot: commit as last commit author (#680) 2021-10-08 16:39:47 +02:00
Dominik Sokal fa4687b0f8 implement simple changelog bot (#676)
* implement simple changelog bot

* update PR template
2021-10-08 15:08:02 +02:00
Expo CI e78c475118 update CHANGELOG.md 2021-10-08 12:37:00 +00:00
Dominik Sokal def91af01f v0.31.0 2021-10-08 14:26:59 +02:00
Dominik Sokal 5eb0fb75ac print platform emoji next to build details url when building for multiple platforms 2021-10-08 14:23:11 +02:00
Dominik Sokal de8c69f766 update CHANGELOG.md 2021-10-08 13:58:55 +02:00
Wojciech KozyraandDominik Sokal 5879ac424d [eas-cli] do not set default image (#675)
* [eas-cli] do not set default image

* upgrade @expo/eas-build-job

* remove redundant import

* update CHANGELOG

Co-authored-by: Dominik Sokal <[email protected]>
2021-10-08 13:56:57 +02:00
Dominik Sokal e30f62ec12 add option to review adhoc devices when reusing profile (#673)
* add option to review adhoc devices when reusing profile

* update CHANGELOG

* update snapshot

* address PR feedback
2021-10-08 10:46:37 +02:00
Bartłomiej Klocek dc4e36e8a8 [eas-cli] Improved service account display in submission summary (#674) 2021-10-08 09:17:46 +02:00
Quinlan Jung 12ded8c9a9 [eas-cli] integrated credentials service with android submissions (#664)
* [eas-cli] integrated credentials service with android submissions

* pr feedback

* tests

* changelog

* pr feedback

* dont print path in summary if it doesnt exist

* fix tests
2021-10-06 13:16:02 -07:00
Dominik Sokal 7378c6fd0e change CHANGELOG commit message 2021-10-06 16:23:02 +02:00
Expo CI 77a8b08834 [skip ci] update CHANGELOG.md 2021-10-06 14:15:53 +00:00
Dominik Sokal 79db9eb482 v0.30.1 2021-10-06 16:09:23 +02:00
Dominik Sokal 59f6d36114 don't use PAT for release 2021-10-06 16:07:24 +02:00
Dominik Sokal c36c26f5a9 fix building monorepo projects on Windows (#671)
* fix building monorepo projects on Windows

* update CHANGELOG

* address PR feedback
2021-10-06 15:34:42 +02:00
Dominik Sokal d6692f20b5 volta pin [email protected] and [email protected] 2021-10-06 09:50:33 +02:00
Brent Vatne 6e37f596df [eas-cli] Bump @expo/config @expo/prebuild-config @expo/config-plugins to fix iOS Info.plist warning (#670)
* [eas-cli] Bump @expo/config @expo/prebuild-config @expo/config-plugins

* Update CHANGELOG
2021-10-06 09:39:57 +02:00
Dominik Sokal d326891ef8 fix committing changelog changes from CI 2021-10-05 18:36:52 +02:00
Dominik Sokal 46a34327d9 fix --json flag when running on GitHub Actions (#669)
* fix --json flag when running on GitHub Actions

* update CHANGELOG
2021-10-05 15:38:34 +02:00
Dominik Sokal 97242af30f update CHANGELOG 2021-10-05 13:23:34 +02:00
Dominik Sokal ca0a8f79cd v0.30.0 2021-10-05 12:32:40 +02:00
Dominik Sokal 67eac59585 automate releases (#668)
* automate releases

* update CONTRIBUTING.md

* add copy-pasta alert

* update CHANGELOG

* send slack notif on successful release
2021-10-05 12:28:34 +02:00
Quinlan Jung 8cf1f0dc22 [eas-cli] fix test snapshots 2021-10-04 13:21:29 -07:00
Quinlan JungandDominik Sokal 51702e4bda [eas-cli] fix ios print formats (#657)
* [eas-cli] fix ios print formats

* fix lint

* changelog

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>

* pr feedback

Co-authored-by: Dominik Sokal <[email protected]>
2021-10-01 13:42:44 -07:00
Quinlan Jung 426a3d1ef3 [eas-cli] use google service key detection in credentials service (#660)
* [eas-cli] use google service key detection in credentials service

* pr feedback

* changelog
2021-09-30 12:59:10 -07:00
Quinlan Jung e8d71a52ec [eas-cli] setup google service account key (#659)
* [eas-cli] setup gsa key

* add tests

* lint

* changelog

* pr feedback
2021-09-29 15:25:41 -07:00
Quinlan JungandDominik Sokal ccd0cb19a4 [eas-cli] remove google service account key (#658)
* [eas-cli] remove google service account key

* chnagelog

* Update packages/eas-cli/src/credentials/android/actions/RemoveGoogleServiceAccountKey.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Update packages/eas-cli/src/credentials/android/utils/googleServiceAccountKey.ts

Co-authored-by: Dominik Sokal <[email protected]>

* pr feedback

* rebase

* rebase

Co-authored-by: Dominik Sokal <[email protected]>
2021-09-29 15:23:15 -07:00
Quinlan JungandDominik Sokal 9f85178d71 [eas-cli] fix android print formats (#656)
* [eas-cli] fix android print formats

* fix lint:

* changelog

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-09-29 10:27:17 -07:00
jkh 085ee52305 [eas-cli] Enable autoIncrementing on Android (#645) 2021-09-29 05:49:42 -07:00
Dominik Sokal 22a71275b2 update CHANGELOG 2021-09-29 13:35:35 +02:00
105 changed files with 2780 additions and 778 deletions

No files matched your search

+1
View File
@@ -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

+1 -1
View File
@@ -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
+46
View File
@@ -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
+68 -1
View File
@@ -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: ''
+52
View File
@@ -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
View File
@@ -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.**
![Wait for all three jobs to complete](./.gh-assets/release-workflow.png)
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.
![There are 5 assets for each release](./.gh-assets/draft-release.png)
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
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.29.1"
"version": "0.31.1"
}
+5 -6
View File
@@ -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
View File
@@ -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 -->
+10 -8
View File
@@ -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'
);
}
+5 -3
View File
@@ -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,
+40 -10
View File
@@ -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;
}
+3 -3
View File
@@ -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;
}
}
+4 -14
View File
@@ -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,
+5 -26
View File
@@ -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)}`
);
});
}
+1 -1
View File
@@ -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);
+1 -4
View File
@@ -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);
+1 -5
View File
@@ -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();
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+3 -5
View File
@@ -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))
);
}
}
@@ -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);
}
}
@@ -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 });
@@ -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();
});
});
@@ -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[]> {
@@ -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 = {
@@ -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"
`;
@@ -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) {
+6 -6
View File
@@ -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));
}
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
});
+17 -3
View File
@@ -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]' },
});
});
});
+11 -1
View File
@@ -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,
};
}
+3 -4
View File
@@ -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();
+2 -6
View File
@@ -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);
}
}
+4 -2
View File
@@ -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",
+7 -4
View File
@@ -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"
+5 -2
View File
@@ -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;
+4
View File
@@ -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(),
+2 -1
View File
@@ -13,7 +13,8 @@ export {
DistributionType,
IosBuildProfile,
IosEnterpriseProvisioning,
VersionAutoIncrement,
IosVersionAutoIncrement,
AndroidVersionAutoIncrement,
} from './EasBuild.types';
export { EasJsonReader } from './EasJsonReader';
export { hasMismatchedExtendsAsync, isUsingDeprecatedFormatAsync, migrateAsync } from './migrate';
+4 -2
View File
@@ -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",
+15
View File
@@ -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'
),
},
],
},
};
+33
View File
@@ -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"
}
}
+17
View File
@@ -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',
};
+14
View File
@@ -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);
}
+97
View File
@@ -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}`);
}
}
+309
View File
@@ -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