mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd23f031b6 | ||
|
|
a74c434718 | ||
|
|
29950a7629 | ||
|
|
0fd33a95f5 | ||
|
|
843ed0d0aa | ||
|
|
bf2cde45c6 | ||
|
|
ecab803cfa | ||
|
|
570ff126a0 | ||
|
|
e24b21bd1e | ||
|
|
20da7f8778 | ||
|
|
14e002274f | ||
|
|
3b8dc6b7aa | ||
|
|
445a48a8dd | ||
|
|
f6436319ed | ||
|
|
bd1e32622d | ||
|
|
74dcb40caa | ||
|
|
f094146dcd | ||
|
|
3784745382 | ||
|
|
72be7e80bf | ||
|
|
cf1b8cb5eb | ||
|
|
b6df8d3a57 | ||
|
|
b96a262cd4 | ||
|
|
3df84f28aa | ||
|
|
554f411fae | ||
|
|
e6ce9837e6 | ||
|
|
30d8a0ce99 | ||
|
|
a9c5907230 | ||
|
|
ad0f94aca9 | ||
|
|
bf66b79126 | ||
|
|
dcc5d62800 | ||
|
|
878ed19f0c | ||
|
|
0e17e415b7 | ||
|
|
d4c5a4e1e4 | ||
|
|
3776c95f00 | ||
|
|
7804a00597 | ||
|
|
5c27574df2 | ||
|
|
f2ab223f0e | ||
|
|
38e64cbd9a | ||
|
|
2cd7194ef6 | ||
|
|
6c33c8a656 | ||
|
|
92643026f3 | ||
|
|
b8a056b43e | ||
|
|
1ba5cb07ce | ||
|
|
8e96143b59 | ||
|
|
52512c333d | ||
|
|
576c798666 | ||
|
|
356d6267ec | ||
|
|
a88897b40e | ||
|
|
8431167a13 | ||
|
|
d87f8d5750 | ||
|
|
1a261cf043 | ||
|
|
9daa933990 | ||
|
|
72f3ba817f | ||
|
|
e8ffdf25f7 | ||
|
|
196abc9a9e | ||
|
|
037529689a | ||
|
|
2502b843f8 | ||
|
|
012e75f8ee | ||
|
|
5ada92430b | ||
|
|
bf27ec5238 | ||
|
|
8f18b35fe8 | ||
|
|
e82b8966b0 | ||
|
|
fad19a1e03 | ||
|
|
553d648e86 | ||
|
|
5588a7343d | ||
|
|
8ec3c52be8 | ||
|
|
e29d7916b8 | ||
|
|
b9840556fc | ||
|
|
3cfb6725e7 | ||
|
|
de794c37f4 | ||
|
|
c9a0747762 | ||
|
|
80dfcdbe29 | ||
|
|
6b05dd6226 | ||
|
|
f1f189b3f4 | ||
|
|
e9f03f7941 | ||
|
|
683a778b15 | ||
|
|
43ccaa6129 | ||
|
|
d2c4608f3f | ||
|
|
d6b2caa4d6 | ||
|
|
78cd4bf86a | ||
|
|
ce8e098a86 | ||
|
|
94494489de | ||
|
|
50ad49cf96 | ||
|
|
6a378b369b | ||
|
|
3f41cdd58e | ||
|
|
ec38cdb154 | ||
|
|
2b89aed724 | ||
|
|
b9334ab7a3 | ||
|
|
0f717c0f22 | ||
|
|
537f95aa7b | ||
|
|
1dee08def7 | ||
|
|
15f6b04daa | ||
|
|
c0f27b8201 | ||
|
|
430181893f | ||
|
|
6636f76cff | ||
|
|
d7b47d080c | ||
|
|
046d8d75bb | ||
|
|
2d1f913648 | ||
|
|
b0522484af | ||
|
|
28ee6b95c9 | ||
|
|
67d85c2025 | ||
|
|
d213040dc4 | ||
|
|
dda4fe45be | ||
|
|
d5c0b1b3d7 | ||
|
|
f968654ffc | ||
|
|
a66753cde9 | ||
|
|
0c85c68948 | ||
|
|
78770912ab | ||
|
|
727dc105f8 | ||
|
|
e63de21702 | ||
|
|
e3bbdd519e | ||
|
|
25cc555189 | ||
|
|
be73d3a337 | ||
|
|
ecb6458a28 | ||
|
|
475d90ea23 | ||
|
|
59171230d4 | ||
|
|
b8b16139bf | ||
|
|
24b725438e | ||
|
|
3d2454ebf2 | ||
|
|
5caef3bc05 | ||
|
|
cc987ab71c | ||
|
|
32d3020659 | ||
|
|
17db11fa72 | ||
|
|
b5305a38cc | ||
|
|
3ec7bb7334 | ||
|
|
ceb07d4f54 | ||
|
|
92e12a57a7 | ||
|
|
373a4d40fa | ||
|
|
568faf7697 | ||
|
|
df6ff1bb7a | ||
|
|
a4a7b1c57f | ||
|
|
a9f8bfd1ff | ||
|
|
e7f687b45d | ||
|
|
666a885ba5 | ||
|
|
25c77d05f4 | ||
|
|
4b26cea3bc | ||
|
|
4aaf486e5e | ||
|
|
9a254ccf2d | ||
|
|
c8aef9dc3a | ||
|
|
f5669de55f | ||
|
|
f08965d9d2 | ||
|
|
817ed771f5 | ||
|
|
da4c41b9b7 | ||
|
|
6f47f5f691 | ||
|
|
204cd32979 | ||
|
|
f83c7d8455 | ||
|
|
5aedf13af1 | ||
|
|
39030e9a3e |
No files matched your search
@@ -3,7 +3,7 @@ commentConfiguration:
|
||||
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
|
||||
rules:
|
||||
- patterns: ['**/*']
|
||||
mentions: ['khamilowicz', 'sjchmiela']
|
||||
mentions: ['douglowder']
|
||||
- patterns: ['packages/eas-cli/src/update/**']
|
||||
mentions: ['wschurman', 'quinlanj']
|
||||
- patterns: ['packages/eas-cli/src/commands/update/**']
|
||||
@@ -22,3 +22,9 @@ rules:
|
||||
mentions: ['kitten', 'kadikraman']
|
||||
- patterns: ['packages/eas-cli/src/commands/deploy/**']
|
||||
mentions: ['kitten', 'kadikraman']
|
||||
- patterns: ['packages/eas-cli/src/commands/build/**']
|
||||
mentions: ['sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/build/**']
|
||||
mentions: ['sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/commands/workflow/**']
|
||||
mentions: ['sjchmiela']
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
build-linux:
|
||||
name: Build for Linux
|
||||
@@ -27,9 +28,13 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Ensure GraphQL schema and generated code is up-to-date
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
yarn verify-graphql-code
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
@@ -68,7 +73,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- run: brew install python-setuptools
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||
@@ -111,7 +116,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: sudo apt-get install nsis
|
||||
- run: yarn build
|
||||
@@ -153,7 +158,7 @@ jobs:
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Build
|
||||
@@ -186,7 +191,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
@@ -206,7 +211,7 @@ jobs:
|
||||
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 commit --allow-empty -m "update CHANGELOG.md"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Ensure GraphQL schema and generated code is up-to-date
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
concurrency:
|
||||
group: test-graphql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Ensure GraphQL schema and generated code is up-to-date
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -0,0 +1,48 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: false
|
||||
description: 'Version number for the release'
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: 'Run the release in dry run mode, without pushing any changes'
|
||||
|
||||
name: Manually trigger a release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Trigger a release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: |
|
||||
# Lerna will not detect changes correctly without having all tags
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
# Checkout the main branch
|
||||
git checkout main
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
yarn install --frozen-lockfile --check-files
|
||||
yarn build
|
||||
if [[ "$INPUT_VERSION" == "" ]]; then
|
||||
echo "Releasing with default version"
|
||||
yarn release
|
||||
else
|
||||
echo "Releasing with version $INPUT_VERSION"
|
||||
yarn release $INPUT_VERSION
|
||||
fi
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
destination_branch: 'main'
|
||||
pr_title: 'Upgrade eas-build dependencies'
|
||||
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
|
||||
pr_reviewer: 'radoslawkrzemien,sjchmiela,khamilowicz'
|
||||
pr_reviewer: 'sjchmiela'
|
||||
github_token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
|
||||
- uses: actions/github-script@v3
|
||||
|
||||
+245
-3
@@ -8,14 +8,256 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
-- Add `--verbose-logs` flag for `build` command ([#3000](https://github.com/expo/eas-cli/pull/3000) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
- Add `eas-build-cache-provider` package. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- Add artifact URLs to workflow:view. ([#3175](https://github.com/expo/eas-cli/pull/3175) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [16.19.1](https://github.com/expo/eas-cli/releases/tag/v16.19.1) - 2025-09-12
|
||||
|
||||
## [16.19.0](https://github.com/expo/eas-cli/releases/tag/v16.19.0) - 2025-09-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Allow any string as Android release `track`, not just the `production`, `internal`, `alpha`, `beta`. ([#3163](https://github.com/expo/eas-cli/pull/3163) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.18.1](https://github.com/expo/eas-cli/releases/tag/v16.18.1) - 2025-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- CLI debug log should respect DEBUG=`eas:*`. ([#3162](https://github.com/expo/eas-cli/pull/3162) by [@douglowder](https://github.com/douglowder))
|
||||
- Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.18.0](https://github.com/expo/eas-cli/releases/tag/v16.18.0) - 2025-08-27
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Show fingerprints in build:view and build:list commands. ([#3137](https://github.com/expo/eas-cli/pull/3137) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make `EXPO_PUBLIC_` env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.17.4](https://github.com/expo/eas-cli/releases/tag/v16.17.4) - 2025-07-31
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Introduce new release process and workflow. ([#3119](https://github.com/expo/eas-cli/pull/3119) by [@douglowder](https://github.com/douglowder))
|
||||
- SSO Login: print browser url ([#3113](https://github.com/expo/eas-cli/pull/3113) by [@BackSlasher](https://github.com/BackSlasher))
|
||||
|
||||
## [16.17.2](https://github.com/expo/eas-cli/releases/tag/v16.17.2) - 2025-07-23
|
||||
|
||||
## [16.17.1](https://github.com/expo/eas-cli/releases/tag/v16.17.1) - 2025-07-23
|
||||
|
||||
## [16.17.0](https://github.com/expo/eas-cli/releases/tag/v16.17.0) - 2025-07-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `deploy:delete` for deleting worker deployments ([#3106](https://github.com/expo/eas-cli/pull/3106) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Add `deploy:alias:delete` for deleting worker deployment aliases ([#3107](https://github.com/expo/eas-cli/pull/3107) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.16.0](https://github.com/expo/eas-cli/releases/tag/v16.16.0) - 2025-07-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add workflow:view and workflow:logs commands. ([#3090](https://github.com/expo/eas-cli/pull/3090) by [@douglowder](https://github.com/douglowder))
|
||||
- Add support for providing inputs to new workflow runs ([#3095](https://github.com/expo/eas-cli/pull/3095) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Refactor capabilities syncing to avoid provisioning profiles becoming invalid. ([#3088](https://github.com/expo/eas-cli/pull/3088) by [@vonovak](https://github.com/vonovak))
|
||||
- Avoid corrupted EAS Hosting batch upload bodies and double-check length and checksums while uploading. ([#3103](https://github.com/expo/eas-cli/pull/3103) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.15.0](https://github.com/expo/eas-cli/releases/tag/v16.15.0) - 2025-07-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Implement new EAS Hosting asset upload protocol, which improves performance by grouping files into batched upload API calls ([#3085](https://github.com/expo/eas-cli/pull/3085) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update the EAS Hosting worker deployment progress indicator to use a progress bar for asset uploads instead ([#3099](https://github.com/expo/eas-cli/pull/3099) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.14.1](https://github.com/expo/eas-cli/releases/tag/v16.14.1) - 2025-07-08
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update apple team info as needed. ([#3096](https://github.com/expo/eas-cli/pull/3096) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.14.0](https://github.com/expo/eas-cli/releases/tag/v16.14.0) - 2025-07-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add update:revert-update-rollout command. ([#3068](https://github.com/expo/eas-cli/pull/3068) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove unreleased `link` and `unlink` commands from `eas env`. ([#3092](https://github.com/expo/eas-cli/pull/3092) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Add new CI to verify GraphQL generation. ([#3093](https://github.com/expo/eas-cli/pull/3093) by [@douglowder](https://github.com/douglowder))
|
||||
- Update GraphQL generation to look in all src directories. ([#3097](https://github.com/expo/eas-cli/pull/3097) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.13.4](https://github.com/expo/eas-cli/releases/tag/v16.13.4) - 2025-07-04
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove hidden flag from `eas upload`. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.13.3](https://github.com/expo/eas-cli/releases/tag/v16.13.3) - 2025-07-01
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix suggested command to switch Xcode Developer Tools. ([#3079](https://github.com/expo/eas-cli/pull/3079) by [@rolfb](https://github.com/rolfb))
|
||||
- Skip configuring build profiles when running `eas update:configure` if no build profiles exist. ([#3084](https://github.com/expo/eas-cli/pull/3084) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Reword how paid plans are described ([#3082](https://github.com/expo/eas-cli/pull/3082) by [@ide](https://github.com/ide))
|
||||
|
||||
## [16.13.2](https://github.com/expo/eas-cli/releases/tag/v16.13.2) - 2025-06-27
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Amend update override fields. ([#3080](https://github.com/expo/eas-cli/pull/3080) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.13.1](https://github.com/expo/eas-cli/releases/tag/v16.13.1) - 2025-06-25
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix bug in log during update:republish. ([#3067](https://github.com/expo/eas-cli/pull/3067) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix update group deletion. ([#3069](https://github.com/expo/eas-cli/pull/3069) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.13.0](https://github.com/expo/eas-cli/releases/tag/v16.13.0) - 2025-06-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--what-to-test` option to `build` command. ([#3065](https://github.com/expo/eas-cli/pull/3065) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Hide `workflow:list`. ([#3060](https://github.com/expo/eas-cli/pull/3060) by [@douglowder](https://github.com/douglowder))
|
||||
- Align text of messages from `build` and `workflow:run`. ([#3061](https://github.com/expo/eas-cli/pull/3061) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.12.0](https://github.com/expo/eas-cli/releases/tag/v16.12.0) - 2025-06-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--what-to-test` option to the `submit` command. ([#3023](https://github.com/expo/eas-cli/pull/3023) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed monorepo support in `workflow:run` if a project is not connected to a GitHub repository. ([#3058](https://github.com/expo/eas-cli/pull/3058) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.10.1](https://github.com/expo/eas-cli/releases/tag/v16.10.1) - 2025-06-13
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed `eas build:dev` command to correctly use passed build profile. ([#3053](https://github.com/expo/eas-cli/pull/3053) by [@sebryu](https://github.com/sebryu))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Allow rolling out update on empty branch. ([#3050](https://github.com/expo/eas-cli/pull/3050) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.10.0](https://github.com/expo/eas-cli/releases/tag/v16.10.0) - 2025-06-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add workflow:cancel. ([#3048](https://github.com/expo/eas-cli/pull/3048) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Removed repack support. ([#3044](https://github.com/expo/eas-cli/pull/3044) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [16.9.0](https://github.com/expo/eas-cli/releases/tag/v16.9.0) - 2025-06-06
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `groups` option to the `submit` command. ([#2891](https://github.com/expo/eas-cli/pull/2891) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Ensure workflow:runs returns most recent runs. ([#3045](https://github.com/expo/eas-cli/pull/3045) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.8.0](https://github.com/expo/eas-cli/releases/tag/v16.8.0) - 2025-06-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add workflow:list and workflow:runs. ([#3030](https://github.com/expo/eas-cli/pull/3030) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Change location of EAS Update host override config. ([#3042](https://github.com/expo/eas-cli/pull/3042) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.7.2](https://github.com/expo/eas-cli/releases/tag/v16.7.2) - 2025-06-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Add support for array `flavorDimensions` (`flavorDimensions = ['version']`). ([#3043](https://github.com/expo/eas-cli/pull/3043) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.7.1](https://github.com/expo/eas-cli/releases/tag/v16.7.1) - 2025-06-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix some unit tests by updating mocks. ([#3035](https://github.com/expo/eas-cli/pull/3035) by [@douglowder](https://github.com/douglowder))
|
||||
- Fix update republishing not including environment or git info. ([#3036](https://github.com/expo/eas-cli/pull/3036) by [@wschurman](https://github.com/wschurman))
|
||||
- Do not require `eas.json` or `package.json` files when creating new workflow runs. ([#3028](https://github.com/expo/eas-cli/pull/3028) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Add `EAS_SKIP_CLI_VERSION_CHECK` allowing us to skip `eas-cli` version check (against `eas.json#version`). ([#3041](https://github.com/expo/eas-cli/pull/3041) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.7.0](https://github.com/expo/eas-cli/releases/tag/v16.7.0) - 2025-05-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for manifest host and asset host overriding for EAS Update. ([#3021](https://github.com/expo/eas-cli/pull/3021) by [@wschurman](https://github.com/wschurman))
|
||||
- Add rollout flag to update:republish. ([#3029](https://github.com/expo/eas-cli/pull/3029) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.6.2](https://github.com/expo/eas-cli/releases/tag/v16.6.2) - 2025-05-23
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Further no longer require owner field for SDK >= 53 or canary. ([#3017](https://github.com/expo/eas-cli/pull/3017) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.6.1](https://github.com/expo/eas-cli/releases/tag/v16.6.1) - 2025-05-14
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Onboarding: add cwd to spawned git process. ([#3015](https://github.com/expo/eas-cli/pull/3015) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.6.0](https://github.com/expo/eas-cli/releases/tag/v16.6.0) - 2025-05-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for uploading iOS internal distribution local builds. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.5.0](https://github.com/expo/eas-cli/releases/tag/v16.5.0) - 2025-05-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--wait`/`--no-wait` flag to `eas workflow:run`. ([#3012](https://github.com/expo/eas-cli/pull/3012) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Add `--json` flag to `eas workflow:run`. ([#3013](https://github.com/expo/eas-cli/pull/3013) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.4.2](https://github.com/expo/eas-cli/releases/tag/v16.4.2) - 2025-05-10
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update `eas-build-cache-provider` function names to use `buildCacheProvider` key. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.4.1](https://github.com/expo/eas-cli/releases/tag/v16.4.1) - 2025-05-07
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Allow non-standard build types in custom gradle command. ([#3003](https://github.com/expo/eas-cli/pull/3003) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
## [16.4.0](https://github.com/expo/eas-cli/releases/tag/v16.4.0) - 2025-05-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--verbose-logs` flag for `build` command ([#3000](https://github.com/expo/eas-cli/pull/3000) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
- Add `eas-build-cache-provider` package. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Read remote build cache provider types from `@expo/config`. ([#3005](https://github.com/expo/eas-cli/pull/3005) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.3.3](https://github.com/expo/eas-cli/releases/tag/v16.3.3) - 2025-04-24
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Releasing EAS CLI
|
||||
|
||||
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "16.3.3",
|
||||
"version": "16.19.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
"lerna": "7.4.2",
|
||||
"lerna": "8.2.3",
|
||||
"oclif": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"ts-jest": "29.1.1",
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
A build cache provider plugin for the Expo CLI
|
||||
|
||||
To use the EAS remote build provider plugin, install the `eas-build-cache-provider` package as a developer dependency:
|
||||
|
||||
```bash
|
||||
npm install --save-dev eas-build-cache-provider
|
||||
```
|
||||
|
||||
Then, update your **app.json** to include the `buildCacheProvider` property and its provider under `experiments`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
...
|
||||
"experiments": {
|
||||
"buildCacheProvider": "eas"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"version": "0.1.0",
|
||||
"version": "16.4.2",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/config": "10.0.6",
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/config": "11.0.10",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"chalk": "4.1.2",
|
||||
"figures": "3.2.0",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { getPackageJson } from '@expo/config';
|
||||
import { RunOptions, getPackageJson } from '@expo/config';
|
||||
import { SpawnResult } from '@expo/spawn-async';
|
||||
|
||||
// import from '@expo/config';
|
||||
type RunOptions = any;
|
||||
|
||||
export function isSpawnResultError(obj: any): obj is Error & SpawnResult {
|
||||
return (
|
||||
obj &&
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Platform } from '@expo/config';
|
||||
import {
|
||||
BuildCacheProviderPlugin,
|
||||
CalculateFingerprintHashProps,
|
||||
ResolveBuildCacheProps,
|
||||
UploadBuildCacheProps,
|
||||
} from '@expo/config';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
@@ -7,22 +12,12 @@ import path from 'path';
|
||||
import { isDevClientBuild, isSpawnResultError } from './helpers';
|
||||
import Log from './log';
|
||||
|
||||
// import from '@expo/config';
|
||||
type CalculateFingerprintHashProps = any;
|
||||
type RemoteBuildCachePlugin = any;
|
||||
type RunOptions = any;
|
||||
|
||||
async function resolveRemoteBuildCacheAsync({
|
||||
async function resolveBuildCacheAsync({
|
||||
projectRoot,
|
||||
platform,
|
||||
fingerprintHash,
|
||||
runOptions,
|
||||
}: {
|
||||
projectRoot: string;
|
||||
platform: Platform;
|
||||
fingerprintHash: string;
|
||||
runOptions: RunOptions;
|
||||
}): Promise<string | null> {
|
||||
}: ResolveBuildCacheProps): Promise<string | null> {
|
||||
const easJsonPath = path.join(projectRoot, 'eas.json');
|
||||
if (!(await fs.exists(easJsonPath))) {
|
||||
Log.debug('eas.json not found, skip checking for remote builds');
|
||||
@@ -60,25 +55,19 @@ async function resolveRemoteBuildCacheAsync({
|
||||
// @TODO(2025-04-11): remove this in a future release
|
||||
if (isSpawnResultError(error) && error.stderr.includes('command build:download not found')) {
|
||||
Log.warn(
|
||||
`To take advantage of remote build cache, upgrade your eas-cli installation to latest.`
|
||||
`To take advantage of EAS build cache provider, upgrade your eas-cli installation to latest.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadEASRemoteBuildCacheAsync({
|
||||
async function uploadBuildCacheAsync({
|
||||
projectRoot,
|
||||
platform,
|
||||
fingerprintHash,
|
||||
buildPath,
|
||||
}: {
|
||||
projectRoot: string;
|
||||
runOptions: RunOptions;
|
||||
platform: Platform;
|
||||
fingerprintHash: string;
|
||||
buildPath?: string;
|
||||
}): Promise<string | null> {
|
||||
}: UploadBuildCacheProps): Promise<string | null> {
|
||||
const easJsonPath = path.join(projectRoot, 'eas.json');
|
||||
if (!(await fs.exists(easJsonPath))) {
|
||||
Log.debug('eas.json not found, skip uploading builds');
|
||||
@@ -86,7 +75,7 @@ async function uploadEASRemoteBuildCacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
Log.log(chalk`{whiteBright \u203A} {bold Uploading build to remote cache}`);
|
||||
Log.log(chalk`{whiteBright \u203A} {bold Uploading build to EAS}`);
|
||||
const results = await spawnAsync(
|
||||
'npx',
|
||||
[
|
||||
@@ -138,10 +127,10 @@ async function calculateEASFingerprintHashAsync({
|
||||
return null;
|
||||
}
|
||||
|
||||
const EASRemoteBuildCacheProvider: RemoteBuildCachePlugin = {
|
||||
resolveRemoteBuildCache: resolveRemoteBuildCacheAsync,
|
||||
uploadRemoteBuildCache: uploadEASRemoteBuildCacheAsync,
|
||||
const EASBuildCacheProvider: BuildCacheProviderPlugin = {
|
||||
resolveBuildCache: resolveBuildCacheAsync,
|
||||
uploadBuildCache: uploadBuildCacheAsync,
|
||||
calculateFingerprintHash: calculateEASFingerprintHashAsync,
|
||||
};
|
||||
|
||||
export default EASRemoteBuildCacheProvider;
|
||||
export default EASBuildCacheProvider;
|
||||
+371
-93
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,7 @@
|
||||
overwrite: true
|
||||
schema: 'https://staging-api.expo.dev/graphql'
|
||||
documents:
|
||||
- 'src/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/commands/**/*.ts'
|
||||
- 'src/branch/**/*.ts'
|
||||
- 'src/channel/**/*.ts'
|
||||
- 'src/worker/**/*.ts'
|
||||
- 'src/**/!(*.d).ts'
|
||||
generates:
|
||||
src/graphql/generated.ts:
|
||||
plugins:
|
||||
|
||||
Generated
+2808
-856
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "16.3.3",
|
||||
"version": "16.19.2",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
@@ -12,11 +12,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "1.0.173",
|
||||
"@expo/eas-json": "16.3.3",
|
||||
"@expo/eas-build-job": "1.0.221",
|
||||
"@expo/eas-json": "16.19.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "1.0.117",
|
||||
"@expo/logger": "1.0.221",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.7.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "1.0.173",
|
||||
"@expo/steps": "1.0.221",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^1.26.2",
|
||||
"@oclif/plugin-autocomplete": "^2.3.10",
|
||||
@@ -38,6 +38,7 @@
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
"bplist-parser": "^0.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"dateformat": "4.6.3",
|
||||
@@ -48,7 +49,7 @@
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fast-glob": "3.3.2",
|
||||
"figures": "3.2.0",
|
||||
"form-data": "4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"fs-extra": "11.2.0",
|
||||
"getenv": "1.0.0",
|
||||
"gradle-to-js": "2.0.1",
|
||||
@@ -90,7 +91,7 @@
|
||||
"uuid": "9.0.1",
|
||||
"wrap-ansi": "7.0.0",
|
||||
"yaml": "2.6.0",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "5.0.0",
|
||||
@@ -107,7 +108,7 @@
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/jsonwebtoken": "8.5.1",
|
||||
"@types/mime": "3.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/node-fetch": "2.6.12",
|
||||
"@types/node-forge": "1.3.1",
|
||||
"@types/pngjs": "6.0.4",
|
||||
"@types/promise-retry": "1.1.3",
|
||||
@@ -118,7 +119,7 @@
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"axios": "0.28.1",
|
||||
"axios": "1.11.0",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"express": "4.20.0",
|
||||
"memfs": "3.4.13",
|
||||
@@ -233,6 +234,7 @@
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log"
|
||||
},
|
||||
"volta": {
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function cleanup()
|
||||
{
|
||||
echo 'Cleaning up...'
|
||||
rm -f graphql.schema.json.bak src/graphql/generated.ts.bak
|
||||
}
|
||||
|
||||
# Fail if anything errors
|
||||
set -eox pipefail
|
||||
# If this script exits, trap it first and clean up
|
||||
trap cleanup EXIT
|
||||
|
||||
cp graphql.schema.json graphql.schema.json.bak
|
||||
cp src/graphql/generated.ts src/graphql/generated.ts.bak
|
||||
|
||||
yarn generate-graphql-code
|
||||
if cmp -s graphql.schema.json graphql.schema.json.bak; then
|
||||
echo "GraphQL schema is up-to-date"
|
||||
if cmp -s src/graphql/generated.ts src/graphql/generated.ts.bak; then
|
||||
echo "GraphQL generated code is up-to-date"
|
||||
exit 0
|
||||
else
|
||||
echo "GraphQL code has changed but has not been regenerated. Run `yarn generate-graphql-code` and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "GraphQL schema has changed on the server."
|
||||
echo "Building eas-json"
|
||||
cd ../eas-json
|
||||
yarn build
|
||||
cd ../eas-cli
|
||||
echo "Running `yarn tsc` to check for breaking changes..."
|
||||
yarn tsc
|
||||
# If there are no errors, then the schema is backwards compatible
|
||||
exit 0
|
||||
fi
|
||||
@@ -7,7 +7,16 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { AppFragment, Role } from '../../graphql/generated';
|
||||
import {
|
||||
AppFragment,
|
||||
Role,
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
|
||||
export function getMockEasJson(): EasJson {
|
||||
return {
|
||||
@@ -81,7 +90,9 @@ export function mockCommandContext<
|
||||
if (contextKey === 'loggedIn') {
|
||||
result.loggedIn = {};
|
||||
}
|
||||
|
||||
if (contextKey === 'projectId') {
|
||||
result.projectId = overrides.projectId ?? mockProjectId;
|
||||
}
|
||||
if (contextKey === 'projectDir') {
|
||||
result.projectDir = projectDir;
|
||||
}
|
||||
@@ -168,3 +179,130 @@ export function withLocalVersionSource(easJson: EasJson): EasJson {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
|
||||
return getMockWorkflowRunsFragment();
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunsFragment(
|
||||
params?:
|
||||
| undefined
|
||||
| {
|
||||
successes?: number;
|
||||
failures?: number;
|
||||
pending?: number;
|
||||
withJobs?: number;
|
||||
}
|
||||
): WorkflowRunFragment[] {
|
||||
const { successes = 0, failures = 0, pending = 0, withJobs = 0 } = params ?? {};
|
||||
const runs: any[] = [];
|
||||
for (let i = 0; i < successes; i++) {
|
||||
runs.push({
|
||||
id: `success-${i}`,
|
||||
status: WorkflowRunStatus.Success,
|
||||
errors: [],
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < failures; i++) {
|
||||
runs.push({
|
||||
id: `failure-${i}`,
|
||||
errors: ['Failed'],
|
||||
status: WorkflowRunStatus.Failure,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < pending; i++) {
|
||||
runs.push({
|
||||
id: `pending-${i}`,
|
||||
errors: [],
|
||||
status: WorkflowRunStatus.InProgress,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < withJobs; i++) {
|
||||
runs.push(getMockWorkflowRunWithJobsFragment());
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
export function getMockWorkflowJobFragment(
|
||||
runId?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
|
||||
return {
|
||||
id: 'job1',
|
||||
key: 'job1',
|
||||
name: 'job1',
|
||||
status: WorkflowJobStatus.Success,
|
||||
type: WorkflowJobType.Build,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
outputs: {},
|
||||
errors: [],
|
||||
workflowRun: {
|
||||
id: runId ?? 'build1',
|
||||
},
|
||||
turtleJobRun: {
|
||||
id: 'job1',
|
||||
logFileUrls: ['https://example.com/log1'],
|
||||
errors: [],
|
||||
artifacts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunWithJobsFragment(
|
||||
runID?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
|
||||
const id = runID ?? 'build1';
|
||||
return {
|
||||
id,
|
||||
status: WorkflowRunStatus.Failure,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
errors: [],
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
app: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
__typename: 'Account',
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [getMockWorkflowJobFragment(id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowJobFragment,
|
||||
getMockWorkflowRunWithJobsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
|
||||
import WorkflowLogView from '../../commands/workflow/logs';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
jest.mock('../../build/android/version');
|
||||
jest.mock('../../build/ios/version');
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowJobQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowRunQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('../../project/workflow');
|
||||
jest.mock('../../project/android/gradleUtils');
|
||||
jest.mock('../../project/ios/target');
|
||||
jest.mock('../../project/ios/scheme');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../commandUtils/workflow/fetchLogs');
|
||||
|
||||
describe(WorkflowLogView, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowJobFragment());
|
||||
jest
|
||||
.mocked(WorkflowRunQuery.withJobsByIdAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('view logs, passing in no parameters, no runs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(Log.error).toHaveBeenCalledWith('No workflow runs found');
|
||||
});
|
||||
test('view logs, passing in no parameters, runs found, no jobs', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ successes: 1 }));
|
||||
jest.mocked(promptAsync).mockResolvedValue({ selectedRun: 'build1' });
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(Log.error).toHaveBeenCalledWith('No job found');
|
||||
});
|
||||
test('view logs, passing in no parameters, runs found, jobs found, step selected, no logs', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||||
expect(Log.error).toHaveBeenCalledWith('No logs found');
|
||||
});
|
||||
test('view logs, passing in no parameters, runs found, jobs found, step selected, logs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, ['job1', '--all-steps'], ctx);
|
||||
await cmd.run();
|
||||
expect(WorkflowJobQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'job1', {
|
||||
useCache: false,
|
||||
});
|
||||
});
|
||||
test('view logs for a workflow job, passing in a run ID', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
|
||||
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--all-steps'], ctx);
|
||||
await cmd.run();
|
||||
expect(WorkflowRunQuery.withJobsByIdAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
'build1',
|
||||
{
|
||||
useCache: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
test('view logs for a workflow job, passing in a run ID, non-interactive', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
|
||||
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--non-interactive'], ctx);
|
||||
try {
|
||||
await cmd.run();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect((e as Error).message).toEqual('No workflow job found that matched the provided ID');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import WorkflowRunList from '../../commands/workflow/runs';
|
||||
import { WorkflowRunStatus, WorkflowRunTriggerEventType } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
jest.mock('../../build/android/version');
|
||||
jest.mock('../../build/ios/version');
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowRunQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('../../project/workflow');
|
||||
jest.mock('../../project/android/gradleUtils');
|
||||
jest.mock('../../project/ios/target');
|
||||
jest.mock('../../project/ios/scheme');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
describe(WorkflowRunList, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('list workflow runs with default params', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
|
||||
const cmd = mockTestCommand(WorkflowRunList, [], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
10
|
||||
);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
test('list workflow runs with custom limit', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
|
||||
const cmd = mockTestCommand(WorkflowRunList, ['--limit', '100'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
100
|
||||
);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
test('list workflow runs with specific filename', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'build',
|
||||
status: WorkflowRunStatus.Success,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
errors: [],
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const cmd = mockTestCommand(WorkflowRunList, ['--workflow', 'build.yml'], ctx);
|
||||
await cmd.run();
|
||||
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'build.yml',
|
||||
undefined,
|
||||
10
|
||||
);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
test('list only workflow runs with status FAILURE, get json output', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ failures: 1 }));
|
||||
const cmd = mockTestCommand(WorkflowRunList, ['--status', 'FAILURE', '--json'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
WorkflowRunStatus.Failure,
|
||||
10
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'failure-0',
|
||||
status: 'FAILURE',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
trigger: '',
|
||||
triggerType: 'Other',
|
||||
startedAt: '2022-01-01T00:00:00.000Z',
|
||||
finishedAt: '2022-01-01T00:00:00.000Z',
|
||||
workflowId: 'build',
|
||||
workflowName: 'build',
|
||||
workflowFileName: 'build.yml',
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('list only workflow runs with specific filename and status FAILURE, get json output', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'build1',
|
||||
status: WorkflowRunStatus.Failure,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
errors: [],
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const cmd = mockTestCommand(
|
||||
WorkflowRunList,
|
||||
['--workflow', 'build.yml', '--status', 'FAILURE', '--json'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'build.yml',
|
||||
WorkflowRunStatus.Failure,
|
||||
10
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'build1',
|
||||
status: 'FAILURE',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
trigger: '',
|
||||
triggerType: 'Manual',
|
||||
startedAt: '2022-01-01T00:00:00.000Z',
|
||||
finishedAt: '2022-01-01T00:00:00.000Z',
|
||||
workflowId: 'build',
|
||||
workflowName: 'build',
|
||||
workflowFileName: 'build.yml',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,11 @@ export function getExpoWebsiteBaseUrl(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getEASUpdateURL(projectId: string): string {
|
||||
export function getEASUpdateURL(projectId: string, manifestHostOverride: string | null): string {
|
||||
if (manifestHostOverride) {
|
||||
return new URL(projectId, `https://${manifestHostOverride}`).href;
|
||||
}
|
||||
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return new URL(projectId, `https://staging-u.expo.dev`).href;
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Log from '../../../log';
|
||||
import { maybeWarnAboutNonStandardBuildType } from '../build';
|
||||
|
||||
const warn = jest.spyOn(Log, 'warn');
|
||||
|
||||
describe('maybeWarnAboutNonStandardBuildType', () => {
|
||||
it('should not warn about non-standard build type', () => {
|
||||
const buildProfile = {
|
||||
gradleCommand: ':app:buildRelease',
|
||||
};
|
||||
|
||||
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'release' });
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should warn about non-standard build type', () => {
|
||||
const buildProfile = {
|
||||
gradleCommand: ':app:buildStage',
|
||||
};
|
||||
|
||||
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'stage' });
|
||||
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Android, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import getenv from 'getenv';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { transformJob } from './graphql';
|
||||
@@ -12,7 +13,7 @@ import AndroidCredentialsProvider, {
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import {
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync,
|
||||
getApplicationIdAsync,
|
||||
@@ -65,6 +66,10 @@ This means that it will most likely produce an AAB and you will not be able to i
|
||||
ctx.vcsClient
|
||||
);
|
||||
|
||||
if (gradleContext?.buildType) {
|
||||
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: gradleContext.buildType });
|
||||
}
|
||||
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
|
||||
}
|
||||
@@ -168,3 +173,38 @@ async function ensureAndroidCredentialsAsync(
|
||||
source: credentialsSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeWarnAboutNonStandardBuildType({
|
||||
buildProfile,
|
||||
buildType,
|
||||
}: {
|
||||
buildProfile: Pick<CommonContext<Platform.ANDROID>['buildProfile'], 'gradleCommand'>;
|
||||
buildType: string;
|
||||
}): void {
|
||||
const shouldSuppressWarning = getenv.boolish('EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING', false);
|
||||
|
||||
if (shouldSuppressWarning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownValidBuildTypes = ['debug', 'release'];
|
||||
if (!knownValidBuildTypes.includes(buildType)) {
|
||||
Log.warn(
|
||||
`Custom gradle command "${chalk.bold(
|
||||
buildProfile.gradleCommand
|
||||
)}" has non-standard build type: "${chalk.bold(
|
||||
buildType
|
||||
)}". Expected the command to end with "${chalk.bold('...Debug')}" or "${chalk.bold(
|
||||
'...Release'
|
||||
)}"`
|
||||
);
|
||||
Log.warn(
|
||||
`Ensure the command is spelled correctly and ${chalk.bold(
|
||||
'build.gradle'
|
||||
)} is configured correctly. To suppress this warning, set ${chalk.bold(
|
||||
'EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING=true'
|
||||
)}.`
|
||||
);
|
||||
Log.warn(learnMore('https://developer.android.com/build/build-variants#build-types'));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import slash from 'slash';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
interface JobData {
|
||||
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Android.Job> {
|
||||
const username = getUsername(ctx.exp, ctx.user);
|
||||
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
@@ -57,9 +57,7 @@ export async function prepareJobAsync(
|
||||
: undefined;
|
||||
|
||||
let buildMode;
|
||||
if (ctx.repack) {
|
||||
buildMode = BuildMode.REPACK;
|
||||
} else if (buildProfile.config) {
|
||||
if (buildProfile.config) {
|
||||
buildMode = BuildMode.CUSTOM;
|
||||
} else {
|
||||
buildMode = BuildMode.BUILD;
|
||||
@@ -114,11 +112,6 @@ export async function prepareJobAsync(
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
...(ctx.repack && {
|
||||
customBuildConfig: {
|
||||
path: '__eas/repack.yml',
|
||||
},
|
||||
}),
|
||||
loggerLevel: ctx.loggerLevel,
|
||||
// Technically, these are unused, but let's include them here for type consistency.
|
||||
// See: https://github.com/expo/eas-build/pull/454
|
||||
|
||||
@@ -500,11 +500,7 @@ async function handleSingleBuildProgressAsync(
|
||||
if (build.priority !== BuildPriority.High) {
|
||||
Log.newLine();
|
||||
Log.log('Start builds sooner in the priority queue.');
|
||||
Log.log(
|
||||
`Sign up for EAS Production or Enterprise at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}`
|
||||
);
|
||||
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface BuildContext<T extends Platform> {
|
||||
requiredPackageManager: NodePackageManager['name'] | null;
|
||||
vcsClient: Client;
|
||||
loggerLevel?: LoggerLevel;
|
||||
repack: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
@@ -42,8 +42,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel,
|
||||
freezeCredentials,
|
||||
repack,
|
||||
isVerboseLoggingEnabled,
|
||||
whatToTest,
|
||||
env,
|
||||
}: {
|
||||
buildProfileName: string;
|
||||
@@ -65,8 +65,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
repack: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
@@ -152,9 +152,9 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
developmentClient,
|
||||
requiredPackageManager,
|
||||
loggerLevel: buildLoggerLevel,
|
||||
repack,
|
||||
isVerboseLoggingEnabled,
|
||||
env,
|
||||
whatToTest,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
|
||||
@@ -14,7 +14,7 @@ import slash from 'slash';
|
||||
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
|
||||
import { IosJobSecretsInput } from '../../graphql/generated';
|
||||
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
interface JobData {
|
||||
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Ios.Job> {
|
||||
const username = getUsername(ctx.exp, ctx.user);
|
||||
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
@@ -49,9 +49,7 @@ export async function prepareJobAsync(
|
||||
: undefined;
|
||||
|
||||
let buildMode;
|
||||
if (ctx.repack) {
|
||||
buildMode = BuildMode.REPACK;
|
||||
} else if (buildProfile.config) {
|
||||
if (buildProfile.config) {
|
||||
buildMode = BuildMode.CUSTOM;
|
||||
} else {
|
||||
buildMode = BuildMode.BUILD;
|
||||
@@ -109,11 +107,6 @@ export async function prepareJobAsync(
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
...(ctx.repack && {
|
||||
customBuildConfig: {
|
||||
path: '__eas/repack.yml',
|
||||
},
|
||||
}),
|
||||
loggerLevel: ctx.loggerLevel,
|
||||
// Technically, these are unused, but let's include them here for type consistency.
|
||||
// See: https://github.com/expo/eas-build/pull/454
|
||||
|
||||
@@ -9,7 +9,10 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
|
||||
import { LocalBuildMode } from './local';
|
||||
import { BuildDistributionType } from './types';
|
||||
import Log from '../log';
|
||||
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
|
||||
import {
|
||||
getUsernameForBuildMetadataAndBuildJob,
|
||||
isExpoUpdatesInstalled,
|
||||
} from '../project/projectUtils';
|
||||
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
|
||||
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
@@ -48,7 +51,7 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
|
||||
? false
|
||||
: await ctx.vcsClient.hasUncommittedChangesAsync(),
|
||||
username: getUsername(ctx.exp, ctx.user),
|
||||
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
|
||||
message: ctx.message,
|
||||
...(ctx.platform === Platform.IOS && {
|
||||
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
|
||||
|
||||
@@ -97,8 +97,8 @@ export interface BuildFlags {
|
||||
message?: string;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
repack: boolean;
|
||||
isVerboseLoggingEnabled?: boolean;
|
||||
whatToTest?: string;
|
||||
}
|
||||
|
||||
export async function runBuildAndSubmitAsync({
|
||||
@@ -230,6 +230,7 @@ export async function runBuildAndSubmitAsync({
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
|
||||
env,
|
||||
easJsonAccessor,
|
||||
});
|
||||
if (maybeBuild) {
|
||||
startedBuilds.push({ build: maybeBuild, buildProfile });
|
||||
@@ -272,7 +273,7 @@ export async function runBuildAndSubmitAsync({
|
||||
({ platform }) => toAppPlatform(platform) === startedBuild.build.platform
|
||||
)
|
||||
).profile;
|
||||
const submission = await prepareAndStartSubmissionAsync({
|
||||
const submission = await prepareAndStartAutoSubmissionAsync({
|
||||
build: startedBuild.build,
|
||||
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
|
||||
moreBuilds: startedBuilds.length > 1,
|
||||
@@ -367,6 +368,7 @@ async function prepareAndStartBuildAsync({
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
env,
|
||||
easJsonAccessor,
|
||||
}: {
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
@@ -379,6 +381,7 @@ async function prepareAndStartBuildAsync({
|
||||
vcsClient: Client;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
env: Env;
|
||||
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
|
||||
const buildCtx = await createBuildContextAsync({
|
||||
@@ -401,8 +404,8 @@ async function prepareAndStartBuildAsync({
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
|
||||
freezeCredentials: flags.freezeCredentials,
|
||||
repack: flags.repack,
|
||||
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
|
||||
whatToTest: flags.whatToTest,
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -426,8 +429,18 @@ async function prepareAndStartBuildAsync({
|
||||
nonInteractive: flags.nonInteractive,
|
||||
buildProfile,
|
||||
env: buildProfile.profile.env,
|
||||
easJsonAccessor,
|
||||
});
|
||||
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
if (
|
||||
isUsingEASUpdate(
|
||||
buildCtx.exp,
|
||||
buildCtx.projectId,
|
||||
easJsonCliConfig.updateManifestHostOverride ?? null
|
||||
)
|
||||
) {
|
||||
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
|
||||
appId: buildCtx.projectId,
|
||||
channelName: buildProfile.profile.channel,
|
||||
@@ -492,7 +505,7 @@ async function startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragme
|
||||
return await sendBuildRequestAsync();
|
||||
}
|
||||
|
||||
async function prepareAndStartSubmissionAsync({
|
||||
async function prepareAndStartAutoSubmissionAsync({
|
||||
build,
|
||||
buildCtx,
|
||||
moreBuilds,
|
||||
@@ -527,6 +540,8 @@ async function prepareAndStartSubmissionAsync({
|
||||
vcsClient: buildCtx.vcsClient,
|
||||
isVerboseFastlaneEnabled: false,
|
||||
specifiedProfile: selectedSubmitProfileName,
|
||||
groups: undefined, // use groups from submit profile
|
||||
whatToTest: buildCtx.whatToTest,
|
||||
});
|
||||
|
||||
if (moreBuilds) {
|
||||
@@ -606,6 +621,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
nonInteractive,
|
||||
sdkVersion,
|
||||
env,
|
||||
easJsonAccessor,
|
||||
}: {
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
@@ -615,6 +631,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
nonInteractive: boolean;
|
||||
sdkVersion?: string;
|
||||
env: Env | undefined;
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
}): Promise<void> {
|
||||
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
|
||||
return;
|
||||
@@ -636,6 +653,9 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
|
||||
});
|
||||
if (installExpoUpdates) {
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp,
|
||||
projectId,
|
||||
@@ -643,6 +663,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
platform: RequestedPlatform.All,
|
||||
vcsClient,
|
||||
env,
|
||||
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
|
||||
});
|
||||
Log.withTick('Installed expo-updates and configured EAS Update.');
|
||||
throw new Error('Command must be re-run to pick up new updates configuration.');
|
||||
|
||||
@@ -35,5 +35,4 @@ export interface BuildFlags {
|
||||
message?: string;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
repack: boolean;
|
||||
}
|
||||
@@ -6,8 +6,44 @@ import {
|
||||
BuildFragment,
|
||||
BuildStatus as GraphQLBuildStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { link } from '../../log';
|
||||
import { appPlatformDisplayNames } from '../../platform';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
|
||||
|
||||
export function formatGraphQLBuildArtifacts(build: BuildFragment): FormatFieldsItem[] {
|
||||
const fields = [
|
||||
{
|
||||
label: 'Application Archive URL',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
default: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? link(url) : 'null';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Build Artifacts URL',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
default: {
|
||||
const url = build.artifacts?.buildArtifactsUrl;
|
||||
return url ? link(url) : 'null';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
return fields.filter(({ value }) => value !== undefined && value !== null) as {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
const actor = getActorName(build);
|
||||
@@ -81,10 +117,11 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: getBuildLogsUrl(build),
|
||||
value: link(getBuildLogsUrl(build)),
|
||||
},
|
||||
...formatGraphQLBuildArtifacts(build),
|
||||
{
|
||||
label: 'Artifact',
|
||||
label: 'Fingerprint',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.New:
|
||||
@@ -96,8 +133,8 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return null;
|
||||
case GraphQLBuildStatus.Finished: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? url : chalk.red('not found');
|
||||
const hash = build.fingerprint?.hash;
|
||||
return hash ? chalk.green(hash) : chalk.red('not found');
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -43,14 +43,14 @@ const errorCodeToErrorMessageOverride: Record<string, (build: BuildFragment) =>
|
||||
|
||||
export function printLogsUrls(builds: BuildFragment[]): void {
|
||||
if (builds.length === 1) {
|
||||
Log.log(`Build details: ${link(getBuildLogsUrl(builds[0]))}`);
|
||||
Log.log(`See logs: ${link(getBuildLogsUrl(builds[0]))}`);
|
||||
} else {
|
||||
builds.forEach(build => {
|
||||
const logsUrl = getBuildLogsUrl(build);
|
||||
Log.log(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
`See ${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build details: ${link(logsUrl)}`
|
||||
} logs: ${link(logsUrl)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
@@ -43,6 +44,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
@@ -78,6 +80,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
@@ -103,6 +106,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
|
||||
+56
-1
@@ -1,6 +1,10 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { easCliVersion } from '../../../../utils/easCli';
|
||||
import {
|
||||
findProjectDirAndVerifyProjectSetupAsync,
|
||||
findProjectRootAsync,
|
||||
} from '../findProjectDirAndVerifyProjectSetupAsync';
|
||||
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('fs');
|
||||
@@ -60,3 +64,54 @@ describe(findProjectRootAsync, () => {
|
||||
expect(projectRoot).toBe('/app');
|
||||
});
|
||||
});
|
||||
|
||||
describe(findProjectDirAndVerifyProjectSetupAsync, () => {
|
||||
it('throws if the CLI version is not satisfied', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({ cli: { version: '1.0.0' } }),
|
||||
'./package.json': JSON.stringify({}),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
|
||||
's'
|
||||
)
|
||||
);
|
||||
|
||||
process.env.EAS_SKIP_CLI_VERSION_CHECK = 'true';
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
delete process.env.EAS_SKIP_CLI_VERSION_CHECK;
|
||||
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
|
||||
's'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if the CLI version is satisfied', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({ cli: { version: '>= 1.0.0' } }),
|
||||
'./package.json': JSON.stringify({}),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw if there is no version', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({}),
|
||||
'./package.json': JSON.stringify({}),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
+15
-3
@@ -2,6 +2,7 @@ import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import getenv from 'getenv';
|
||||
import path from 'path';
|
||||
import pkgDir from 'pkg-dir';
|
||||
import semver from 'semver';
|
||||
@@ -13,7 +14,14 @@ import { resolveVcsClient } from '../../../vcs';
|
||||
async function applyCliConfigAsync(projectDir: string): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
|
||||
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
|
||||
|
||||
const shouldSkipVersionCheck = getenv.boolish('EAS_SKIP_CLI_VERSION_CHECK', false);
|
||||
|
||||
if (
|
||||
!shouldSkipVersionCheck &&
|
||||
config?.version &&
|
||||
!semver.satisfies(easCliVersion, config.version)
|
||||
) {
|
||||
throw new Error(
|
||||
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json (${
|
||||
config.version
|
||||
@@ -112,8 +120,12 @@ let ranEnsureEasCliIsNotInDependencies = false;
|
||||
*
|
||||
* @deprecated Should not be used outside of context functions.
|
||||
*/
|
||||
export async function findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
|
||||
const projectDir = await findProjectRootAsync();
|
||||
export async function findProjectDirAndVerifyProjectSetupAsync({
|
||||
cwd,
|
||||
}: {
|
||||
cwd?: string;
|
||||
} = {}): Promise<string> {
|
||||
const projectDir = await findProjectRootAsync({ cwd });
|
||||
await applyCliConfigAsync(projectDir);
|
||||
if (!ranEnsureEasCliIsNotInDependencies) {
|
||||
ranEnsureEasCliIsNotInDependencies = true;
|
||||
|
||||
@@ -180,7 +180,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
|
||||
Log.warn('EAS project not configured.');
|
||||
|
||||
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
|
||||
const getDefaultAccountNameForEASProject = (exp: ExpoConfig, user: Actor): string => {
|
||||
if (exp.owner) {
|
||||
return exp.owner;
|
||||
}
|
||||
@@ -191,7 +191,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
return user.username;
|
||||
case 'Robot':
|
||||
throw new Error(
|
||||
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -199,7 +199,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
accountName: getAccountNameForEASProjectSync(exp, actor),
|
||||
accountName: getDefaultAccountNameForEASProject(exp, actor),
|
||||
projectName: exp.slug,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WorkflowJobResult } from './types';
|
||||
|
||||
// This function is in a separate module for testing purposes
|
||||
|
||||
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
|
||||
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Choice } from 'prompts';
|
||||
|
||||
import { WorkflowJobResult, WorkflowLogs } from './types';
|
||||
import {
|
||||
choiceFromWorkflowJob,
|
||||
choiceFromWorkflowRun,
|
||||
choicesFromWorkflowLogs,
|
||||
processLogsFromJobAsync,
|
||||
processWorkflowRuns,
|
||||
} from './utils';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
/*
|
||||
* State machine types and functions for moving between different workflow command states
|
||||
*/
|
||||
|
||||
export enum WorkflowCommandSelectionStateValue {
|
||||
WORKFLOW_RUN_SELECTION = 'WORKFLOW_RUN_SELECTION',
|
||||
WORKFLOW_JOB_SELECTION = 'WORKFLOW_JOB_SELECTION',
|
||||
WORKFLOW_STEP_SELECTION = 'WORKFLOW_STEP_SELECTION',
|
||||
FINISH = 'FINISH',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
const workflowCommandSelectionAllowedStateTransitions: {
|
||||
[key in WorkflowCommandSelectionStateValue]: WorkflowCommandSelectionStateValue[];
|
||||
} = {
|
||||
WORKFLOW_JOB_SELECTION: [
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
WorkflowCommandSelectionStateValue.FINISH,
|
||||
],
|
||||
WORKFLOW_RUN_SELECTION: [
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
],
|
||||
WORKFLOW_STEP_SELECTION: [
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
WorkflowCommandSelectionStateValue.FINISH,
|
||||
],
|
||||
ERROR: [WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION],
|
||||
FINISH: [],
|
||||
};
|
||||
|
||||
export type WorkflowCommandSelectionState = {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
nonInteractive: boolean;
|
||||
allSteps: boolean;
|
||||
state: WorkflowCommandSelectionStateValue;
|
||||
runId?: string;
|
||||
jobId?: string;
|
||||
step?: string;
|
||||
job?: WorkflowJobResult;
|
||||
logs?: WorkflowLogs | null;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type WorkflowCommandSelectionStateParameters = Omit<
|
||||
WorkflowCommandSelectionState,
|
||||
'graphqlClient' | 'projectId' | 'state' | 'nonInteractive' | 'allSteps'
|
||||
>;
|
||||
|
||||
export type WorkflowCommandSelectionAction = (
|
||||
prevState: WorkflowCommandSelectionState
|
||||
) => Promise<WorkflowCommandSelectionState>;
|
||||
|
||||
export function moveToNewWorkflowCommandSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
newStateValue: WorkflowCommandSelectionStateValue,
|
||||
parameters: WorkflowCommandSelectionStateParameters
|
||||
): WorkflowCommandSelectionState {
|
||||
if (
|
||||
!workflowCommandSelectionAllowedStateTransitions[previousState.state].includes(newStateValue)
|
||||
) {
|
||||
const errorMessage = `Invalid state transition from ${
|
||||
previousState.state
|
||||
} to ${newStateValue}. Allowed transitions from ${
|
||||
previousState.state
|
||||
}: ${workflowCommandSelectionAllowedStateTransitions[previousState.state].join(', ')}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return {
|
||||
...previousState,
|
||||
state: newStateValue,
|
||||
...parameters,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveToWorkflowRunSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params?: {
|
||||
runId?: string | undefined;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
|
||||
{
|
||||
runId: params?.runId,
|
||||
jobId: undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowJobSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
jobId?: string | undefined;
|
||||
runId?: string | undefined;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowStepSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
job: WorkflowJobResult;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowSelectionFinishedState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
step: string;
|
||||
logs: WorkflowLogs;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.FINISH,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowSelectionErrorState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
message: string
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
{
|
||||
message,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
export const workflowRunSelectionAction: WorkflowCommandSelectionAction = async prevState => {
|
||||
const { graphqlClient, projectId, runId, jobId, allSteps } = prevState;
|
||||
Log.debug(
|
||||
`workflowRunSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
|
||||
);
|
||||
|
||||
if (runId) {
|
||||
return moveToWorkflowJobSelectionState(prevState, { runId });
|
||||
}
|
||||
const runs = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
if (runs.length === 0) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No workflow runs found');
|
||||
}
|
||||
const processedRuns = processWorkflowRuns(runs);
|
||||
const choices = processedRuns.map(run => choiceFromWorkflowRun(run));
|
||||
const selectedId = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedRun',
|
||||
message: 'Select a workflow run:',
|
||||
choices,
|
||||
})
|
||||
).selectedRun;
|
||||
return moveToWorkflowJobSelectionState(prevState, { runId: selectedId });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
export const workflowJobSelectionAction: WorkflowCommandSelectionAction = async prevState => {
|
||||
const { graphqlClient, runId, jobId, nonInteractive, allSteps } = prevState;
|
||||
Log.debug(
|
||||
`workflowJobSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
|
||||
);
|
||||
if (jobId) {
|
||||
let workflowJobResult = undefined;
|
||||
try {
|
||||
workflowJobResult = await WorkflowJobQuery.byIdAsync(graphqlClient, jobId, {
|
||||
useCache: false,
|
||||
});
|
||||
return moveToWorkflowStepSelectionState(prevState, { job: workflowJobResult });
|
||||
} catch {}
|
||||
if (nonInteractive && !workflowJobResult) {
|
||||
return moveToWorkflowSelectionErrorState(
|
||||
prevState,
|
||||
'No workflow job found that matched the provided ID'
|
||||
);
|
||||
} else {
|
||||
// The passed in ID may be a run ID, pass it back to run selection action
|
||||
return moveToWorkflowRunSelectionState(prevState, { runId: jobId });
|
||||
}
|
||||
} else {
|
||||
// No job ID was passed in
|
||||
if (nonInteractive) {
|
||||
return moveToWorkflowSelectionErrorState(
|
||||
prevState,
|
||||
'No workflow job ID provided in non-interactive mode'
|
||||
);
|
||||
} else if (!runId) {
|
||||
// If no jobId or runId, we should go back to run selection
|
||||
return moveToWorkflowRunSelectionState(prevState);
|
||||
}
|
||||
const workflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, runId, {
|
||||
useCache: false,
|
||||
});
|
||||
if (!workflowRunResult) {
|
||||
return moveToWorkflowSelectionErrorState(
|
||||
prevState,
|
||||
'No workflow run found that matched the provided ID'
|
||||
);
|
||||
}
|
||||
const choices: Choice[] = [
|
||||
...workflowRunResult.jobs.map((job, i) => choiceFromWorkflowJob(job, i)),
|
||||
{
|
||||
title: 'Go back and select a different workflow run',
|
||||
value: -1,
|
||||
},
|
||||
];
|
||||
const selectedJobIndex = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedJob',
|
||||
message: 'Select a job:',
|
||||
choices,
|
||||
})
|
||||
).selectedJob;
|
||||
if (selectedJobIndex === -1) {
|
||||
return moveToWorkflowRunSelectionState(prevState);
|
||||
}
|
||||
const selectedJob = workflowRunResult.jobs[selectedJobIndex] as WorkflowJobResult;
|
||||
return moveToWorkflowStepSelectionState(prevState, { job: selectedJob });
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async prevState => {
|
||||
const { job, allSteps } = prevState;
|
||||
Log.debug(`workflowStepSelectionAction: job = ${job?.id}, allSteps = ${allSteps}`);
|
||||
if (!job) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
|
||||
}
|
||||
const logs = await processLogsFromJobAsync(job);
|
||||
if (!logs) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
|
||||
}
|
||||
if (allSteps) {
|
||||
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
|
||||
}
|
||||
const choices: Choice[] = [
|
||||
...choicesFromWorkflowLogs(logs),
|
||||
{
|
||||
title: 'Go back and select a different workflow job',
|
||||
value: 'go-back',
|
||||
},
|
||||
];
|
||||
const selectedStep: string =
|
||||
(
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedStep',
|
||||
message: 'Select a step:',
|
||||
choices,
|
||||
})
|
||||
).selectedStep ?? '';
|
||||
if (selectedStep === 'go-back') {
|
||||
return moveToWorkflowJobSelectionState(prevState, {
|
||||
runId: job.workflowRun.id,
|
||||
jobId: undefined,
|
||||
});
|
||||
}
|
||||
return moveToWorkflowSelectionFinishedState(prevState, { step: selectedStep, logs });
|
||||
};
|
||||
|
||||
export const executeWorkflowSelectionActionsAsync: WorkflowCommandSelectionAction =
|
||||
async prevState => {
|
||||
let currentState = prevState;
|
||||
while (
|
||||
currentState.state !== WorkflowCommandSelectionStateValue.FINISH &&
|
||||
currentState.state !== WorkflowCommandSelectionStateValue.ERROR
|
||||
) {
|
||||
Log.debug(`${currentState.state}`);
|
||||
switch (currentState.state) {
|
||||
case WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION:
|
||||
currentState = await workflowJobSelectionAction(currentState);
|
||||
break;
|
||||
case WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION:
|
||||
currentState = await workflowRunSelectionAction(currentState);
|
||||
break;
|
||||
case WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION:
|
||||
currentState = await workflowStepSelectionAction(currentState);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return currentState;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
|
||||
|
||||
/*
|
||||
* Utility types for workflow commands
|
||||
*/
|
||||
|
||||
export enum WorkflowTriggerType {
|
||||
MANUAL = 'Manual',
|
||||
GITHUB = 'GitHub',
|
||||
SCHEDULED = 'Scheduled',
|
||||
OTHER = 'Other',
|
||||
}
|
||||
|
||||
export type WorkflowRunResult = {
|
||||
id: string;
|
||||
status: string;
|
||||
gitCommitMessage: string | null;
|
||||
gitCommitHash: string | null;
|
||||
triggerType: WorkflowTriggerType;
|
||||
trigger: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
workflowId: string;
|
||||
workflowName: string | null;
|
||||
workflowFileName: string;
|
||||
};
|
||||
|
||||
export type WorkflowResult = {
|
||||
id: string;
|
||||
name?: string | null | undefined;
|
||||
fileName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowJobResult =
|
||||
| WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number]
|
||||
| WorkflowJobByIdQuery['workflowJobs']['byId'];
|
||||
|
||||
export type WorkflowRunWithJobsResult = WorkflowRunResult & {
|
||||
jobs: WorkflowJobResult[];
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
|
||||
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { fetchRawLogsForJobAsync } from './fetchLogs';
|
||||
import {
|
||||
WorkflowJobResult,
|
||||
WorkflowLogLine,
|
||||
WorkflowLogs,
|
||||
WorkflowRunResult,
|
||||
WorkflowTriggerType,
|
||||
} from './types';
|
||||
import {
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { Choice } from '../../prompts';
|
||||
|
||||
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
|
||||
triggerType: WorkflowTriggerType;
|
||||
trigger: string | null;
|
||||
} {
|
||||
let triggerType = WorkflowTriggerType.OTHER;
|
||||
let trigger = '';
|
||||
if (run.actor?.__typename === 'Robot') {
|
||||
if (run.actor.firstName?.startsWith('GitHub App · ')) {
|
||||
trigger = `${run.requestedGitRef ?? ''}@${run.gitCommitHash?.substring(0, 12) ?? ''}`;
|
||||
}
|
||||
} else if (run.actor?.__typename === 'User') {
|
||||
trigger = run.actor.username;
|
||||
}
|
||||
switch (run.triggerEventType) {
|
||||
case WorkflowRunTriggerEventType.Manual:
|
||||
triggerType = WorkflowTriggerType.MANUAL;
|
||||
break;
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestLabeled:
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestOpened:
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestReopened:
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestSynchronize:
|
||||
case WorkflowRunTriggerEventType.GithubPush:
|
||||
triggerType = WorkflowTriggerType.GITHUB;
|
||||
break;
|
||||
case WorkflowRunTriggerEventType.Schedule:
|
||||
triggerType = WorkflowTriggerType.SCHEDULED;
|
||||
trigger = run.triggeringSchedule ?? '';
|
||||
break;
|
||||
}
|
||||
return { triggerType, trigger };
|
||||
}
|
||||
|
||||
export function choiceFromWorkflowRun(run: WorkflowRunResult): Choice {
|
||||
const titleArray = [
|
||||
run.workflowFileName,
|
||||
run.status,
|
||||
run.startedAt,
|
||||
run.triggerType,
|
||||
run.trigger,
|
||||
];
|
||||
return {
|
||||
title: titleArray.join(' - '),
|
||||
value: run.id,
|
||||
description: `ID: ${run.id}, Message: ${run.gitCommitMessage?.split('\n')[0] ?? ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Choice {
|
||||
return {
|
||||
title: `${job.name} - ${job.status}`,
|
||||
value: index,
|
||||
description: `ID: ${job.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function choicesFromWorkflowLogs(logs: WorkflowLogs): Choice[] {
|
||||
return Array.from(logs.keys()).map(step => {
|
||||
const logLines = logs.get(step);
|
||||
const stepStatus =
|
||||
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
|
||||
return {
|
||||
title: `${step} - ${stepStatus}`,
|
||||
value: step,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
|
||||
return runs.map(run => {
|
||||
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
|
||||
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
|
||||
return {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
gitCommitMessage: run.gitCommitMessage?.split('\n')[0] ?? null,
|
||||
gitCommitHash: run.gitCommitHash ?? null,
|
||||
startedAt: run.createdAt,
|
||||
finishedAt,
|
||||
triggerType,
|
||||
trigger,
|
||||
workflowId: run.workflow.id,
|
||||
workflowName: run.workflow.name ?? null,
|
||||
workflowFileName: run.workflow.fileName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function processLogsFromJobAsync(
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
const rawLogs = await fetchRawLogsForJobAsync(job);
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
const logs: WorkflowLogs = new Map();
|
||||
const logKeys = new Set<string>();
|
||||
rawLogs.split('\n').forEach(line => {
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { result, marker } = parsedLine;
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
|
||||
const stepId = buildStepDisplayName ?? buildStepInternalId;
|
||||
if (stepId) {
|
||||
if (!logKeys.has(stepId)) {
|
||||
logKeys.add(stepId);
|
||||
logs.set(stepId, []);
|
||||
}
|
||||
logs.get(stepId)?.push({ time, msg, result, marker });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -66,8 +67,13 @@ export default class BuildConfigure extends EasCommand {
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
if (didCreateEasJson && isUsingEASUpdate(exp, projectId)) {
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
if (didCreateEasJson) {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
if (isUsingEASUpdate(exp, projectId, easJsonCliConfig.updateManifestHostOverride ?? null)) {
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
}
|
||||
}
|
||||
|
||||
// configure expo-updates
|
||||
|
||||
@@ -115,6 +115,7 @@ export default class BuildDev extends EasCommand {
|
||||
projectId,
|
||||
platform,
|
||||
fingerprint,
|
||||
profile: buildProfile.profileName,
|
||||
});
|
||||
if (builds.length !== 0) {
|
||||
const build = builds[0];
|
||||
@@ -137,6 +138,7 @@ export default class BuildDev extends EasCommand {
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
profile: buildProfile.profileName,
|
||||
});
|
||||
if (
|
||||
previousBuildsForSelectedProfile.length > 0 &&
|
||||
@@ -164,7 +166,6 @@ export default class BuildDev extends EasCommand {
|
||||
json: false,
|
||||
autoSubmit: false,
|
||||
localBuildOptions: {},
|
||||
repack: false,
|
||||
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
},
|
||||
actor,
|
||||
@@ -308,11 +309,13 @@ export default class BuildDev extends EasCommand {
|
||||
projectId,
|
||||
platform,
|
||||
fingerprint,
|
||||
profile,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
platform: Platform;
|
||||
fingerprint?: { hash: string };
|
||||
profile?: string;
|
||||
}): Promise<BuildFragment[]> {
|
||||
return await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
@@ -323,6 +326,7 @@ export default class BuildDev extends EasCommand {
|
||||
simulator: platform === Platform.IOS ? true : undefined,
|
||||
distribution: platform === Platform.ANDROID ? DistributionType.Internal : undefined,
|
||||
developmentClient: true,
|
||||
buildProfile: profile,
|
||||
},
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
|
||||
@@ -38,7 +38,7 @@ interface RawBuildFlags {
|
||||
'build-logger-level'?: LoggerLevel;
|
||||
'freeze-credentials': boolean;
|
||||
'verbose-logs'?: boolean;
|
||||
repack: boolean;
|
||||
'what-to-test'?: string;
|
||||
}
|
||||
|
||||
export default class Build extends EasCommand {
|
||||
@@ -92,6 +92,10 @@ export default class Build extends EasCommand {
|
||||
helpValue: 'PROFILE_NAME',
|
||||
exclusive: ['auto-submit'],
|
||||
}),
|
||||
'what-to-test': Flags.string({
|
||||
description:
|
||||
'Specify the "What to Test" information for the build in TestFlight (iOS-only). To be used with the `auto-submit` flag',
|
||||
}),
|
||||
'resource-class': Flags.enum({
|
||||
options: Object.values(ResourceClass),
|
||||
hidden: true,
|
||||
@@ -114,11 +118,6 @@ export default class Build extends EasCommand {
|
||||
default: false,
|
||||
description: 'Prevent the build from updating credentials in non-interactive mode',
|
||||
}),
|
||||
repack: Flags.boolean({
|
||||
default: false,
|
||||
hidden: true,
|
||||
description: 'Use the golden dev client build repack flow as it works for onboarding',
|
||||
}),
|
||||
'verbose-logs': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Use verbose logs for the build process',
|
||||
@@ -195,6 +194,13 @@ export default class Build extends EasCommand {
|
||||
if (flags.json && !nonInteractive) {
|
||||
Errors.error('--json is allowed only when building in non-interactive mode', { exit: 1 });
|
||||
}
|
||||
const autoSubmit = flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined;
|
||||
if (flags['what-to-test'] && !autoSubmit) {
|
||||
Errors.error(
|
||||
'The --what-to-test flag can only be used with the --auto-submit or --auto-submit-with-profile flag.',
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
const requestedPlatform =
|
||||
flags.platform &&
|
||||
@@ -237,14 +243,14 @@ export default class Build extends EasCommand {
|
||||
wait: flags['wait'],
|
||||
clearCache: flags['clear-cache'],
|
||||
json: flags['json'],
|
||||
autoSubmit: flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined,
|
||||
autoSubmit,
|
||||
submitProfile: flags['auto-submit-with-profile'] ?? profile,
|
||||
resourceClass: flags['resource-class'],
|
||||
message,
|
||||
buildLoggerLevel: flags['build-logger-level'],
|
||||
freezeCredentials: flags['freeze-credentials'],
|
||||
isVerboseLoggingEnabled: flags['verbose-logs'],
|
||||
repack: flags.repack,
|
||||
whatToTest: flags['what-to-test'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ export default class BuildInspect extends EasCommand {
|
||||
workingdir: tmpWorkingdir,
|
||||
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
|
||||
},
|
||||
repack: false,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
|
||||
@@ -93,7 +93,6 @@ export default class BuildInternal extends EasCommand {
|
||||
localBuildMode: LocalBuildMode.INTERNAL,
|
||||
},
|
||||
submitProfile: flags['auto-submit-with-profile'] ?? flags.profile,
|
||||
repack: false,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
|
||||
@@ -16,7 +16,6 @@ import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ChannelDelete extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'Delete a channel';
|
||||
|
||||
static override args = [
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import Log from '../../../log';
|
||||
import { Ora, ora } from '../../../ora';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import {
|
||||
deleteWorkerDeploymentAliasAsync,
|
||||
selectWorkerDeploymentAliasOnAppAsync,
|
||||
} from '../../../worker/deployment';
|
||||
|
||||
interface DeployAliasDeleteFlags {
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export default class WorkerAliasDelete extends EasCommand {
|
||||
static override description = 'Delete deployment aliases.';
|
||||
static override aliases = ['worker:alias:delete'];
|
||||
static override state = 'preview';
|
||||
|
||||
static override args = [{ name: 'ALIAS_NAME' }];
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
override async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { ALIAS_NAME: aliasNameFromArg },
|
||||
flags: rawFlags,
|
||||
} = await this.parse(WorkerAliasDelete);
|
||||
const flags = this.sanitizeFlags(rawFlags);
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkerAliasDelete, {
|
||||
nonInteractive: true,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const { projectId } = await getDynamicPrivateProjectConfigAsync();
|
||||
const aliasName = await resolveDeploymentAliasAsync(
|
||||
flags,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
aliasNameFromArg
|
||||
);
|
||||
|
||||
const isProduction = !aliasName;
|
||||
|
||||
if (!flags.nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
isProduction
|
||||
? `You are about to delete your production alias`
|
||||
: `You are about to delete alias: "${aliasName}"`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
|
||||
if (!confirmed) {
|
||||
Log.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let progress: null | Ora = null;
|
||||
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAliasAsync>> = null;
|
||||
|
||||
try {
|
||||
progress = ora(chalk`Deleting alias {bold ${aliasName ?? 'production'}}`).start();
|
||||
deleteResult = await deleteWorkerDeploymentAliasAsync({
|
||||
graphqlClient,
|
||||
appId: projectId,
|
||||
aliasName,
|
||||
});
|
||||
progress.text = chalk`Deleted alias {bold ${aliasName ?? 'production'}}`;
|
||||
} catch (error: any) {
|
||||
progress?.fail(chalk`Failed to delete alias {bold ${aliasName ?? 'production'}}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
progress?.succeed(chalk`Deleted alias {bold ${aliasName ?? 'production'}}`);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
aliasName: deleteResult.aliasName,
|
||||
id: deleteResult.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
|
||||
return {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
json: flags['json'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDeploymentAliasAsync(
|
||||
flags: DeployAliasDeleteFlags,
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
aliasNameFromArg?: string
|
||||
): Promise<string> {
|
||||
if (aliasNameFromArg?.trim()) {
|
||||
return aliasNameFromArg.trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (flags.nonInteractive) {
|
||||
throw new Error('Alias name must be provided in non-interactive mode');
|
||||
}
|
||||
|
||||
const alias = await selectWorkerDeploymentAliasOnAppAsync({
|
||||
graphqlClient,
|
||||
appId: projectId,
|
||||
selectTitle: 'alias to delete',
|
||||
});
|
||||
|
||||
if (!alias) {
|
||||
throw new Error('No aliases found for this project, create aliases with "eas deploy:alias"');
|
||||
}
|
||||
|
||||
return alias.aliasName;
|
||||
}
|
||||
+13
-10
@@ -1,20 +1,23 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { WorkerDeploymentAliasFragment } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { Ora, ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import { WorkerDeploymentAliasFragment } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { Ora, ora } from '../../../ora';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import {
|
||||
assignWorkerDeploymentAliasAsync,
|
||||
assignWorkerDeploymentProductionAsync,
|
||||
selectWorkerDeploymentOnAppAsync,
|
||||
} from '../../worker/deployment';
|
||||
import { formatWorkerDeploymentJson, formatWorkerDeploymentTable } from '../../worker/utils/logs';
|
||||
} from '../../../worker/deployment';
|
||||
import {
|
||||
formatWorkerDeploymentJson,
|
||||
formatWorkerDeploymentTable,
|
||||
} from '../../../worker/utils/logs';
|
||||
|
||||
interface DeployAliasFlags {
|
||||
nonInteractive: boolean;
|
||||
@@ -0,0 +1,114 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { Ora, ora } from '../../ora';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { deleteWorkerDeploymentAsync } from '../../worker/deployment';
|
||||
|
||||
interface DeployDeleteFlags {
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface RawDeployDeleteFlags {
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export default class WorkerDelete extends EasCommand {
|
||||
static override description = 'Delete a deployment.';
|
||||
static override aliases = ['worker:delete'];
|
||||
static override state = 'preview';
|
||||
|
||||
static override args = [{ name: 'DEPLOYMENT_ID' }];
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
override async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { DEPLOYMENT_ID: deploymentIdFromArg },
|
||||
flags: rawFlags,
|
||||
} = await this.parse(WorkerDelete);
|
||||
const flags = this.sanitizeFlags(rawFlags);
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkerDelete, {
|
||||
nonInteractive: true,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const { projectId } = await getDynamicPrivateProjectConfigAsync();
|
||||
|
||||
if (!deploymentIdFromArg) {
|
||||
if (flags.nonInteractive) {
|
||||
throw new Error('Deployment ID must be provided in non-interactive mode');
|
||||
}
|
||||
throw new Error('Deployment ID is required');
|
||||
}
|
||||
|
||||
if (!flags.nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permanently delete deployment with ID: "${deploymentIdFromArg}"` +
|
||||
`\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
|
||||
if (!confirmed) {
|
||||
Log.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let progress: Ora | null = null;
|
||||
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAsync>> = null;
|
||||
|
||||
try {
|
||||
progress = ora(chalk`Deleting deployment {bold ${deploymentIdFromArg}}`).start();
|
||||
deleteResult = await deleteWorkerDeploymentAsync({
|
||||
graphqlClient,
|
||||
appId: projectId,
|
||||
deploymentIdentifier: deploymentIdFromArg,
|
||||
});
|
||||
progress.text = chalk`Deleted deployment {bold ${deploymentIdFromArg}}`;
|
||||
} catch (error: any) {
|
||||
progress?.fail(chalk`Failed to delete deployment {bold ${deploymentIdFromArg}}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
progress?.succeed(chalk`Deleted deployment {bold ${deploymentIdFromArg}}`);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
deploymentId: deleteResult.deploymentIdentifier,
|
||||
id: deleteResult.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFlags(flags: RawDeployDeleteFlags): DeployDeleteFlags {
|
||||
return {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
json: flags['json'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { createProgressTracker } from '../../utils/progress';
|
||||
import * as WorkerAssets from '../../worker/assets';
|
||||
import {
|
||||
assignWorkerDeploymentAliasAsync,
|
||||
@@ -21,9 +20,10 @@ import {
|
||||
getSignedDeploymentUrlAsync,
|
||||
} from '../../worker/deployment';
|
||||
import {
|
||||
UploadParams,
|
||||
UploadPayload,
|
||||
batchUploadAsync,
|
||||
callUploadApiAsync,
|
||||
createProgressBar,
|
||||
uploadAsync,
|
||||
} from '../../worker/upload';
|
||||
import {
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
getDeploymentUrlFromFullName,
|
||||
} from '../../worker/utils/logs';
|
||||
|
||||
const MAX_UPLOAD_SIZE = 5e8; // 500MB
|
||||
|
||||
const isDirectory = (directoryPath: string): Promise<boolean> =>
|
||||
fs.promises
|
||||
.stat(directoryPath)
|
||||
@@ -60,11 +62,16 @@ interface RawDeployFlags {
|
||||
'dry-run': boolean;
|
||||
}
|
||||
|
||||
interface UploadAssetBatchInstruction {
|
||||
sha512: string[];
|
||||
}
|
||||
|
||||
interface DeployInProgressParams {
|
||||
id: string;
|
||||
fullName: string;
|
||||
baseURL: string;
|
||||
token: string;
|
||||
upload?: UploadAssetBatchInstruction[];
|
||||
}
|
||||
|
||||
export default class WorkerDeploy extends EasCommand {
|
||||
@@ -162,14 +169,14 @@ export default class WorkerDeploy extends EasCommand {
|
||||
tarPath: string,
|
||||
uploadUrl: string
|
||||
): Promise<DeployInProgressParams> {
|
||||
const { response } = await uploadAsync({
|
||||
url: uploadUrl,
|
||||
filePath: tarPath,
|
||||
compress: false,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
const payload = { filePath: tarPath };
|
||||
const { response } = await uploadAsync(
|
||||
{
|
||||
baseURL: uploadUrl,
|
||||
method: 'POST',
|
||||
},
|
||||
});
|
||||
payload
|
||||
);
|
||||
if (response.status === 413) {
|
||||
throw new Error(
|
||||
'Upload failed! (Payload too large)\n' +
|
||||
@@ -185,73 +192,79 @@ export default class WorkerDeploy extends EasCommand {
|
||||
if (!json.success || !json.result || typeof json.result !== 'object') {
|
||||
throw new Error(json.message ? `Upload failed: ${json.message}` : 'Upload failed!');
|
||||
}
|
||||
const { id, fullName, token } = json.result;
|
||||
const { id, fullName, token, upload } = json.result;
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('Upload failed: API failed to return a deployment token');
|
||||
} else if (typeof id !== 'string') {
|
||||
throw new Error('Upload failed: API failed to return a deployment identifier');
|
||||
} else if (typeof fullName !== 'string') {
|
||||
throw new Error('Upload failed: API failed to return a script name');
|
||||
} else if (!Array.isArray(upload) && upload !== undefined) {
|
||||
throw new Error('Upload failed: API returned invalid asset upload instructions');
|
||||
}
|
||||
const baseURL = new URL('/', uploadUrl).toString();
|
||||
return { id, fullName, baseURL, token };
|
||||
return { id, fullName, baseURL, token, upload };
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAssetsAsync(
|
||||
assetMap: WorkerAssets.AssetMap,
|
||||
assetFiles: WorkerAssets.AssetFileEntry[],
|
||||
deployParams: DeployInProgressParams
|
||||
): Promise<void> {
|
||||
const uploadParams: UploadParams[] = [];
|
||||
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
|
||||
if (!assetPath) {
|
||||
return;
|
||||
const baseURL = new URL('/asset/', deployParams.baseURL);
|
||||
const uploadInit = { baseURL, method: 'POST' };
|
||||
uploadInit.baseURL.searchParams.set('token', deployParams.token);
|
||||
|
||||
const uploadPayloads: UploadPayload[] = [];
|
||||
if (deployParams.upload) {
|
||||
const assetsBySHA512 = assetFiles.reduce((map, asset) => {
|
||||
map.set(asset.sha512, asset);
|
||||
return map;
|
||||
}, new Map<string, WorkerAssets.AssetFileEntry>());
|
||||
const payloads = deployParams.upload
|
||||
.map(instruction =>
|
||||
instruction.sha512.map(sha512 => {
|
||||
const asset = assetsBySHA512.get(sha512);
|
||||
if (!asset) {
|
||||
// NOTE(@kitten): This should never happen
|
||||
throw new Error(
|
||||
`Uploading assets failed: API instructed us to upload an asset that does not exist`
|
||||
);
|
||||
}
|
||||
return asset;
|
||||
})
|
||||
)
|
||||
.filter(assets => assets && assets.length > 0)
|
||||
.map(assets => (assets.length > 1 ? { multipart: assets } : { asset: assets[0] }));
|
||||
uploadPayloads.push(...payloads);
|
||||
} else {
|
||||
// NOTE(@kitten): Legacy format which uploads assets one-by-one
|
||||
uploadPayloads.push(...assetFiles.map(asset => ({ asset })));
|
||||
}
|
||||
|
||||
for await (const asset of WorkerAssets.listAssetMapFilesAsync(assetPath, assetMap)) {
|
||||
const uploadURL = new URL(`/asset/${asset.sha512}`, deployParams.baseURL);
|
||||
uploadURL.searchParams.set('token', deployParams.token);
|
||||
uploadParams.push({ url: uploadURL.toString(), filePath: asset.path });
|
||||
}
|
||||
|
||||
const progress = {
|
||||
total: uploadParams.length,
|
||||
pending: 0,
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
};
|
||||
|
||||
const updateProgress = createProgressTracker({
|
||||
total: progress.total,
|
||||
message(ratio) {
|
||||
const percent = `${Math.floor(ratio * 100)}`;
|
||||
const details = chalk.dim(
|
||||
`(${progress.pending} Pending, ${progress.transferred} Completed, ${progress.total} Total)`
|
||||
);
|
||||
return `Uploading assets: ${percent.padStart(3)}% ${details}`;
|
||||
},
|
||||
completedMessage: 'Uploaded assets',
|
||||
});
|
||||
|
||||
const progressTotal = uploadPayloads.reduce(
|
||||
(acc, payload) => acc + ('multipart' in payload ? payload.multipart.length : 1),
|
||||
0
|
||||
);
|
||||
const progressTracker = createProgressBar(`Uploading ${progressTotal} assets`);
|
||||
try {
|
||||
for await (const signal of batchUploadAsync(uploadParams)) {
|
||||
if ('response' in signal) {
|
||||
progress.pending--;
|
||||
progress.percent = ++progress.transferred / progress.total;
|
||||
} else {
|
||||
progress.pending++;
|
||||
}
|
||||
updateProgress({ progress });
|
||||
for await (const signal of batchUploadAsync(
|
||||
uploadInit,
|
||||
uploadPayloads,
|
||||
progressTracker.update
|
||||
)) {
|
||||
progressTracker.update(signal.progress);
|
||||
}
|
||||
} catch (error: any) {
|
||||
updateProgress({ isComplete: true, error });
|
||||
progressTracker.stop();
|
||||
throw error;
|
||||
} finally {
|
||||
progressTracker.stop();
|
||||
}
|
||||
updateProgress({ isComplete: true });
|
||||
}
|
||||
|
||||
let assetMap: WorkerAssets.AssetMap;
|
||||
let tarPath: string;
|
||||
let assetFiles: WorkerAssets.AssetFileEntry[];
|
||||
let deployResult: DeployInProgressParams;
|
||||
let progress = ora('Preparing project').start();
|
||||
|
||||
@@ -271,12 +284,13 @@ export default class WorkerDeploy extends EasCommand {
|
||||
manifestResult.conflictingVariableNames.join(' ')
|
||||
);
|
||||
}
|
||||
assetMap = await WorkerAssets.createAssetMapAsync(
|
||||
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path
|
||||
assetFiles = await WorkerAssets.collectAssetsAsync(
|
||||
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path,
|
||||
{ maxFileSize: MAX_UPLOAD_SIZE }
|
||||
);
|
||||
tarPath = await WorkerAssets.packFilesIterableAsync(
|
||||
emitWorkerTarballAsync({
|
||||
assetMap,
|
||||
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
|
||||
manifest: manifestResult.manifest,
|
||||
})
|
||||
);
|
||||
@@ -334,7 +348,7 @@ export default class WorkerDeploy extends EasCommand {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await uploadAssetsAsync(assetMap, deployResult);
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
await finalizeDeployAsync(deployResult);
|
||||
|
||||
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
|
||||
|
||||
@@ -289,7 +289,7 @@ describe(EnvCreate, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an account-wide variable and links it', async () => {
|
||||
it('creates an account-wide variable', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
@@ -302,7 +302,6 @@ describe(EnvCreate, () => {
|
||||
'development',
|
||||
'--scope',
|
||||
'account',
|
||||
'--link',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -330,18 +329,6 @@ describe(EnvCreate, () => {
|
||||
},
|
||||
testAccountId
|
||||
);
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'testId',
|
||||
testProjectId,
|
||||
EnvironmentVariableEnvironment.Production
|
||||
);
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'testId',
|
||||
testProjectId,
|
||||
EnvironmentVariableEnvironment.Development
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for missing arguments', async () => {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableScope,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import EnvLink from '../link';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvLink, () => {
|
||||
const projectId = 'test-project-id';
|
||||
const variableId = '1';
|
||||
const graphqlClient = {};
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockContext = {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
};
|
||||
|
||||
const successMessage = (env: EnvironmentVariableEnvironment): string =>
|
||||
`Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold(
|
||||
'@testuser/testpp'
|
||||
)} in ${env.toLocaleLowerCase()}.`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
});
|
||||
|
||||
it('links an account-wide variable to the current project in non-interactive mode', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
|
||||
mockVariables[0]
|
||||
);
|
||||
|
||||
const command = new EnvLink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.run();
|
||||
|
||||
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: ['TEST_VARIABLE'],
|
||||
});
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId
|
||||
);
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Development)
|
||||
);
|
||||
});
|
||||
|
||||
it('links an account-wide variable to the current project to a specified environment', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
|
||||
mockVariables[0]
|
||||
);
|
||||
|
||||
const command = new EnvLink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.run();
|
||||
|
||||
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: ['TEST_VARIABLE'],
|
||||
});
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Production
|
||||
);
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Production)
|
||||
);
|
||||
});
|
||||
|
||||
it('uses --variable-environment to select the variable with ambigous name', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Preview],
|
||||
},
|
||||
{
|
||||
id: 'other-id',
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockImplementation(
|
||||
(_client, { environment }) => {
|
||||
return mockVariables.filter(v => v.environments.includes(environment));
|
||||
}
|
||||
);
|
||||
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
|
||||
mockVariables[0]
|
||||
);
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvLink(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--environment',
|
||||
'production',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'other-id',
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Production
|
||||
);
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Production)
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for variable selection when the name is ambigous', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Preview],
|
||||
},
|
||||
{
|
||||
id: 'other-id',
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
|
||||
mockVariables[0]
|
||||
);
|
||||
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
(promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments });
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvLink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Preview
|
||||
);
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Preview)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when variable name is not found', async () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
|
||||
it('uses environments from prompt to both link and unlink environments', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Preview],
|
||||
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
|
||||
mockVariables[0]
|
||||
);
|
||||
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
(promptAsync as jest.Mock).mockResolvedValue({
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
});
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvLink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Production
|
||||
);
|
||||
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Preview
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when variable name is not found', async () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableScope,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import EnvUnlink from '../unlink';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvUnlink, () => {
|
||||
const projectId = 'test-project-id';
|
||||
const variableId = '1';
|
||||
const graphqlClient = {};
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockContext = {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
};
|
||||
|
||||
const successMessage = (env: EnvironmentVariableEnvironment): string =>
|
||||
`Unlinked variable ${chalk.bold('TEST_VARIABLE')} from project ${chalk.bold(
|
||||
'@testuser/testpp'
|
||||
)} in ${env.toLocaleLowerCase()}.`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
});
|
||||
|
||||
it('unlinks an account-wide variable from the current project in non-interactive mode', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(
|
||||
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
|
||||
).mockResolvedValue(mockVariables[0]);
|
||||
|
||||
const command = new EnvUnlink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.run();
|
||||
|
||||
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: ['TEST_VARIABLE'],
|
||||
});
|
||||
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId
|
||||
);
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Development)
|
||||
);
|
||||
});
|
||||
|
||||
it('unlinks an account-wide variable from the current project in a specified environment', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
linkedEnvironments: [EnvironmentVariableEnvironment.Production],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(
|
||||
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
|
||||
).mockResolvedValue(mockVariables[0]);
|
||||
|
||||
const command = new EnvUnlink(
|
||||
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.run();
|
||||
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Production)
|
||||
);
|
||||
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: ['TEST_VARIABLE'],
|
||||
});
|
||||
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Production
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for variable selection when the name is ambigous', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Preview],
|
||||
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
|
||||
},
|
||||
{
|
||||
id: 'other-id',
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(
|
||||
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
|
||||
).mockResolvedValue(mockVariables[0]);
|
||||
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
(promptAsync as jest.Mock).mockResolvedValue({
|
||||
environments: [],
|
||||
});
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvUnlink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Preview
|
||||
);
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
successMessage(EnvironmentVariableEnvironment.Preview)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when variable name is not found', async () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
|
||||
it('uses environments from prompt to both link and unlink environments', async () => {
|
||||
const mockVariables = [
|
||||
{
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Preview],
|
||||
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
|
||||
mockVariables[0]
|
||||
);
|
||||
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
(promptAsync as jest.Mock).mockResolvedValue({
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
});
|
||||
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvUnlink([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Production
|
||||
);
|
||||
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
variableId,
|
||||
projectId,
|
||||
EnvironmentVariableEnvironment.Preview
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when variable name is not found', async () => {
|
||||
const mockVariables: never[] = [];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
|
||||
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-61
@@ -33,12 +33,11 @@ import {
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../utils/prompts';
|
||||
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
interface RawCreateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
link?: boolean;
|
||||
force?: boolean;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
@@ -50,7 +49,6 @@ interface RawCreateFlags {
|
||||
interface CreateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
link?: boolean;
|
||||
force?: boolean;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
@@ -78,10 +76,6 @@ export default class EnvCreate extends EasCommand {
|
||||
value: Flags.string({
|
||||
description: 'Text value or the variable',
|
||||
}),
|
||||
link: Flags.boolean({
|
||||
description: 'Link account-wide variable to the current project',
|
||||
hidden: true, // every account-wide variable is global for now so it's not user facing
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description: 'Overwrite existing variable',
|
||||
default: false,
|
||||
@@ -114,7 +108,6 @@ export default class EnvCreate extends EasCommand {
|
||||
'non-interactive': nonInteractive,
|
||||
environment: environments,
|
||||
visibility,
|
||||
link,
|
||||
force,
|
||||
type,
|
||||
fileName,
|
||||
@@ -154,29 +147,6 @@ export default class EnvCreate extends EasCommand {
|
||||
});
|
||||
overwrite = true;
|
||||
}
|
||||
if (existingVariable.scope === EnvironmentVariableScope.Shared) {
|
||||
await this.promptForOverwriteAsync({
|
||||
nonInteractive,
|
||||
force,
|
||||
message: `Account-wide variable with ${name} name already exists on this account.`,
|
||||
suggestion: 'Do you want to unlink it first?',
|
||||
});
|
||||
|
||||
Log.withTick(
|
||||
`Unlinking account-wide variable ${chalk.bold(name)} on project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)}.`
|
||||
);
|
||||
|
||||
await performForEnvironmentsAsync(environments, async environment => {
|
||||
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
existingVariable.id,
|
||||
projectId,
|
||||
environment
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const variable =
|
||||
@@ -265,29 +235,6 @@ export default class EnvCreate extends EasCommand {
|
||||
Log.withTick(
|
||||
`Created a new variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
|
||||
);
|
||||
|
||||
if (link) {
|
||||
Log.withTick(
|
||||
`Linking account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)}.`
|
||||
);
|
||||
|
||||
await performForEnvironmentsAsync(environments, async environment => {
|
||||
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
variable.id,
|
||||
projectId,
|
||||
environment
|
||||
);
|
||||
});
|
||||
|
||||
Log.withTick(
|
||||
`Linked account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +349,6 @@ export default class EnvCreate extends EasCommand {
|
||||
value,
|
||||
environment: newEnvironments,
|
||||
visibility: newVisibility,
|
||||
link: rest.link ?? false,
|
||||
force: rest.force ?? false,
|
||||
'non-interactive': nonInteractive,
|
||||
type: newType,
|
||||
@@ -412,12 +358,6 @@ export default class EnvCreate extends EasCommand {
|
||||
}
|
||||
|
||||
private sanitizeFlags(flags: RawCreateFlags): CreateFlags {
|
||||
if (flags.scope !== 'account' && flags.link) {
|
||||
throw new Error(
|
||||
`Unexpected argument: --link can only be used when creating account-wide variables`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...flags,
|
||||
scope:
|
||||
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EASMultiEnvironmentFlag,
|
||||
EASNonInteractiveFlag,
|
||||
EasEnvironmentFlagParameters,
|
||||
} from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
type LinkFlags = {
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'non-interactive': boolean;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
};
|
||||
|
||||
export default class EnvLink extends EasCommand {
|
||||
static override description = 'link an account-wide environment variable to the current project';
|
||||
|
||||
// for now we only roll out global account-wide env variables so this should stay hidden
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
'variable-name': Flags.string({
|
||||
description: 'Name of the variable',
|
||||
}),
|
||||
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
|
||||
...EasEnvironmentFlagParameters,
|
||||
description: 'Current environment of the variable to link',
|
||||
}),
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvLink);
|
||||
|
||||
let {
|
||||
'variable-name': name,
|
||||
'variable-environment': currentEnvironment,
|
||||
'non-interactive': nonInteractive,
|
||||
environment: environments,
|
||||
} = this.validateInputs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvLink, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
environment: currentEnvironment,
|
||||
filterNames: name ? [name] : undefined,
|
||||
});
|
||||
|
||||
let selectedVariable = variables[0];
|
||||
|
||||
if (variables.length > 1) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Multiple variables found, run command with --variable-name and --variable-environment arguments.'
|
||||
);
|
||||
}
|
||||
selectedVariable = await selectAsync(
|
||||
'Select account-wide variable',
|
||||
variables.map(variable => ({
|
||||
title: formatVariableName(variable),
|
||||
value: variable,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariable) {
|
||||
throw new Error(`Account-wide variable ${name} doesn't exist`);
|
||||
}
|
||||
|
||||
let explicitSelect = false;
|
||||
if (!nonInteractive && !environments) {
|
||||
const selectedEnvironments =
|
||||
(selectedVariable.linkedEnvironments ?? []).length > 0
|
||||
? selectedVariable.linkedEnvironments
|
||||
: selectedVariable.environments;
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
selectedEnvironments: selectedEnvironments ?? [],
|
||||
});
|
||||
explicitSelect = true;
|
||||
}
|
||||
|
||||
if (!environments) {
|
||||
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
selectedVariable.id,
|
||||
projectId
|
||||
);
|
||||
Log.withTick(
|
||||
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
|
||||
try {
|
||||
if (
|
||||
selectedVariable.linkedEnvironments?.includes(environment) ===
|
||||
environments.includes(environment)
|
||||
) {
|
||||
if (!explicitSelect && environments.includes(environment)) {
|
||||
Log.withTick(
|
||||
`Variable ${chalk.bold(
|
||||
selectedVariable.name
|
||||
)} is already linked to ${environment.toLocaleLowerCase()}.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (environments.includes(environment)) {
|
||||
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
selectedVariable.id,
|
||||
projectId,
|
||||
environment
|
||||
);
|
||||
Log.withTick(
|
||||
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)} in ${environment.toLocaleLowerCase()}.`
|
||||
);
|
||||
} else if (explicitSelect) {
|
||||
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
selectedVariable.id,
|
||||
projectId,
|
||||
environment
|
||||
);
|
||||
Log.withTick(
|
||||
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)} in ${environment.toLocaleLowerCase()}.`
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Log.warn(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
private validateInputs(flags: LinkFlags, { environment }: { environment?: string }): LinkFlags {
|
||||
environment = environment?.toUpperCase();
|
||||
|
||||
if (environment && !isEnvironment(environment)) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments = flags.environment
|
||||
? flags.environment
|
||||
: environment
|
||||
? [environment as EnvironmentVariableEnvironment]
|
||||
: undefined;
|
||||
|
||||
if (flags['non-interactive']) {
|
||||
if (!flags['variable-name']) {
|
||||
throw new Error(
|
||||
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
|
||||
'--variable-name VARIABLE_NAME'
|
||||
)} flag to fix the issue`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...flags,
|
||||
environment: environments,
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -246,9 +246,9 @@ export default class EnvPush extends EasCommand {
|
||||
name,
|
||||
value,
|
||||
environments,
|
||||
visibility: name.startsWith('EXPO_SENSITIVE')
|
||||
? EnvironmentVariableVisibility.Sensitive
|
||||
: EnvironmentVariableVisibility.Public,
|
||||
visibility: name.startsWith('EXPO_PUBLIC_')
|
||||
? EnvironmentVariableVisibility.Public
|
||||
: EnvironmentVariableVisibility.Sensitive,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASMultiEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
type UnlinkFlags = {
|
||||
'variable-name'?: string;
|
||||
'non-interactive': boolean;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
};
|
||||
|
||||
export default class EnvUnlink extends EasCommand {
|
||||
static override description =
|
||||
'unlink an account-wide environment variable from the current project';
|
||||
|
||||
// for now we only roll out global account-wide env variables so this should stay hidden
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
'variable-name': Flags.string({
|
||||
description: 'Name of the variable',
|
||||
}),
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to unlink the variable from. One of 'production', 'preview', or 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvUnlink);
|
||||
|
||||
let {
|
||||
'variable-name': name,
|
||||
'non-interactive': nonInteractive,
|
||||
environment: unlinkEnvironments,
|
||||
} = this.validateInputs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvUnlink, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: name ? [name] : undefined,
|
||||
});
|
||||
|
||||
let selectedVariable = variables[0];
|
||||
|
||||
if (variables.length > 1) {
|
||||
if (nonInteractive) {
|
||||
throw new Error("Multiple variables found, please select one using '--variable-name'");
|
||||
}
|
||||
selectedVariable = await selectAsync(
|
||||
'Select account-wide variable',
|
||||
variables.map(variable => ({
|
||||
title: formatVariableName(variable),
|
||||
value: variable,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariable) {
|
||||
throw new Error(`Account-wide variable ${name} doesn't exist`);
|
||||
}
|
||||
|
||||
let explicitSelect = false;
|
||||
|
||||
if (!nonInteractive && !unlinkEnvironments) {
|
||||
const selectedEnvironments =
|
||||
(selectedVariable.linkedEnvironments ?? []).length > 0
|
||||
? selectedVariable.linkedEnvironments
|
||||
: selectedVariable.environments;
|
||||
const environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
selectedEnvironments: selectedEnvironments ?? [],
|
||||
});
|
||||
explicitSelect = true;
|
||||
unlinkEnvironments = Object.values(EnvironmentVariableEnvironment).filter(
|
||||
env => !environments.includes(env)
|
||||
);
|
||||
}
|
||||
|
||||
if (!unlinkEnvironments) {
|
||||
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
selectedVariable.id,
|
||||
projectId
|
||||
);
|
||||
Log.withTick(
|
||||
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
|
||||
if (
|
||||
selectedVariable.linkedEnvironments?.includes(environment) !==
|
||||
unlinkEnvironments.includes(environment)
|
||||
) {
|
||||
if (!explicitSelect && unlinkEnvironments.includes(environment)) {
|
||||
Log.withTick(
|
||||
`Variable ${chalk.bold(
|
||||
selectedVariable.name
|
||||
)} is already unlinked from ${environment.toLocaleLowerCase()}.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (unlinkEnvironments.includes(environment)) {
|
||||
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
selectedVariable.id,
|
||||
projectId,
|
||||
environment
|
||||
);
|
||||
Log.withTick(
|
||||
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)} in ${environment.toLocaleLowerCase()}.`
|
||||
);
|
||||
} else if (explicitSelect) {
|
||||
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
|
||||
graphqlClient,
|
||||
selectedVariable.id,
|
||||
projectId,
|
||||
environment
|
||||
);
|
||||
Log.withTick(
|
||||
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)} in ${environment.toLocaleLowerCase()}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateInputs(
|
||||
flags: UnlinkFlags,
|
||||
{ environment }: { environment?: string }
|
||||
): UnlinkFlags {
|
||||
if (flags['non-interactive']) {
|
||||
if (!flags['variable-name']) {
|
||||
throw new Error(
|
||||
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return {
|
||||
environment: [environment],
|
||||
...flags,
|
||||
};
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export default class MetadataPull extends EasCommand {
|
||||
- Update the ${chalk.bold(relativePath)} file to prepare the app information.
|
||||
- Run ${chalk.bold('eas submit')} or manually upload a new app version to the app stores.
|
||||
- Once the app is uploaded, run ${chalk.bold('eas metadata:push')} to sync the store config.
|
||||
- ${learnMore('https://docs.expo.dev/eas-metadata/introduction/')}`);
|
||||
- ${learnMore('https://docs.expo.dev/eas/metadata/')}`);
|
||||
} catch (error: any) {
|
||||
handleMetadataError(error);
|
||||
}
|
||||
|
||||
@@ -307,7 +307,6 @@ export default class Onboarding extends EasCommand {
|
||||
autoSubmit: false,
|
||||
localBuildOptions: {},
|
||||
freezeCredentials: false,
|
||||
repack: true,
|
||||
},
|
||||
actor,
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
@@ -401,7 +400,7 @@ async function configureProjectFromBareDefaultExpoTemplateAsync({
|
||||
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
|
||||
app.ownerAccount.name
|
||||
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.slug)}`;
|
||||
const updateUrl = getEASUpdateURL(app.id);
|
||||
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
|
||||
|
||||
const easBuildGitHubConfig = {
|
||||
android: {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { maybeWarnAboutEasOutagesAsync } from '../utils/statuspageService';
|
||||
interface RawCommandFlags {
|
||||
platform?: string;
|
||||
profile?: string;
|
||||
'what-to-test'?: string;
|
||||
latest?: boolean;
|
||||
id?: string;
|
||||
path?: string;
|
||||
@@ -34,16 +35,19 @@ interface RawCommandFlags {
|
||||
wait: boolean;
|
||||
'non-interactive': boolean;
|
||||
'verbose-fastlane': boolean;
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
interface CommandFlags {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
profile?: string;
|
||||
whatToTest?: string;
|
||||
archiveFlags: SubmitArchiveFlags;
|
||||
verbose: boolean;
|
||||
wait: boolean;
|
||||
nonInteractive: boolean;
|
||||
isVerboseFastlaneEnabled: boolean;
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export default class Submit extends EasCommand {
|
||||
@@ -76,6 +80,9 @@ export default class Submit extends EasCommand {
|
||||
description: 'App archive url',
|
||||
exclusive: ['latest', 'id', 'path'],
|
||||
}),
|
||||
'what-to-test': Flags.string({
|
||||
description: `Sets the "What to test" information in TestFlight (iOS only).`,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
description: 'Always print logs from EAS Submit',
|
||||
default: false,
|
||||
@@ -89,6 +96,12 @@ export default class Submit extends EasCommand {
|
||||
default: false,
|
||||
description: 'Enable verbose logging for the submission process',
|
||||
}),
|
||||
groups: Flags.string({
|
||||
description:
|
||||
'Internal TestFlight testing groups to add the build to (iOS only). Learn more: https://developer.apple.com/help/app-store-connect/test-a-beta-version/add-internal-testers',
|
||||
multiple: true,
|
||||
char: 'g',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run command in non-interactive mode',
|
||||
@@ -140,12 +153,14 @@ export default class Submit extends EasCommand {
|
||||
archiveFlags: flagsWithPlatform.archiveFlags,
|
||||
nonInteractive: flagsWithPlatform.nonInteractive,
|
||||
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
|
||||
groups: flagsWithPlatform.groups,
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
exp,
|
||||
projectId,
|
||||
vcsClient,
|
||||
whatToTest: flagsWithPlatform.whatToTest,
|
||||
specifiedProfile: flagsWithPlatform.profile,
|
||||
});
|
||||
|
||||
@@ -184,6 +199,8 @@ export default class Submit extends EasCommand {
|
||||
profile,
|
||||
'non-interactive': nonInteractive,
|
||||
'verbose-fastlane': isVerboseFastlaneEnabled,
|
||||
groups,
|
||||
'what-to-test': whatToTest,
|
||||
...archiveFlags
|
||||
} = flags;
|
||||
|
||||
@@ -204,7 +221,9 @@ export default class Submit extends EasCommand {
|
||||
wait,
|
||||
profile,
|
||||
nonInteractive,
|
||||
whatToTest,
|
||||
isVerboseFastlaneEnabled,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
AndroidSubmissionConfigInput,
|
||||
IosSubmissionConfigInput,
|
||||
SubmissionAndroidReleaseStatus,
|
||||
SubmissionAndroidTrack,
|
||||
} from '../../graphql/generated';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { GoogleServiceAccountKeyQuery } from '../../graphql/queries/GoogleServiceAccountKeyQuery';
|
||||
@@ -96,6 +95,7 @@ export default class SubmitInternal extends EasCommand {
|
||||
projectId,
|
||||
vcsClient,
|
||||
specifiedProfile: flags.profile,
|
||||
groups: undefined, // use groups from submit profile
|
||||
});
|
||||
|
||||
let config;
|
||||
@@ -114,6 +114,7 @@ export default class SubmitInternal extends EasCommand {
|
||||
const configInput: z.input<typeof SubmissionConfig.Ios.SchemaZ> = {
|
||||
ascAppIdentifier: iosConfig.ascAppIdentifier,
|
||||
isVerboseFastlaneEnabled: iosConfig.isVerboseFastlaneEnabled ?? undefined,
|
||||
groups: iosConfig.groups ?? undefined,
|
||||
...(ascApiKeyJson
|
||||
? { ascApiJsonKey: ascApiKeyJson }
|
||||
: {
|
||||
@@ -139,12 +140,11 @@ export default class SubmitInternal extends EasCommand {
|
||||
graphqlClient,
|
||||
})
|
||||
);
|
||||
const track = graphQlTrackToConfigTrack[androidConfig.track];
|
||||
|
||||
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> = {
|
||||
changesNotSentForReview,
|
||||
googleServiceAccountKeyJson,
|
||||
track,
|
||||
track: androidConfig.track,
|
||||
...(releaseStatus === SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS
|
||||
? {
|
||||
releaseStatus: SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS,
|
||||
@@ -215,10 +215,3 @@ const graphQlReleaseStatusToConfigReleaseStatus = {
|
||||
[SubmissionAndroidReleaseStatus.Completed]: SubmissionConfig.Android.ReleaseStatus.COMPLETED,
|
||||
[SubmissionAndroidReleaseStatus.Halted]: SubmissionConfig.Android.ReleaseStatus.HALTED,
|
||||
};
|
||||
|
||||
const graphQlTrackToConfigTrack = {
|
||||
[SubmissionAndroidTrack.Production]: SubmissionConfig.Android.ReleaseTrack.PRODUCTION,
|
||||
[SubmissionAndroidTrack.Beta]: SubmissionConfig.Android.ReleaseTrack.BETA,
|
||||
[SubmissionAndroidTrack.Alpha]: SubmissionConfig.Android.ReleaseTrack.ALPHA,
|
||||
[SubmissionAndroidTrack.Internal]: SubmissionConfig.Android.ReleaseTrack.INTERNAL,
|
||||
};
|
||||
@@ -35,6 +35,7 @@ const updateStub: UpdateFragment = {
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
manifestFragment: JSON.stringify({ fake: 'manifest' }),
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://expo.dev/fake/manifest/link',
|
||||
|
||||
@@ -32,6 +32,7 @@ const updateStub: UpdateFragment = {
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
manifestFragment: JSON.stringify({ fake: 'manifest' }),
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://expo.dev/fake/manifest/link',
|
||||
|
||||
@@ -32,6 +32,7 @@ const updateStub: UpdateFragment = {
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
manifestFragment: JSON.stringify({ fake: 'manifest' }),
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://expo.dev/fake/manifest/link',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -47,6 +48,10 @@ export default class UpdateConfigure extends EasCommand {
|
||||
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp,
|
||||
projectId,
|
||||
@@ -55,6 +60,7 @@ export default class UpdateConfigure extends EasCommand {
|
||||
vcsClient,
|
||||
env: undefined,
|
||||
forceNativeConfigSync: true,
|
||||
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
|
||||
});
|
||||
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
ScheduleUpdateGroupDeletionMutation,
|
||||
ScheduleUpdateGroupDeletionMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { BackgroundJobReceiptNode } from '../../graphql/types/BackgroundJobReceipt';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
async function scheduleUpdateGroupDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
group,
|
||||
}: {
|
||||
group: string;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleUpdateGroupDeletionMutation, ScheduleUpdateGroupDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleUpdateGroupDeletion($group: ID!) {
|
||||
update {
|
||||
scheduleUpdateGroupDeletion(group: $group) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ group }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return result.update.scheduleUpdateGroupDeletion;
|
||||
}
|
||||
|
||||
export default class UpdateDelete extends EasCommand {
|
||||
static override description = 'delete all the updates in an update group';
|
||||
|
||||
@@ -79,7 +42,7 @@ export default class UpdateDelete extends EasCommand {
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
const shouldAbort = await confirmAsync({
|
||||
const shouldContinue = await confirmAsync({
|
||||
message:
|
||||
`🚨${chalk.red('CAUTION')}🚨\n\n` +
|
||||
`${chalk.yellow(`This will delete all of the updates in group "${group}".`)} ${chalk.red(
|
||||
@@ -87,10 +50,10 @@ export default class UpdateDelete extends EasCommand {
|
||||
)}\n\n` +
|
||||
`If you want to revert to a previous publish, you should use 'update --republish' targeted at the last working update group instead.\n\n` +
|
||||
`An update group should only be deleted in an emergency like an accidental publish of a secret. In this case user 'update --republish' to revert to the last working update group first and then proceed with the deletion. Deleting an update group when it is the latest publish can lead to inconsistent caching behavior by clients.\n\n` +
|
||||
`Would you like to abort?`,
|
||||
`Would you like to continue?`,
|
||||
});
|
||||
|
||||
if (shouldAbort) {
|
||||
if (!shouldContinue) {
|
||||
Log.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -211,6 +212,10 @@ export default class UpdatePublish extends EasCommand {
|
||||
|
||||
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
|
||||
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp: expPossiblyWithoutEasUpdateConfigured,
|
||||
platform: requestedPlatform,
|
||||
@@ -218,6 +223,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
projectId,
|
||||
vcsClient,
|
||||
env: undefined,
|
||||
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
|
||||
});
|
||||
|
||||
const { exp } = await getDynamicPublicProjectConfigAsync();
|
||||
@@ -507,6 +513,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
isGitWorkingTreeDirty,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
environment: environment ?? null,
|
||||
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
|
||||
assetHostOverride: easJsonCliConfig.updateAssetHostOverride ?? null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { Platform } from '@expo/config';
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
|
||||
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
|
||||
import { UpdateToRepublish, republishAsync } from '../../update/republish';
|
||||
import { truncateString as truncateUpdateMessage } from '../../update/utils';
|
||||
import {
|
||||
UpdateToRepublish,
|
||||
getOrAskUpdateMessageAsync,
|
||||
getUpdateGroupOrAskForUpdateGroupAsync,
|
||||
republishAsync,
|
||||
} from '../../update/republish';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
@@ -31,6 +29,7 @@ type UpdateRepublishRawFlags = {
|
||||
'private-key-path'?: string;
|
||||
'non-interactive': boolean;
|
||||
json?: boolean;
|
||||
'rollout-percentage'?: number;
|
||||
};
|
||||
|
||||
type UpdateRepublishFlags = {
|
||||
@@ -44,6 +43,7 @@ type UpdateRepublishFlags = {
|
||||
privateKeyPath?: string;
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
rolloutPercentage?: number;
|
||||
};
|
||||
|
||||
export default class UpdateRepublish extends EasCommand {
|
||||
@@ -86,6 +86,12 @@ export default class UpdateRepublish extends EasCommand {
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
'rollout-percentage': Flags.integer({
|
||||
description: `Percentage of users this update should be immediately available to. Users not in the rollout will be served the previous latest update on the branch, even if that update is itself being rolled out. The specified number must be an integer between 1 and 100. When not specified, this defaults to 100.`,
|
||||
required: false,
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -112,7 +118,11 @@ export default class UpdateRepublish extends EasCommand {
|
||||
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
|
||||
|
||||
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
|
||||
const existingUpdates = await getUpdateGroupOrAskForUpdateGroupAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
flags
|
||||
);
|
||||
const updatesToPublish = existingUpdates.filter(update =>
|
||||
flags.platform.includes(update.platform as Platform)
|
||||
);
|
||||
@@ -130,7 +140,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (rawFlags.platform === 'all') {
|
||||
if (rawFlags.platform !== 'all') {
|
||||
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
|
||||
} else {
|
||||
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
|
||||
@@ -163,6 +173,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json: flags.json,
|
||||
rolloutPercentage: flags.rolloutPercentage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,6 +202,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
platform,
|
||||
updateMessage: rawFlags.message,
|
||||
privateKeyPath,
|
||||
rolloutPercentage: rawFlags['rollout-percentage'],
|
||||
json: rawFlags.json ?? false,
|
||||
nonInteractive,
|
||||
};
|
||||
@@ -224,177 +236,3 @@ async function getOrAskTargetBranchAsync(
|
||||
// if neither provided, assume republish on same branch
|
||||
return { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName };
|
||||
}
|
||||
|
||||
/** Retrieve the update group from either the update group id, or select from branch name. */
|
||||
async function getOrAskUpdatesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
flags: UpdateRepublishFlags
|
||||
): Promise<UpdateToRepublish[]> {
|
||||
if (flags.groupId) {
|
||||
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
|
||||
groupId: flags.groupId,
|
||||
});
|
||||
|
||||
return updateGroup.map(update => ({
|
||||
...update,
|
||||
groupId: update.group,
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
}));
|
||||
}
|
||||
|
||||
if (flags.nonInteractive) {
|
||||
throw new Error('Must supply --group when in non-interactive mode');
|
||||
}
|
||||
|
||||
if (flags.branchName) {
|
||||
return await askUpdatesFromBranchNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
branchName: flags.branchName,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
if (flags.channelName) {
|
||||
return await askUpdatesFromChannelNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
channelName: flags.channelName,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Find update by branch or channel?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Branch', value: 'branch' },
|
||||
{ title: 'Channel', value: 'channel' },
|
||||
],
|
||||
});
|
||||
|
||||
if (choice === 'channel') {
|
||||
const { name } = await selectChannelOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
selectionPromptTitle: 'Select a channel to view',
|
||||
paginatedQueryOptions: {
|
||||
json: flags.json,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return await askUpdatesFromChannelNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
channelName: name,
|
||||
projectId,
|
||||
});
|
||||
} else if (choice === 'branch') {
|
||||
const { name } = await selectBranchOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
promptTitle: 'Select branch from which to choose update',
|
||||
displayTextForListItem: updateBranch => ({
|
||||
title: updateBranch.name,
|
||||
}),
|
||||
// discard limit and offset because this query is not their intended target
|
||||
paginatedQueryOptions: {
|
||||
json: flags.json,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return await askUpdatesFromBranchNameAsync(graphqlClient, {
|
||||
...flags,
|
||||
branchName: name,
|
||||
projectId,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Must choose update via channel or branch');
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the user which update needs to be republished by branch name, this requires interactive mode */
|
||||
async function askUpdatesFromBranchNameAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
branchName,
|
||||
json,
|
||||
nonInteractive,
|
||||
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
|
||||
): Promise<UpdateToRepublish[]> {
|
||||
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName,
|
||||
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
|
||||
});
|
||||
|
||||
return updateGroup.map(update => ({
|
||||
...update,
|
||||
groupId: update.group,
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
}));
|
||||
}
|
||||
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
|
||||
async function askUpdatesFromChannelNameAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
channelName,
|
||||
json,
|
||||
nonInteractive,
|
||||
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
|
||||
): Promise<UpdateToRepublish[]> {
|
||||
const { branchName } = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
channelName
|
||||
);
|
||||
|
||||
return await askUpdatesFromBranchNameAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName,
|
||||
json,
|
||||
nonInteractive,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get or ask the user for the update (group) message for the republish */
|
||||
async function getOrAskUpdateMessageAsync(
|
||||
updates: UpdateToRepublish[],
|
||||
flags: UpdateRepublishFlags
|
||||
): Promise<string> {
|
||||
if (flags.updateMessage) {
|
||||
return sanitizeUpdateMessage(flags.updateMessage);
|
||||
}
|
||||
|
||||
if (flags.nonInteractive || flags.json) {
|
||||
throw new Error('Must supply --message when in non-interactive mode');
|
||||
}
|
||||
|
||||
// This command only uses a single update group to republish, meaning these values are always identical
|
||||
const oldGroupId = updates[0].groupId;
|
||||
const oldUpdateMessage = updates[0].message;
|
||||
|
||||
const { updateMessage } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'updateMessage',
|
||||
message: 'Provide an update message.',
|
||||
initial: `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`,
|
||||
validate: (value: any) => (value ? true : 'Update message may not be empty.'),
|
||||
});
|
||||
|
||||
return sanitizeUpdateMessage(updateMessage);
|
||||
}
|
||||
|
||||
function sanitizeUpdateMessage(updateMessage: string): string {
|
||||
if (updateMessage !== truncateUpdateMessage(updateMessage, 1024)) {
|
||||
Log.warn('Update message exceeds the allowed 1024 character limit, truncated update message.');
|
||||
return truncateUpdateMessage(updateMessage, 1024);
|
||||
}
|
||||
|
||||
return updateMessage;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Flags } from '@oclif/core';
|
||||
import assert from 'assert';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
|
||||
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
|
||||
import {
|
||||
UpdateToRepublish,
|
||||
askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync,
|
||||
getOrAskUpdateMessageAsync,
|
||||
getUpdateGroupAsync,
|
||||
republishAsync,
|
||||
} from '../../update/republish';
|
||||
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
|
||||
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
type RolloutUpdate = UpdateToRepublish & {
|
||||
rolloutPercentage: NonNullable<UpdateToRepublish['rolloutPercentage']>;
|
||||
};
|
||||
|
||||
type RolloutUpdateWithControlUpdate = RolloutUpdate & {
|
||||
rolloutControlUpdate: NonNullable<UpdateToRepublish['rolloutControlUpdate']>;
|
||||
};
|
||||
|
||||
type UpdateRevertUpdateRolloutRawFlags = {
|
||||
branch?: string;
|
||||
channel?: string;
|
||||
group?: string;
|
||||
message?: string;
|
||||
'private-key-path'?: string;
|
||||
'non-interactive': boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type UpdateRevertUpdateRolloutFlags = {
|
||||
branchName?: string;
|
||||
channelName?: string;
|
||||
groupId?: string;
|
||||
updateMessage?: string;
|
||||
privateKeyPath?: string;
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
};
|
||||
|
||||
export function nonNullish<TValue>(value: TValue | null | undefined): value is NonNullable<TValue> {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
export default class UpdateRevertUpdateRollout extends EasCommand {
|
||||
static override description = 'revert a rollout update for a project';
|
||||
|
||||
static override flags = {
|
||||
channel: Flags.string({
|
||||
description: 'Channel name to select an update group to revert the rollout update from',
|
||||
exclusive: ['branch', 'group'],
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch name to select an update group to revert the rollout update from',
|
||||
exclusive: ['channel', 'group'],
|
||||
}),
|
||||
group: Flags.string({
|
||||
description: 'Rollout update group ID to revert',
|
||||
exclusive: ['branch', 'channel'],
|
||||
}),
|
||||
message: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Short message describing the revert',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags: rawFlags } = await this.parse(UpdateRevertUpdateRollout);
|
||||
const flags = this.sanitizeFlags(rawFlags);
|
||||
|
||||
const {
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
loggedIn: { graphqlClient },
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(UpdateRevertUpdateRollout, {
|
||||
nonInteractive: flags.nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
|
||||
|
||||
let updateGroupToRepublish: RolloutUpdate[];
|
||||
if (flags.groupId) {
|
||||
const updateGroup = await getUpdateGroupAsync(graphqlClient, flags.groupId);
|
||||
if (!updateGroupIsRolloutUpdateGroup(updateGroup)) {
|
||||
throw new Error(
|
||||
`The update group with ID "${flags.groupId}" is not a rollout update group.`
|
||||
);
|
||||
}
|
||||
updateGroupToRepublish = updateGroup;
|
||||
} else {
|
||||
const latestUpdateGroupForEachPublishPlatform =
|
||||
await askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
flags
|
||||
);
|
||||
|
||||
const uniqueUpdateGroups = getUniqueUpdateGroups(
|
||||
Object.values(latestUpdateGroupForEachPublishPlatform).filter(nonNullish)
|
||||
);
|
||||
|
||||
const rolloutUpdateGroups = uniqueUpdateGroups.filter(updateGroupIsRolloutUpdateGroup);
|
||||
|
||||
if (rolloutUpdateGroups.length === 0) {
|
||||
throw new Error(`No rollout update groups found.`);
|
||||
}
|
||||
|
||||
if (rolloutUpdateGroups.length === 1) {
|
||||
updateGroupToRepublish = rolloutUpdateGroups[0];
|
||||
} else {
|
||||
const { choice: chosenId } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which rollout update group would you like to revert?',
|
||||
name: 'choice',
|
||||
choices: rolloutUpdateGroups.map(ug => ({
|
||||
title: `Rollout update group ID: ${ug[0].groupId}, Platform: ${ug[0].platform}, Rollout Percentage: ${ug[0].rolloutPercentage}`,
|
||||
value: ug[0].groupId,
|
||||
})),
|
||||
});
|
||||
|
||||
if (!chosenId) {
|
||||
throw new Error('No rollout update group selected.');
|
||||
}
|
||||
|
||||
const chosenUpdateGroup = rolloutUpdateGroups.find(ug => ug[0].groupId === chosenId);
|
||||
|
||||
if (!chosenUpdateGroup) {
|
||||
throw new Error('No rollout update group selected.');
|
||||
}
|
||||
|
||||
updateGroupToRepublish = chosenUpdateGroup;
|
||||
}
|
||||
}
|
||||
|
||||
const rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[] | null =
|
||||
updateGroupIsUpdateGroupWithControlUpdate(updateGroupToRepublish)
|
||||
? updateGroupToRepublish
|
||||
: null;
|
||||
if (rolloutUpdateGroupWithControlUpdates) {
|
||||
await this.deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId,
|
||||
rolloutUpdateGroupWithControlUpdates,
|
||||
codeSigningInfo,
|
||||
flags,
|
||||
});
|
||||
} else {
|
||||
await this.deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
|
||||
graphqlClient,
|
||||
vcsClient,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
rolloutUpdateGroup: updateGroupToRepublish,
|
||||
codeSigningInfo,
|
||||
flags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId,
|
||||
rolloutUpdateGroupWithControlUpdates,
|
||||
codeSigningInfo,
|
||||
flags,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[];
|
||||
codeSigningInfo: CodeSigningInfo | undefined;
|
||||
flags: UpdateRevertUpdateRolloutFlags;
|
||||
}): Promise<void> {
|
||||
const controlUpdateGroupIdsToRepublish = Array.from(
|
||||
new Set(rolloutUpdateGroupWithControlUpdates.map(update => update.rolloutControlUpdate.group))
|
||||
);
|
||||
|
||||
const updateGroupsToRepublish = await Promise.all(
|
||||
controlUpdateGroupIdsToRepublish.map(controlUpdateGroupIdToRepublish =>
|
||||
getUpdateGroupAsync(graphqlClient, controlUpdateGroupIdToRepublish)
|
||||
)
|
||||
);
|
||||
|
||||
const updateGroupOrGroupsClause =
|
||||
controlUpdateGroupIdsToRepublish.length > 1
|
||||
? `control update groups (IDs: ${controlUpdateGroupIdsToRepublish
|
||||
.map(id => `"${id}"`)
|
||||
.join(', ')})`
|
||||
: `control update group (ID: "${controlUpdateGroupIdsToRepublish[0]}")`;
|
||||
|
||||
if (!flags.nonInteractive) {
|
||||
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupWithControlUpdates[0].groupId}"? This will delete the rollout update group and republish the ${updateGroupOrGroupsClause}.`;
|
||||
const didConfirm = await confirmAsync({ message: confirmMessage });
|
||||
if (!didConfirm) {
|
||||
throw new Error('Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
const updateMessages: string[] = [];
|
||||
for (const updateGroup of updateGroupsToRepublish) {
|
||||
updateMessages.push(await getOrAskUpdateMessageAsync(updateGroup, flags));
|
||||
}
|
||||
|
||||
// assert all updateGroupsToRepublish have the same branch name and id
|
||||
const branchNames = updateGroupsToRepublish.flatMap(updateGroup => updateGroup[0].branchName);
|
||||
const branchIds = updateGroupsToRepublish.map(updateGroup => updateGroup[0].branchId);
|
||||
assert(
|
||||
branchNames.every(name => name === branchNames[0]),
|
||||
'All update groups being republished must belong to the same branch.'
|
||||
);
|
||||
assert(
|
||||
branchIds.every(id => id === branchIds[0]),
|
||||
'All update groups being republished must belong to the same branch.'
|
||||
);
|
||||
const targetBranch = {
|
||||
branchName: branchNames[0],
|
||||
branchId: branchIds[0],
|
||||
};
|
||||
|
||||
await this.deleteRolloutUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
rolloutUpdateGroup: rolloutUpdateGroupWithControlUpdates,
|
||||
});
|
||||
|
||||
for (let i = 0; i < updateGroupsToRepublish.length; i++) {
|
||||
const updateGroupToRepublish = updateGroupsToRepublish[i];
|
||||
const updateMessage = updateMessages[i];
|
||||
|
||||
await republishAsync({
|
||||
graphqlClient,
|
||||
app: { exp, projectId },
|
||||
updatesToPublish: updateGroupToRepublish,
|
||||
targetBranch,
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json: flags.json,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
|
||||
graphqlClient,
|
||||
vcsClient,
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
rolloutUpdateGroup,
|
||||
codeSigningInfo,
|
||||
flags,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
vcsClient: Client;
|
||||
exp: ExpoConfig;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
rolloutUpdateGroup: RolloutUpdate[];
|
||||
codeSigningInfo: CodeSigningInfo | undefined;
|
||||
flags: UpdateRevertUpdateRolloutFlags;
|
||||
}): Promise<void> {
|
||||
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
|
||||
|
||||
if (!flags.nonInteractive) {
|
||||
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupId}"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. ${learnMore(
|
||||
'https://expo.fyi/eas-update-update-rollouts',
|
||||
{ learnMoreMessage: 'More info' }
|
||||
)})`;
|
||||
const didConfirm = await confirmAsync({ message: confirmMessage });
|
||||
if (!didConfirm) {
|
||||
throw new Error('Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
// check that the expo-updates package version supports roll back to embedded
|
||||
await enforceRollBackToEmbeddedUpdateSupportAsync(projectDir);
|
||||
const updateMessage = await getUpdateMessageForCommandAsync(vcsClient, {
|
||||
updateMessageArg: flags.updateMessage,
|
||||
autoFlag: false,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
jsonFlag: flags.json,
|
||||
});
|
||||
|
||||
await this.deleteRolloutUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
rolloutUpdateGroup,
|
||||
});
|
||||
|
||||
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
|
||||
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
|
||||
const targetBranch = {
|
||||
name: rolloutUpdateGroup[0].branchName,
|
||||
id: rolloutUpdateGroup[0].branchId,
|
||||
};
|
||||
|
||||
await publishRollBackToEmbeddedUpdateAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
exp,
|
||||
updateMessage,
|
||||
branch: targetBranch,
|
||||
codeSigningInfo,
|
||||
platforms,
|
||||
runtimeVersion,
|
||||
json: flags.json,
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteRolloutUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
rolloutUpdateGroup,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
rolloutUpdateGroup: RolloutUpdate[];
|
||||
}): Promise<void> {
|
||||
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
|
||||
|
||||
const updateGroupDeletionReceipt = await scheduleUpdateGroupDeletionAsync(graphqlClient, {
|
||||
group: rolloutUpdateGroupId,
|
||||
});
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(
|
||||
graphqlClient,
|
||||
updateGroupDeletionReceipt
|
||||
);
|
||||
Log.debug('Rollout update group deletion result', { successfulReceipt });
|
||||
}
|
||||
|
||||
private sanitizeFlags(
|
||||
rawFlags: UpdateRevertUpdateRolloutRawFlags
|
||||
): UpdateRevertUpdateRolloutFlags {
|
||||
const branchName = rawFlags.branch;
|
||||
const channelName = rawFlags.channel;
|
||||
const groupId = rawFlags.group;
|
||||
const nonInteractive = rawFlags['non-interactive'];
|
||||
const privateKeyPath = rawFlags['private-key-path'];
|
||||
|
||||
if (nonInteractive && !groupId) {
|
||||
throw new Error('Only --group can be used in non-interactive mode');
|
||||
}
|
||||
|
||||
return {
|
||||
branchName,
|
||||
channelName,
|
||||
groupId,
|
||||
|
||||
updateMessage: rawFlags.message,
|
||||
privateKeyPath,
|
||||
json: rawFlags.json ?? false,
|
||||
nonInteractive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueUpdateGroups(updateGroups: UpdateToRepublish[][]): UpdateToRepublish[][] {
|
||||
const uniqueUpdateGroups = new Map<string, UpdateToRepublish[]>();
|
||||
for (const updateGroup of updateGroups) {
|
||||
const groupId = updateGroup[0].groupId;
|
||||
if (!uniqueUpdateGroups.has(groupId)) {
|
||||
uniqueUpdateGroups.set(groupId, updateGroup);
|
||||
}
|
||||
}
|
||||
return Array.from(uniqueUpdateGroups.values());
|
||||
}
|
||||
|
||||
function updateGroupIsRolloutUpdateGroup(
|
||||
updateGroup: UpdateToRepublish[]
|
||||
): updateGroup is RolloutUpdate[] {
|
||||
return updateGroup.every(updateIsRolloutUpdate);
|
||||
}
|
||||
|
||||
function updateIsRolloutUpdate(updateGroup: UpdateToRepublish): updateGroup is RolloutUpdate {
|
||||
return updateGroup.rolloutPercentage !== undefined && updateGroup.rolloutPercentage !== null;
|
||||
}
|
||||
|
||||
function updateGroupIsUpdateGroupWithControlUpdate(
|
||||
updateGroup: RolloutUpdate[]
|
||||
): updateGroup is RolloutUpdateWithControlUpdate[] {
|
||||
return updateGroup.every(updateIsRolloutWithControlUpdate);
|
||||
}
|
||||
|
||||
function updateIsRolloutWithControlUpdate(
|
||||
updateGroup: RolloutUpdate
|
||||
): updateGroup is RolloutUpdateWithControlUpdate {
|
||||
return !!updateGroup.rolloutControlUpdate;
|
||||
}
|
||||
@@ -1,50 +1,24 @@
|
||||
import { Platform as PublishPlatform } from '@expo/config';
|
||||
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { ensureBranchExistsAsync } from '../../branch/queries';
|
||||
import { getUpdateGroupUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import fetch from '../../fetch';
|
||||
import {
|
||||
PublishUpdateGroupInput,
|
||||
RuntimeFragment,
|
||||
StatuspageServiceName,
|
||||
UpdatePublishMutation,
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
enforceRollBackToEmbeddedUpdateSupportAsync,
|
||||
getOwnerAccountForProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import {
|
||||
RuntimeVersionInfo,
|
||||
UpdatePublishPlatform,
|
||||
defaultPublishPlatforms,
|
||||
getBranchNameForCommandAsync,
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
|
||||
getUpdateMessageForCommandAsync,
|
||||
} from '../../project/publish';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
|
||||
import {
|
||||
CodeSigningInfo,
|
||||
checkDirectiveBodyAgainstUpdateInfoGroup,
|
||||
getCodeSigningInfoAsync,
|
||||
getDirectiveBodyAsync,
|
||||
signBody,
|
||||
} from '../../utils/code-signing';
|
||||
import uniqBy from '../../utils/expodash/uniqBy';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
|
||||
import { selectRuntimeOnBranchAsync } from '../../update/queries';
|
||||
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
type RawUpdateFlags = {
|
||||
@@ -148,6 +122,10 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
|
||||
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
|
||||
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp: expPossiblyWithoutEasUpdateConfigured,
|
||||
platform: platformFlag,
|
||||
@@ -155,6 +133,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
projectId,
|
||||
vcsClient,
|
||||
env: undefined,
|
||||
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
|
||||
});
|
||||
|
||||
// check that the expo-updates package version supports roll back to embedded
|
||||
@@ -193,7 +172,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
const selectedRuntime =
|
||||
runtimeVersionArg ??
|
||||
(
|
||||
await UpdateRollBackToEmbedded.selectRuntimeAsync(graphqlClient, {
|
||||
await selectRuntimeOnBranchAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName,
|
||||
})
|
||||
@@ -202,196 +181,16 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
|
||||
Errors.error('Must select a runtime or provide the --runtimeVersion flag', { exit: 1 });
|
||||
}
|
||||
|
||||
const runtimeToPlatformsAndFingerprintInfoMapping =
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
|
||||
realizedPlatforms.map(platform => ({
|
||||
platform,
|
||||
runtimeVersionInfo: {
|
||||
runtimeVersion: selectedRuntime,
|
||||
expoUpdatesRuntimeFingerprint: null,
|
||||
expoUpdatesRuntimeFingerprintHash: null,
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
|
||||
const publishSpinner = ora('Publishing...').start();
|
||||
try {
|
||||
newUpdates = await this.publishRollbacksAsync({
|
||||
graphqlClient,
|
||||
updateMessage,
|
||||
branchId: branch.id,
|
||||
codeSigningInfo,
|
||||
runtimeToPlatformsAndFingerprintInfoMapping,
|
||||
realizedPlatforms,
|
||||
});
|
||||
publishSpinner.succeed('Published!');
|
||||
} catch (e) {
|
||||
publishSpinner.fail('Failed to publish updates');
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
for (const runtime of uniqBy(
|
||||
runtimeToPlatformsAndFingerprintInfoMapping,
|
||||
version => version.runtimeVersion
|
||||
)) {
|
||||
const newUpdatesForRuntimeVersion = newUpdates.filter(
|
||||
update => update.runtimeVersion === runtime.runtimeVersion
|
||||
);
|
||||
if (newUpdatesForRuntimeVersion.length === 0) {
|
||||
throw new Error(
|
||||
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
|
||||
);
|
||||
}
|
||||
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
|
||||
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
|
||||
update => update.platform === 'android'
|
||||
);
|
||||
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
|
||||
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
|
||||
|
||||
const projectName = exp.slug;
|
||||
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
|
||||
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
|
||||
const updateGroupLink = link(updateGroupUrl, { dim: false });
|
||||
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Branch', value: branchName },
|
||||
{ label: 'Runtime version', value: runtime.runtimeVersion },
|
||||
{ label: 'Platform', value: platforms.join(', ') },
|
||||
{ label: 'Update group ID', value: updateGroupId },
|
||||
...(newAndroidUpdate
|
||||
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
|
||||
: []),
|
||||
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
|
||||
{ label: 'Message', value: updateMessage ?? '' },
|
||||
{ label: 'EAS Dashboard', value: updateGroupLink },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async publishRollbacksAsync({
|
||||
graphqlClient,
|
||||
updateMessage,
|
||||
branchId,
|
||||
codeSigningInfo,
|
||||
runtimeToPlatformsAndFingerprintInfoMapping,
|
||||
realizedPlatforms,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
updateMessage: string | undefined;
|
||||
branchId: string;
|
||||
codeSigningInfo: CodeSigningInfo | undefined;
|
||||
runtimeToPlatformsAndFingerprintInfoMapping: (RuntimeVersionInfo & {
|
||||
platforms: UpdatePublishPlatform[];
|
||||
})[];
|
||||
realizedPlatforms: PublishPlatform[];
|
||||
}): Promise<UpdatePublishMutation['updateBranch']['publishUpdateGroups']> {
|
||||
const rollbackInfoGroups = Object.fromEntries(
|
||||
realizedPlatforms.map(platform => [platform, true])
|
||||
);
|
||||
|
||||
// Sort the updates into different groups based on their platform specific runtime versions
|
||||
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformsAndFingerprintInfoMapping.map(
|
||||
({ runtimeVersion, platforms }) => {
|
||||
const localRollbackInfoGroup = Object.fromEntries(
|
||||
platforms.map(platform => [platform, rollbackInfoGroups[platform]])
|
||||
);
|
||||
|
||||
return {
|
||||
branchId,
|
||||
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
|
||||
|
||||
if (codeSigningInfo) {
|
||||
Log.log('🔒 Signing roll back');
|
||||
|
||||
const updatesTemp = [...newUpdates];
|
||||
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
|
||||
const newUpdates = updatesTemp.splice(
|
||||
0,
|
||||
Object.keys(nullthrows(updateGroup.rollBackToEmbeddedInfoGroup)).length
|
||||
);
|
||||
return {
|
||||
updateGroup,
|
||||
newUpdates,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
updateGroupsAndTheirUpdates.map(async ({ newUpdates }) => {
|
||||
await Promise.all(
|
||||
newUpdates.map(async newUpdate => {
|
||||
const response = await fetch(newUpdate.manifestPermalink, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'multipart/mixed' },
|
||||
});
|
||||
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
|
||||
|
||||
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
|
||||
|
||||
const directiveSignature = signBody(directiveBody, codeSigningInfo);
|
||||
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
|
||||
alg: codeSigningInfo.codeSigningMetadata.alg,
|
||||
keyid: codeSigningInfo.codeSigningMetadata.keyid,
|
||||
sig: directiveSignature,
|
||||
});
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return newUpdates;
|
||||
}
|
||||
|
||||
private static async selectRuntimeAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
batchSize = 5,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
batchSize?: number;
|
||||
}
|
||||
): Promise<RuntimeFragment | null> {
|
||||
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
|
||||
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
first: queryParams.first,
|
||||
after: queryParams.after,
|
||||
last: queryParams.last,
|
||||
before: queryParams.before,
|
||||
});
|
||||
};
|
||||
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
|
||||
return runtime.version;
|
||||
};
|
||||
return await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType: 'target runtime',
|
||||
pageSize: batchSize,
|
||||
await publishRollBackToEmbeddedUpdateAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
exp,
|
||||
updateMessage,
|
||||
branch,
|
||||
codeSigningInfo,
|
||||
platforms: realizedPlatforms,
|
||||
runtimeVersion: selectedRuntime,
|
||||
json: jsonFlag,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export default class UpdateRollback extends EasCommand {
|
||||
static override description = 'roll back to an embedded update or an existing update';
|
||||
static override description =
|
||||
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
|
||||
|
||||
static override flags = {
|
||||
'private-key-path': Flags.string({
|
||||
|
||||
@@ -13,6 +13,7 @@ import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
|
||||
import {
|
||||
BuildMetadataInput,
|
||||
DistributionType,
|
||||
LocalBuildArchiveSourceType,
|
||||
UploadSessionType,
|
||||
@@ -27,11 +28,11 @@ import { uploadFileAtPathToGCSAsync } from '../uploads';
|
||||
import { fromNow } from '../utils/date';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
|
||||
import { getTmpDirectory } from '../utils/paths';
|
||||
import { parseBinaryPlistBuffer } from '../utils/plist';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
|
||||
export default class BuildUpload extends EasCommand {
|
||||
static override description = 'upload a local build and generate a sharable link';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
|
||||
@@ -82,6 +83,7 @@ export default class BuildUpload extends EasCommand {
|
||||
fingerprintHash: buildFingerprintHash,
|
||||
developmentClient,
|
||||
simulator,
|
||||
...otherMetadata
|
||||
} = await extractAppMetadataAsync(localBuildPath, platform);
|
||||
|
||||
let fingerprint = manualFingerprintHash ?? buildFingerprintHash;
|
||||
@@ -120,7 +122,12 @@ export default class BuildUpload extends EasCommand {
|
||||
projectId,
|
||||
{ platform: toAppPlatform(platform), simulator },
|
||||
{ type: LocalBuildArchiveSourceType.Gcs, bucketKey },
|
||||
{ distribution: DistributionType.Internal, fingerprintHash: fingerprint, developmentClient }
|
||||
{
|
||||
distribution: DistributionType.Internal,
|
||||
fingerprintHash: fingerprint,
|
||||
developmentClient,
|
||||
...otherMetadata,
|
||||
}
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
@@ -303,19 +310,33 @@ async function uploadAppArchiveAsync(
|
||||
return bucketKey;
|
||||
}
|
||||
|
||||
type AppMetadata = {
|
||||
fingerprintHash?: string;
|
||||
developmentClient: boolean;
|
||||
function getInfoPlistMetadata(infoPlist: any): {
|
||||
appName?: string;
|
||||
appIdentifier?: string;
|
||||
simulator: boolean;
|
||||
};
|
||||
} {
|
||||
const appName = infoPlist?.CFBundleDisplayName ?? infoPlist?.CFBundleName;
|
||||
const appIdentifier = infoPlist?.CFBundleIdentifier;
|
||||
const simulator = infoPlist?.DTPlatformName?.includes('simulator');
|
||||
|
||||
return {
|
||||
appName,
|
||||
appIdentifier,
|
||||
simulator,
|
||||
};
|
||||
}
|
||||
|
||||
async function extractAppMetadataAsync(
|
||||
buildPath: string,
|
||||
platform: Platform
|
||||
): Promise<AppMetadata> {
|
||||
): Promise<{ developmentClient: boolean; simulator: boolean } & BuildMetadataInput> {
|
||||
let developmentClient = false;
|
||||
let fingerprintHash: string | undefined;
|
||||
const simulator = platform === Platform.IOS;
|
||||
|
||||
// By default, we assume the iOS apps are for simulators
|
||||
let simulator = platform === Platform.IOS;
|
||||
let appName: string | undefined;
|
||||
let appIdentifier: string | undefined;
|
||||
|
||||
const basePath = platform === Platform.ANDROID ? 'assets/' : buildPath;
|
||||
const fingerprintFilePath =
|
||||
@@ -340,14 +361,51 @@ async function extractAppMetadataAsync(
|
||||
}
|
||||
} else if (buildExtension === '.app') {
|
||||
developmentClient = await fs.exists(path.join(basePath, devMenuBundlePath));
|
||||
if (await fs.exists(path.join(basePath, 'Info.plist'))) {
|
||||
const infoPlistBuffer = await fs.readFile(path.join(basePath, 'Info.plist'));
|
||||
const infoPlist = parseBinaryPlistBuffer(infoPlistBuffer);
|
||||
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
|
||||
}
|
||||
|
||||
if (await fs.exists(path.join(basePath, fingerprintFilePath))) {
|
||||
fingerprintHash = await fs.readFile(path.join(basePath, fingerprintFilePath), 'utf8');
|
||||
}
|
||||
} else if (buildExtension === '.ipa') {
|
||||
const zip = new StreamZip.async({ file: buildPath });
|
||||
try {
|
||||
const entries = await zip.entries();
|
||||
const entriesKeys = Object.keys(entries);
|
||||
|
||||
await Promise.all(
|
||||
entriesKeys.map(async path => {
|
||||
const infoPlistRegex = /^Payload\/[^/]+\.app\/Info\.plist$/;
|
||||
if (infoPlistRegex.test(path)) {
|
||||
const infoPlistBuffer = await zip.entryData(entries[path]);
|
||||
const infoPlist = parseBinaryPlistBuffer(infoPlistBuffer);
|
||||
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.includes('/EXDevMenu.bundle')) {
|
||||
developmentClient = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.includes('EXUpdates.bundle/fingerprint')) {
|
||||
fingerprintHash = (await zip.entryData(entries[path])).toString('utf-8');
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Log.error(`Error reading ${buildExtension}: ${err}`);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
} else {
|
||||
// Use tar to list files in the archive
|
||||
try {
|
||||
let fingerprintHashPromise: Promise<string> | undefined;
|
||||
let infoPlistPromise: Promise<Buffer> | undefined;
|
||||
await tar.list({
|
||||
file: buildPath,
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
@@ -368,11 +426,29 @@ async function extractAppMetadataAsync(
|
||||
}
|
||||
});
|
||||
}
|
||||
if (entry.path.endsWith('Info.plist')) {
|
||||
infoPlistPromise = new Promise<Buffer>(async (resolve, reject) => {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of entry) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const content = Buffer.concat(chunks);
|
||||
resolve(content);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (fingerprintHashPromise !== undefined) {
|
||||
fingerprintHash = await fingerprintHashPromise;
|
||||
}
|
||||
if (infoPlistPromise !== undefined) {
|
||||
const infoPlist = parseBinaryPlistBuffer(await infoPlistPromise);
|
||||
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
|
||||
}
|
||||
} catch (err) {
|
||||
Log.error(`Error reading ${buildExtension}: ${err}`);
|
||||
}
|
||||
@@ -382,5 +458,7 @@ async function extractAppMetadataAsync(
|
||||
developmentClient,
|
||||
fingerprintHash,
|
||||
simulator,
|
||||
appName,
|
||||
appIdentifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { parseInputs, parseJsonInputs, parseWorkflowInputsFromYaml } from '../run';
|
||||
|
||||
describe('parseInputs', () => {
|
||||
it('should parse single key=value pair', () => {
|
||||
const inputs = parseInputs(['key=value']);
|
||||
expect(inputs).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should parse multiple key=value pairs', () => {
|
||||
const inputs = parseInputs(['key1=value1', 'key2=value2', 'key3=value3']);
|
||||
expect(inputs).toEqual({
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
key3: 'value3',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty value', () => {
|
||||
const inputs = parseInputs(['key=']);
|
||||
expect(inputs).toEqual({ key: '' });
|
||||
});
|
||||
|
||||
it('should handle values with equals signs', () => {
|
||||
const inputs = parseInputs(['key=value=with=equals']);
|
||||
expect(inputs).toEqual({ key: 'value=with=equals' });
|
||||
});
|
||||
|
||||
it('should handle values with spaces', () => {
|
||||
const inputs = parseInputs(['key=value with spaces']);
|
||||
expect(inputs).toEqual({ key: 'value with spaces' });
|
||||
});
|
||||
|
||||
it('should handle special characters in values', () => {
|
||||
const inputs = parseInputs(['key=value!@#$%^&*()']);
|
||||
expect(inputs).toEqual({ key: 'value!@#$%^&*()' });
|
||||
});
|
||||
|
||||
it('should throw error for invalid format without equals', () => {
|
||||
expect(() => parseInputs(['invalid_format'])).toThrow(
|
||||
'Invalid input format: invalid_format. Expected key=value format.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty key', () => {
|
||||
expect(() => parseInputs(['=value'])).toThrow(
|
||||
'Invalid input format: =value. Key cannot be empty.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const inputs = parseInputs([]);
|
||||
expect(inputs).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle mix of valid and complex inputs', () => {
|
||||
const inputs = parseInputs([
|
||||
'simple=value',
|
||||
'complex=key=value=with=multiple=equals',
|
||||
'empty=',
|
||||
'spaces=value with spaces',
|
||||
]);
|
||||
expect(inputs).toEqual({
|
||||
simple: 'value',
|
||||
complex: 'key=value=with=multiple=equals',
|
||||
empty: '',
|
||||
spaces: 'value with spaces',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonInputs', () => {
|
||||
it('should parse simple JSON object', () => {
|
||||
const json = '{"key": "value", "number": 42, "bool": true}';
|
||||
const inputs = parseJsonInputs(json);
|
||||
expect(inputs).toEqual({
|
||||
key: 'value',
|
||||
number: 42,
|
||||
bool: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex objects', () => {
|
||||
const json = '{"obj": {"nested": "value"}, "arr": [1, 2, 3]}';
|
||||
const inputs = parseJsonInputs(json);
|
||||
expect(inputs).toEqual({
|
||||
obj: { nested: 'value' },
|
||||
arr: [1, 2, 3],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const json = '{}';
|
||||
const inputs = parseJsonInputs(json);
|
||||
expect(inputs).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle string values with special characters', () => {
|
||||
const json =
|
||||
'{"special": "value with spaces", "equals": "key=value", "quotes": "value\\"with\\"quotes"}';
|
||||
const inputs = parseJsonInputs(json);
|
||||
expect(inputs).toEqual({
|
||||
special: 'value with spaces',
|
||||
equals: 'key=value',
|
||||
quotes: 'value"with"quotes',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON', () => {
|
||||
const invalidJson = '{"invalid": json}';
|
||||
expect(() => parseJsonInputs(invalidJson)).toThrow('Invalid JSON input.');
|
||||
});
|
||||
|
||||
it('should throw error for non-object JSON', () => {
|
||||
expect(() => parseJsonInputs('["array"]')).toThrow('Invalid JSON input.');
|
||||
|
||||
expect(() => parseJsonInputs('"string"')).toThrow('Invalid JSON input.');
|
||||
|
||||
expect(() => parseJsonInputs('42')).toThrow('Invalid JSON input.');
|
||||
|
||||
expect(() => parseJsonInputs('null')).toThrow('Invalid JSON input.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWorkflowInputsFromYaml', () => {
|
||||
it('should parse workflow inputs from YAML', () => {
|
||||
const yamlConfig = `
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
type: string
|
||||
required: true
|
||||
description: "Environment to deploy to"
|
||||
debug:
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Enable debug mode"
|
||||
version:
|
||||
type: number
|
||||
required: true
|
||||
description: "Version number"
|
||||
deployment_type:
|
||||
type: choice
|
||||
options: ["staging", "production"]
|
||||
default: "staging"
|
||||
description: "Type of deployment"
|
||||
target_env:
|
||||
type: environment
|
||||
default: "preview"
|
||||
description: "Target environment"
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
- run: echo "test"
|
||||
`;
|
||||
|
||||
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
|
||||
expect(inputs).toEqual({
|
||||
environment: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Environment to deploy to',
|
||||
},
|
||||
debug: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
description: 'Enable debug mode',
|
||||
},
|
||||
version: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
description: 'Version number',
|
||||
},
|
||||
deployment_type: {
|
||||
type: 'choice',
|
||||
required: false,
|
||||
default: 'staging',
|
||||
options: ['staging', 'production'],
|
||||
description: 'Type of deployment',
|
||||
},
|
||||
target_env: {
|
||||
type: 'environment',
|
||||
required: false,
|
||||
default: 'preview',
|
||||
description: 'Target environment',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle YAML without workflow_dispatch inputs', () => {
|
||||
const yamlConfig = `
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
- run: echo "test"
|
||||
`;
|
||||
|
||||
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
|
||||
expect(inputs).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle empty YAML', () => {
|
||||
const yamlConfig = '';
|
||||
|
||||
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
|
||||
expect(inputs).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle invalid YAML gracefully', () => {
|
||||
const yamlConfig = 'invalid: yaml: content:';
|
||||
|
||||
const { parseWorkflowInputsFromYaml } = require('../run');
|
||||
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
|
||||
expect(inputs).toEqual({});
|
||||
});
|
||||
|
||||
it('should default type to string when not specified', () => {
|
||||
const yamlConfig = `
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
simple_input:
|
||||
description: "A simple input"
|
||||
required: true
|
||||
`;
|
||||
|
||||
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
|
||||
expect(inputs.simple_input).toEqual({
|
||||
type: 'string',
|
||||
description: 'A simple input',
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { choiceFromWorkflowRun, processWorkflowRuns } from '../../commandUtils/workflow/utils';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export default class WorkflowRunCancel extends EasCommand {
|
||||
static override description =
|
||||
'Cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel.';
|
||||
|
||||
static override strict = false;
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { argv } = await this.parse(WorkflowRunCancel);
|
||||
let nonInteractive = false;
|
||||
const workflowRunIds: Set<string> = new Set();
|
||||
|
||||
// Custom parsing of argv
|
||||
const tokens: string[] = [...argv];
|
||||
while (tokens.length > 0) {
|
||||
const token = tokens.shift();
|
||||
if (token === '--non-interactive') {
|
||||
nonInteractive = true;
|
||||
continue;
|
||||
} else if (token) {
|
||||
workflowRunIds.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowRunCancel, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (workflowRunIds.size === 0) {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply workflow run IDs as arguments when in non-interactive mode');
|
||||
}
|
||||
// Run the workflow run list query and select runs to cancel
|
||||
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
WorkflowRunStatus.InProgress,
|
||||
50
|
||||
);
|
||||
const runs = processWorkflowRuns(queryResult);
|
||||
if (runs.length === 0) {
|
||||
Log.warn('No workflow runs to cancel');
|
||||
return;
|
||||
}
|
||||
const answers = await promptAsync({
|
||||
type: 'multiselect',
|
||||
name: 'selectedRuns',
|
||||
message: 'Select IN_PROGRESS workflow runs to cancel',
|
||||
choices: runs.map(run => choiceFromWorkflowRun(run)),
|
||||
});
|
||||
answers.selectedRuns.forEach((id: string) => {
|
||||
workflowRunIds.add(id);
|
||||
});
|
||||
if (workflowRunIds.size === 0) {
|
||||
Log.warn('No workflow runs to cancel');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
for (const workflowRunId of workflowRunIds) {
|
||||
try {
|
||||
await WorkflowRunMutation.cancelWorkflowRunAsync(graphqlClient, {
|
||||
workflowRunId,
|
||||
});
|
||||
|
||||
Log.log(`Workflow run ${workflowRunId} has been canceled.`);
|
||||
} catch (e: any) {
|
||||
Log.error(`Failed to cancel workflow run ${workflowRunId}: ${e}`);
|
||||
}
|
||||
}
|
||||
Log.addNewLineIfNone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import { WorkflowResult } from '../../commandUtils/workflow/types';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log from '../../log';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class WorkflowList extends EasCommand {
|
||||
static override hidden = true;
|
||||
|
||||
static override description = 'List workflows for the current project';
|
||||
|
||||
static override flags = {
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(WorkflowList);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowList, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const workflows = await AppQuery.byIdWorkflowsAsync(graphqlClient, projectId);
|
||||
|
||||
const result: WorkflowResult[] = workflows.map(workflow => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
fileName: workflow.fileName,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
}));
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
result.forEach(workflow => {
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'ID', value: workflow.id },
|
||||
{ label: 'Name', value: workflow.name ?? '-' },
|
||||
{ label: 'File name', value: workflow.fileName },
|
||||
{ label: 'Created At', value: workflow.createdAt },
|
||||
{ label: 'Updated At', value: workflow.updatedAt },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
WorkflowCommandSelectionState,
|
||||
WorkflowCommandSelectionStateValue,
|
||||
executeWorkflowSelectionActionsAsync,
|
||||
} from '../../commandUtils/workflow/stateMachine';
|
||||
import { WorkflowLogLine, WorkflowLogs } from '../../commandUtils/workflow/types';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
function printLogsForAllSteps(logs: WorkflowLogs): void {
|
||||
[...logs.keys()].forEach(step => {
|
||||
const logLines = logs.get(step);
|
||||
if (logLines) {
|
||||
Log.log(`Step: ${step}`);
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
}
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
|
||||
export default class WorkflowLogView extends EasCommand {
|
||||
static override description =
|
||||
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.';
|
||||
|
||||
static override flags = {
|
||||
...EasJsonOnlyFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
'all-steps': Flags.boolean({
|
||||
description:
|
||||
'Print all logs, rather than prompting for a specific step. This will be automatically set when in non-interactive mode.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override args = [
|
||||
{ name: 'id', description: 'ID of the workflow run or workflow job to view logs for' },
|
||||
];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(WorkflowLogView);
|
||||
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const allSteps = nonInteractive ? true : flags['all-steps'];
|
||||
Log.debug(`allSteps = ${allSteps}`);
|
||||
Log.debug(`nonInteractive = ${nonInteractive}`);
|
||||
Log.debug(`flags.json = ${flags.json}`);
|
||||
Log.debug(`args.id = ${args.id}`);
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
} = await this.getContextAsync(WorkflowLogView, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const finalSelectionState: WorkflowCommandSelectionState =
|
||||
await executeWorkflowSelectionActionsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
nonInteractive,
|
||||
allSteps,
|
||||
state: WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
jobId: args.id,
|
||||
});
|
||||
|
||||
if (finalSelectionState.state === WorkflowCommandSelectionStateValue.ERROR) {
|
||||
Log.error(finalSelectionState.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = finalSelectionState?.logs as unknown as WorkflowLogs | null;
|
||||
if (allSteps) {
|
||||
if (logs) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(Object.fromEntries(logs));
|
||||
} else {
|
||||
printLogsForAllSteps(logs);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const selectedStep = finalSelectionState?.step as unknown as string;
|
||||
const logLines = logs?.get(selectedStep);
|
||||
if (logLines) {
|
||||
if (flags.json) {
|
||||
const output: { [key: string]: WorkflowLogLine[] | null } = {};
|
||||
output[selectedStep] = logLines ?? null;
|
||||
printJsonOnlyOutput(output);
|
||||
} else {
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,104 @@
|
||||
/**
|
||||
* EAS Workflow Run Command
|
||||
*
|
||||
* This command runs an EAS workflow with support for interactive input prompting.
|
||||
*
|
||||
* Input Sources (in order of precedence):
|
||||
* 1. Command line flags (-F key=value)
|
||||
* 2. STDIN JSON input (echo '{"key": "value"}' | eas workflow:run)
|
||||
* 3. Interactive prompts (when required inputs are missing and not in non-interactive mode)
|
||||
*
|
||||
* Interactive Prompting:
|
||||
* - When running in interactive mode (default), the command will automatically prompt
|
||||
* for any required inputs that are not provided via flags or STDIN
|
||||
* - Input types supported: string, boolean, number, choice, environment
|
||||
* - Each input type has appropriate validation and default values
|
||||
* - Use --non-interactive flag to disable prompting and require all inputs via flags
|
||||
*
|
||||
* Example workflow with inputs:
|
||||
* ```yaml
|
||||
* on:
|
||||
* workflow_dispatch:
|
||||
* inputs:
|
||||
* environment:
|
||||
* type: string
|
||||
* required: true
|
||||
* description: "Environment to deploy to"
|
||||
* debug:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: "Enable debug mode"
|
||||
* version:
|
||||
* type: number
|
||||
* required: true
|
||||
* description: "Version number"
|
||||
* deployment_type:
|
||||
* type: choice
|
||||
* options: ["staging", "production"]
|
||||
* default: "staging"
|
||||
* description: "Type of deployment"
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Flags } from '@oclif/core';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import slash from 'slash';
|
||||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { WorkflowProjectSourceType } from '../../graphql/generated';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
const EXIT_CODES = {
|
||||
WORKFLOW_FAILED: 11,
|
||||
WORKFLOW_CANCELED: 12,
|
||||
WAIT_ABORTED: 13,
|
||||
};
|
||||
|
||||
export default class WorkflowRun extends EasCommand {
|
||||
static override description = 'Run an EAS workflow';
|
||||
static override description = 'run an EAS workflow';
|
||||
|
||||
static override args = [{ name: 'file', description: 'Path to the workflow file to run' }];
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
wait: Flags.boolean({
|
||||
default: false,
|
||||
allowNo: true,
|
||||
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
|
||||
summary: 'Wait for workflow run to complete',
|
||||
}),
|
||||
input: Flags.string({
|
||||
char: 'F',
|
||||
aliases: ['f', 'field'],
|
||||
multiple: true,
|
||||
description:
|
||||
'Add a parameter in key=value format. Use multiple instances of this flag to set multiple inputs.',
|
||||
summary: 'Set workflow inputs',
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -32,6 +111,10 @@ export default class WorkflowRun extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(WorkflowRun);
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -79,9 +162,51 @@ export default class WorkflowRun extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
let inputs: Record<string, unknown> | undefined;
|
||||
|
||||
// Check for stdin input
|
||||
const stdinData = await maybeReadStdinAsync();
|
||||
|
||||
const inputsFromFlags = [...(flags.input ?? [])];
|
||||
|
||||
// Validate that both stdin and -F flags are not provided simultaneously
|
||||
if (stdinData && inputsFromFlags.length > 0) {
|
||||
throw new Error(
|
||||
'Cannot use both stdin JSON input and -F flags simultaneously. Please use only one input method.'
|
||||
);
|
||||
}
|
||||
|
||||
if (stdinData) {
|
||||
inputs = parseJsonInputs(stdinData);
|
||||
} else if (inputsFromFlags.length > 0) {
|
||||
inputs = parseInputs(inputsFromFlags);
|
||||
}
|
||||
|
||||
// Parse workflow inputs from YAML and prompt for missing required inputs
|
||||
const inputSpecs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
if (!flags['non-interactive']) {
|
||||
inputs = await maybePromptForMissingInputsAsync({ inputSpecs, inputs: inputs ?? {} });
|
||||
}
|
||||
|
||||
if (inputs && Object.keys(inputs).length > 0) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.newLine();
|
||||
Log.log('Running with inputs:');
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
Log.log(`- ${chalk.bold(key)}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let projectArchiveBucketKey: string;
|
||||
let easJsonBucketKey: string;
|
||||
let packageJsonBucketKey: string;
|
||||
let easJsonBucketKey: string | null = null;
|
||||
let packageJsonBucketKey: string | null = null;
|
||||
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
|
||||
const projectRootDirectory = slash(
|
||||
path.relative(await vcsClient.getRootPathAsync(), projectDir) || '.'
|
||||
);
|
||||
|
||||
try {
|
||||
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
|
||||
@@ -89,54 +214,413 @@ export default class WorkflowRun extends EasCommand {
|
||||
vcsClient,
|
||||
accountId: account.id,
|
||||
}));
|
||||
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: path.join(projectDir, 'eas.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: path.join(projectDir, 'package.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
|
||||
if (await fileExistsAsync(easJsonPath)) {
|
||||
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: easJsonPath,
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} else {
|
||||
Log.warn(
|
||||
`⚠ No ${chalk.bold('eas.json')} found in the project directory. Running ${chalk.bold(
|
||||
'type: build'
|
||||
)} jobs will not work. Run ${chalk.bold(
|
||||
'eas build:configure'
|
||||
)} to configure your project for builds.`
|
||||
);
|
||||
}
|
||||
|
||||
if (await fileExistsAsync(packageJsonPath)) {
|
||||
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: packageJsonPath,
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} else {
|
||||
Log.warn(
|
||||
`⚠ No ${chalk.bold(
|
||||
'package.json'
|
||||
)} found in the project directory. It is used to automatically infer best job configuration for your project. You may want to define ${chalk.bold(
|
||||
'image'
|
||||
)} property in your workflow to specify the image to use.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
Log.error('Failed to upload project sources.');
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
let workflowRunId: string;
|
||||
|
||||
try {
|
||||
const { id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: projectId,
|
||||
workflowRevisionInput: {
|
||||
fileName: path.basename(args.file),
|
||||
yamlConfig,
|
||||
({ id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
workflowRevisionInput: {
|
||||
fileName: path.basename(args.file),
|
||||
yamlConfig,
|
||||
},
|
||||
workflowRunInput: {
|
||||
inputs,
|
||||
projectSource: {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
projectRootDirectory,
|
||||
},
|
||||
workflowRunInput: {
|
||||
projectSource: {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
Log.newLine();
|
||||
Log.succeed(
|
||||
`Workflow run started successfully. See logs: ${link(
|
||||
getWorkflowRunUrl(account.name, projectName, workflowRunId)
|
||||
)}`
|
||||
);
|
||||
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
|
||||
} catch (err) {
|
||||
Log.error('Failed to start the workflow with the API.');
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!flags.wait) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
id: workflowRunId,
|
||||
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
|
||||
});
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
const { status } = await waitForWorkflowRunToEndAsync(graphqlClient, {
|
||||
workflowRunId,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
printJsonOnlyOutput({
|
||||
...workflowRun,
|
||||
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
|
||||
});
|
||||
}
|
||||
|
||||
if (status === WorkflowRunStatus.Failure) {
|
||||
process.exit(EXIT_CODES.WORKFLOW_FAILED);
|
||||
} else if (status === WorkflowRunStatus.Canceled) {
|
||||
process.exit(EXIT_CODES.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkflowRunToEndAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ workflowRunId }: { workflowRunId: string }
|
||||
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
|
||||
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
|
||||
|
||||
const spinner = ora('Currently waiting for workflow run to start.').start();
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.byIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.InProgress:
|
||||
spinner.start('Workflow run is in progress.');
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.warn('Workflow run is waiting for action.');
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.warn('Workflow run has been canceled.');
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure:
|
||||
spinner.fail('Workflow run has failed.');
|
||||
return workflowRun;
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.succeed('Workflow run completed successfully.');
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(EXIT_CODES.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(filePath, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
export function parseInputs(inputFlags: string[]): Record<string, string> {
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
for (const inputFlag of inputFlags) {
|
||||
const equalIndex = inputFlag.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
|
||||
}
|
||||
|
||||
const key = inputFlag.substring(0, equalIndex);
|
||||
const value = inputFlag.substring(equalIndex + 1);
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
|
||||
}
|
||||
|
||||
inputs[key] = value;
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export async function maybeReadStdinAsync(): Promise<string | null> {
|
||||
// Check if there's data on stdin
|
||||
if (process.stdin.isTTY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
data += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const trimmedData = data.trim();
|
||||
resolve(trimmedData || null);
|
||||
});
|
||||
|
||||
process.stdin.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseJsonInputs(jsonString: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('JSON input must be an object.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON input.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
// `z.coerce.boolean()` does `Boolean(val)` under the hood,
|
||||
// which is not what we want. See:
|
||||
// https://github.com/colinhacks/zod/issues/2985#issuecomment-2230692578
|
||||
const booleanLike = z.union([
|
||||
z.boolean(),
|
||||
z.codec(z.number(), z.boolean(), {
|
||||
decode: n => !!n,
|
||||
encode: b => (b ? 1 : 0),
|
||||
}),
|
||||
z.stringbool({ truthy: ['true', 'True'], falsy: ['false', 'False'] }),
|
||||
]);
|
||||
|
||||
const stringLike = z.codec(
|
||||
z.union([
|
||||
// We're going to coerce numbers and strings into strings.
|
||||
z.number(),
|
||||
z.string(),
|
||||
// We do not allow other primitives, like:
|
||||
// - bigints, symbols - because YAML does not support them,
|
||||
// - booleans - because YAML accepts `True` and `true` as boolean input
|
||||
// and parses both as JS `true` -- if we stringified that,
|
||||
// we would lose the capital "T" which may not be what the user expects,
|
||||
// - nulls - user should do `"null"` or not pass the property at all.
|
||||
]),
|
||||
z.string(),
|
||||
{
|
||||
decode: value => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
throw new Error(`Cannot convert ${typeof value} to string: ${value}`);
|
||||
},
|
||||
encode: value => value,
|
||||
}
|
||||
);
|
||||
|
||||
export const WorkflowDispatchInputZ = z
|
||||
.object({
|
||||
description: stringLike.optional().describe('Description of the input'),
|
||||
required: booleanLike.default(false).describe('Whether the input is required.'),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal('string').default('string'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('boolean'),
|
||||
default: booleanLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('number'),
|
||||
default: z.number().optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('choice'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('environment'),
|
||||
default: z.string().optional().describe('Default value for the input'),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
export function parseWorkflowInputsFromYaml(
|
||||
yamlConfig: string
|
||||
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
|
||||
try {
|
||||
const parsed = YAML.parse(yamlConfig);
|
||||
return z
|
||||
.record(z.string(), WorkflowDispatchInputZ)
|
||||
.default({})
|
||||
.parse(parsed?.on?.workflow_dispatch?.inputs);
|
||||
} catch (error) {
|
||||
Log.warn('Failed to parse workflow inputs from YAML:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePromptForMissingInputsAsync({
|
||||
inputSpecs,
|
||||
inputs,
|
||||
}: {
|
||||
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
|
||||
inputs: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
|
||||
|
||||
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
|
||||
|
||||
if (missingRequiredInputs.length === 0) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Some required inputs are missing. Please provide them:');
|
||||
|
||||
const nextInputs = { ...inputs };
|
||||
|
||||
for (const [key, spec] of missingRequiredInputs) {
|
||||
const value = await promptForMissingInputAsync({ key, spec });
|
||||
nextInputs[key] = value;
|
||||
}
|
||||
|
||||
return nextInputs;
|
||||
}
|
||||
|
||||
async function promptForMissingInputAsync({
|
||||
key,
|
||||
spec,
|
||||
}: {
|
||||
key: string;
|
||||
spec: z.infer<typeof WorkflowDispatchInputZ>;
|
||||
}): Promise<unknown> {
|
||||
const message = spec.description ? `${key} (${spec.description})` : key;
|
||||
|
||||
switch (spec.type) {
|
||||
case 'boolean': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'number',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: number) => {
|
||||
if (isNaN(val)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'choice': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'value',
|
||||
message,
|
||||
choices: spec.options.map(option => ({
|
||||
title: option,
|
||||
value: option,
|
||||
})),
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'string':
|
||||
case 'environment': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: string) => {
|
||||
if (spec.required && (!val || val.trim() === '')) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { processWorkflowRuns } from '../../commandUtils/workflow/utils';
|
||||
import { WorkflowRunFragment, WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class WorkflowRunList extends EasCommand {
|
||||
static override description =
|
||||
'list recent workflow runs for this project, with their IDs, statuses, and timestamps';
|
||||
|
||||
static override flags = {
|
||||
workflow: Flags.string({
|
||||
description:
|
||||
'If present, the query will only return runs for the specified workflow file name',
|
||||
required: false,
|
||||
}),
|
||||
status: Flags.enum({
|
||||
description: 'If present, filter the returned runs to select those with the specified status',
|
||||
required: false,
|
||||
options: Object.values(WorkflowRunStatus),
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: 100 }),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(WorkflowRunList);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowRunList, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const workflowFileName = flags.workflow;
|
||||
const status = flags.status;
|
||||
const limit = flags.limit ?? 10;
|
||||
|
||||
let runs: WorkflowRunFragment[];
|
||||
if (workflowFileName) {
|
||||
runs = await WorkflowRunQuery.byAppIdFileNameAndStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
workflowFileName,
|
||||
status,
|
||||
limit
|
||||
);
|
||||
} else {
|
||||
runs = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
status,
|
||||
limit
|
||||
);
|
||||
}
|
||||
|
||||
const result = processWorkflowRuns(runs);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
result.forEach(run => {
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Run ID', value: run.id },
|
||||
{ label: 'Workflow', value: run.workflowFileName },
|
||||
{ label: 'Status', value: run.status },
|
||||
{ label: 'Started At', value: run.startedAt },
|
||||
{ label: 'Finished At', value: run.finishedAt },
|
||||
{ label: 'Trigger Type', value: run.triggerType },
|
||||
{ label: 'Trigger', value: run.trigger ?? 'null' },
|
||||
{ label: 'Git Commit Message', value: run.gitCommitMessage ?? 'null' },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { formatGraphQLBuildArtifacts } from '../../build/utils/formatBuild';
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
WorkflowCommandSelectionStateValue,
|
||||
workflowRunSelectionAction,
|
||||
} from '../../commandUtils/workflow/stateMachine';
|
||||
import { WorkflowTriggerType } from '../../commandUtils/workflow/types';
|
||||
import { computeTriggerInfoForWorkflowRun } from '../../commandUtils/workflow/utils';
|
||||
import {
|
||||
BuildArtifacts,
|
||||
WorkflowArtifact,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
type ReducedWorkflowArtifact = Omit<
|
||||
WorkflowArtifact,
|
||||
'createdAt' | 'jobRun' | 'storageType' | 'updatedAt'
|
||||
>;
|
||||
|
||||
type WorkflowJobOutput =
|
||||
WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number]['outputs'];
|
||||
|
||||
type WorkflowJobResult = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] & {
|
||||
artifacts?: ReducedWorkflowArtifact[] | BuildArtifacts | undefined;
|
||||
output?: WorkflowJobOutput | undefined;
|
||||
};
|
||||
|
||||
type WorkflowRunResult = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] & {
|
||||
logURL?: string | undefined;
|
||||
triggerType?: WorkflowTriggerType | undefined;
|
||||
trigger?: string | null;
|
||||
jobs: WorkflowJobResult[];
|
||||
};
|
||||
|
||||
const processedOutputs: (job: WorkflowJobResult) => FormatFieldsItem[] = job => {
|
||||
const result: FormatFieldsItem[] = [];
|
||||
const keys = job.outputs ? Object.keys(job.outputs) : [];
|
||||
keys.forEach(key => {
|
||||
result.push({
|
||||
label: ` ${key}`,
|
||||
get value(): string {
|
||||
const value = job.outputs[key];
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
},
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export default class WorkflowView extends EasCommand {
|
||||
static override description =
|
||||
'view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
|
||||
|
||||
static override flags = {
|
||||
...EasJsonOnlyFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override args = [{ name: 'id', description: 'ID of the workflow run to view' }];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(WorkflowView);
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
} = await this.getContextAsync(WorkflowView, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (nonInteractive && !args.id) {
|
||||
throw new Error('If non-interactive, this command requires a workflow run ID as argument');
|
||||
}
|
||||
|
||||
const actionResult = await workflowRunSelectionAction({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
nonInteractive,
|
||||
allSteps: false,
|
||||
state: WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
|
||||
runId: args.id,
|
||||
});
|
||||
if (actionResult.state === WorkflowCommandSelectionStateValue.ERROR) {
|
||||
Log.error(actionResult.message);
|
||||
return;
|
||||
}
|
||||
const idToQuery = actionResult.runId ?? '';
|
||||
|
||||
const result: WorkflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(
|
||||
graphqlClient,
|
||||
idToQuery,
|
||||
{
|
||||
useCache: false,
|
||||
}
|
||||
);
|
||||
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(result);
|
||||
result.triggerType = triggerType;
|
||||
result.trigger = trigger;
|
||||
|
||||
const processedJobs: WorkflowJobResult[] = result.jobs.map(job => {
|
||||
const processedJob = job as WorkflowJobResult;
|
||||
if (job.type === WorkflowJobType.Build) {
|
||||
processedJob.artifacts = job.turtleBuild?.artifacts ?? undefined;
|
||||
} else {
|
||||
processedJob.artifacts = job.turtleJobRun?.artifacts;
|
||||
}
|
||||
delete processedJob.turtleJobRun;
|
||||
return processedJob;
|
||||
});
|
||||
result.jobs = processedJobs;
|
||||
|
||||
result.logURL = getWorkflowRunUrl(
|
||||
result.workflow.app.ownerAccount.name,
|
||||
result.workflow.app.name,
|
||||
result.id
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Run ID', value: result.id },
|
||||
{ label: 'Workflow', value: result.workflow.fileName },
|
||||
{ label: 'Trigger Type', value: result.triggerType },
|
||||
{ label: 'Trigger', value: result.trigger ?? 'null' },
|
||||
{
|
||||
label: 'Git Commit Message',
|
||||
value: result.gitCommitMessage?.split('\n')[0] ?? 'null',
|
||||
},
|
||||
{ label: 'Status', value: result.status },
|
||||
{ label: 'Errors', value: result.errors.map(error => error.title).join('\n') },
|
||||
{ label: 'Created At', value: result.createdAt },
|
||||
{ label: 'Updated At', value: result.updatedAt },
|
||||
{ label: 'Log URL', value: link(result.logURL) },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
result.jobs.forEach((job: WorkflowJobResult) => {
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Job ID', value: job.id },
|
||||
{ label: ' Key', value: job.key },
|
||||
{ label: ' Name', value: job.name },
|
||||
{ label: ' Status', value: job.status },
|
||||
{ label: ' Type', value: job.type },
|
||||
{ label: ' Created At', value: job.createdAt },
|
||||
{ label: ' Updated At', value: job.updatedAt },
|
||||
])
|
||||
);
|
||||
if (job.errors.length > 0) {
|
||||
Log.gray(chalk.dim(' Errors:'));
|
||||
job.errors.forEach(error => {
|
||||
Log.log(formatFields([{ label: ` ${error.title}`, value: `${error.message}` }]));
|
||||
});
|
||||
}
|
||||
if (job.outputs) {
|
||||
const outputs = processedOutputs(job);
|
||||
if (outputs.length > 0) {
|
||||
Log.gray(chalk.dim(' Outputs:'));
|
||||
Log.log(formatFields(outputs));
|
||||
}
|
||||
}
|
||||
if (job.type === WorkflowJobType.Build) {
|
||||
if (job.turtleBuild?.artifacts) {
|
||||
Log.gray(chalk.dim(' Artifacts:'));
|
||||
Log.log(
|
||||
formatFields(
|
||||
formatGraphQLBuildArtifacts(job.turtleBuild).map(item => {
|
||||
item.label = ` ${item.label}`;
|
||||
return item;
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const jobArtifacts = job.artifacts as ReducedWorkflowArtifact[];
|
||||
if (jobArtifacts?.length) {
|
||||
Log.gray(chalk.dim(' Artifacts:'));
|
||||
jobArtifacts.forEach(artifact => {
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: ' ID', value: artifact.id },
|
||||
{ label: ' Name', value: artifact.name },
|
||||
{ label: ' Content Type', value: artifact?.contentType ?? 'null' },
|
||||
{
|
||||
label: ' File Size Bytes',
|
||||
value: artifact?.fileSizeBytes ? `${artifact.fileSizeBytes}` : 'null',
|
||||
},
|
||||
{ label: ' Filename', value: artifact.filename },
|
||||
{
|
||||
label: ' Download URL',
|
||||
value: artifact?.downloadUrl ? link(artifact.downloadUrl) : 'null',
|
||||
},
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export function getNewIosApiMock(): { [key in keyof typeof IosGraphqlClient]?: a
|
||||
updateIosAppCredentialsAsync: jest.fn(),
|
||||
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
|
||||
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
|
||||
createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync: () => testAppleTeam,
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
|
||||
getDevicesForAppleTeamAsync: jest.fn(),
|
||||
createProvisioningProfileAsync: jest.fn(),
|
||||
|
||||
@@ -14,9 +14,14 @@ import { displayAndroidAppCredentials } from '../printCredentials';
|
||||
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('chalk', () => ({
|
||||
red: jest.fn(text => `red(${text})`),
|
||||
blue: jest.fn(text => `blue(${text})`),
|
||||
yellow: jest.fn(text => `yellow(${text})`),
|
||||
green: jest.fn(text => `green(${text})`),
|
||||
gray: jest.fn(text => `gray(${text})`),
|
||||
bold: jest.fn(log => log),
|
||||
cyan: { bold: jest.fn(log => log) },
|
||||
dim: jest.fn(log => log),
|
||||
cyan: { bold: jest.fn(log => log) },
|
||||
}));
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
|
||||
|
||||
@@ -98,9 +98,7 @@ export default class IosCredentialsProvider {
|
||||
const setupPushKeyAction = new SetUpPushKey(appLookupParams);
|
||||
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(ctx);
|
||||
if (isPushKeySetup) {
|
||||
Log.succeed(
|
||||
`Push Notifications setup for ${app.projectName}: ${applicationTarget.bundleIdentifier}`
|
||||
);
|
||||
Log.succeed(`Push Notifications are set up`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,12 @@ export async function resolveAppleTeamIfAuthenticatedAsync(
|
||||
if (!ctx.appStore.authCtx) {
|
||||
return null;
|
||||
}
|
||||
return await ctx.ios.createOrGetExistingAppleTeamAsync(ctx.graphqlClient, app.account, {
|
||||
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
|
||||
appleTeamName: ctx.appStore.authCtx.team.name,
|
||||
});
|
||||
return await ctx.ios.createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
|
||||
ctx.graphqlClient,
|
||||
app.account.id,
|
||||
{
|
||||
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
|
||||
appleTeamName: ctx.appStore.authCtx.team.name,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ async function generateDistributionCertificateAsync(
|
||||
`✅ Distribution Certificates can be revoked with no side effects for App Store builds.`
|
||||
)
|
||||
);
|
||||
Log.log(learnMore('https://docs.expo.dev/distribution/app-signing/#summary'));
|
||||
Log.log(learnMore('https://docs.expo.dev/app-signing/app-credentials/#summary'));
|
||||
Log.newLine();
|
||||
|
||||
const { distCertsToRevoke } = await promptAsync({
|
||||
|
||||
@@ -76,7 +76,9 @@ async function generatePushKeyAsync(ctx: CredentialsContext): Promise<PushKey> {
|
||||
}
|
||||
|
||||
Log.log(chalk.grey(`⚠️ Revoking a Push Key will affect other apps that rely on it`));
|
||||
Log.log(learnMore('https://docs.expo.dev/distribution/app-signing/#push-notification-keys'));
|
||||
Log.log(
|
||||
learnMore('https://docs.expo.dev/app-signing/app-credentials/#push-notification-keys')
|
||||
);
|
||||
Log.log();
|
||||
|
||||
const { pushKeysToRevoke } = await promptAsync({
|
||||
|
||||
+2
-2
@@ -44,9 +44,9 @@ export class SetUpTargetBuildCredentialsFromCredentialsJson {
|
||||
const appleTeamFromProvisioningProfile = readAppleTeam(
|
||||
this.targetCredentials.provisioningProfile
|
||||
);
|
||||
const appleTeam = await ctx.ios.createOrGetExistingAppleTeamAsync(
|
||||
const appleTeam = await ctx.ios.createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
|
||||
ctx.graphqlClient,
|
||||
this.app.account,
|
||||
this.app.account.id,
|
||||
{
|
||||
appleTeamIdentifier: appleTeamFromProvisioningProfile.teamId,
|
||||
appleTeamName: appleTeamFromProvisioningProfile.teamName,
|
||||
|
||||
+18
-1
@@ -5,12 +5,29 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
|
||||
import { AppleDistributionCertificateQuery } from '../../api/graphql/queries/AppleDistributionCertificateQuery';
|
||||
import { formatDistributionCertificate } from '../DistributionCertificateUtils';
|
||||
jest.mock('../../api/graphql/queries/AppleDistributionCertificateQuery');
|
||||
/*
|
||||
jest.mock('chalk', () => {
|
||||
return {
|
||||
__esModule: true, // this property makes it work
|
||||
default: { gray: jest.fn(log => log), underline: jest.fn(log => log) },
|
||||
default: {
|
||||
blue: jest.fn(log => log),
|
||||
gray: jest.fn(log => log),
|
||||
underline: jest.fn(log => log),
|
||||
},
|
||||
};
|
||||
});
|
||||
*/
|
||||
jest.mock('chalk', () => ({
|
||||
red: jest.fn(text => `red(${text})`),
|
||||
blue: jest.fn(text => `blue(${text})`),
|
||||
yellow: jest.fn(text => `yellow(${text})`),
|
||||
green: jest.fn(text => `green(${text})`),
|
||||
gray: jest.fn(log => log),
|
||||
bold: jest.fn(log => log),
|
||||
dim: jest.fn(log => log),
|
||||
cyan: { bold: jest.fn(log => log) },
|
||||
underline: jest.fn(log => log),
|
||||
}));
|
||||
|
||||
mockdate.set(new Date('4/20/2021'));
|
||||
describe('select credentials', () => {
|
||||
|
||||
@@ -260,23 +260,34 @@ async function createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrGetExistingAppleTeamAsync(
|
||||
export async function createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: AccountFragment,
|
||||
accountId: string,
|
||||
{ appleTeamIdentifier, appleTeamName }: { appleTeamIdentifier: string; appleTeamName?: string }
|
||||
): Promise<AppleTeamFragment> {
|
||||
const appleTeam = await AppleTeamQuery.getByAppleTeamIdentifierAsync(
|
||||
graphqlClient,
|
||||
account.id,
|
||||
accountId,
|
||||
appleTeamIdentifier
|
||||
);
|
||||
if (appleTeam) {
|
||||
const didAppleTeamNameChange =
|
||||
appleTeamName && appleTeam && appleTeamName !== appleTeam.appleTeamName;
|
||||
if (didAppleTeamNameChange) {
|
||||
return await AppleTeamMutation.updateAppleTeamAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appleTeamName,
|
||||
},
|
||||
appleTeam.id
|
||||
);
|
||||
}
|
||||
return appleTeam;
|
||||
} else {
|
||||
return await AppleTeamMutation.createAppleTeamAsync(
|
||||
graphqlClient,
|
||||
{ appleTeamIdentifier, appleTeamName },
|
||||
account.id
|
||||
accountId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -424,10 +435,14 @@ export async function createDistributionCertificateAsync(
|
||||
account: AccountFragment,
|
||||
distCert: DistributionCertificate
|
||||
): Promise<AppleDistributionCertificateMutationResult> {
|
||||
const appleTeam = await createOrGetExistingAppleTeamAsync(graphqlClient, account, {
|
||||
appleTeamIdentifier: distCert.teamId,
|
||||
appleTeamName: distCert.teamName,
|
||||
});
|
||||
const appleTeam = await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
|
||||
graphqlClient,
|
||||
account.id,
|
||||
{
|
||||
appleTeamIdentifier: distCert.teamId,
|
||||
appleTeamName: distCert.teamName,
|
||||
}
|
||||
);
|
||||
return await AppleDistributionCertificateMutation.createAppleDistributionCertificateAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
@@ -456,10 +471,14 @@ export async function createPushKeyAsync(
|
||||
account: AccountFragment,
|
||||
pushKey: PushKey
|
||||
): Promise<ApplePushKeyFragment> {
|
||||
const appleTeam = await createOrGetExistingAppleTeamAsync(graphqlClient, account, {
|
||||
appleTeamIdentifier: pushKey.teamId,
|
||||
appleTeamName: pushKey.teamName,
|
||||
});
|
||||
const appleTeam = await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
|
||||
graphqlClient,
|
||||
account.id,
|
||||
{
|
||||
appleTeamIdentifier: pushKey.teamId,
|
||||
appleTeamName: pushKey.teamName,
|
||||
}
|
||||
);
|
||||
return await ApplePushKeyMutation.createApplePushKeyAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
@@ -502,7 +521,7 @@ export async function createAscApiKeyAsync(
|
||||
ascApiKey: MinimalAscApiKey
|
||||
): Promise<AppStoreConnectApiKeyFragment> {
|
||||
const maybeAppleTeam = ascApiKey.teamId
|
||||
? await createOrGetExistingAppleTeamAsync(graphqlClient, account, {
|
||||
? await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(graphqlClient, account.id, {
|
||||
appleTeamIdentifier: ascApiKey.teamId,
|
||||
appleTeamName: ascApiKey.teamName,
|
||||
})
|
||||
|
||||
@@ -4,42 +4,35 @@ import gql from 'graphql-tag';
|
||||
import { ExpoGraphqlClient } from '../../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../../../../../graphql/client';
|
||||
import {
|
||||
AccountFragment,
|
||||
AppleTeamFragment,
|
||||
AppleTeamInput,
|
||||
AppleTeamUpdateInput,
|
||||
CreateAppleTeamMutation,
|
||||
CreateAppleTeamMutationVariables,
|
||||
UpdateAppleTeamMutation,
|
||||
UpdateAppleTeamMutationVariables,
|
||||
} from '../../../../../graphql/generated';
|
||||
import { AccountFragmentNode } from '../../../../../graphql/types/Account';
|
||||
import { AppleTeamFragmentNode } from '../../../../../graphql/types/credentials/AppleTeam';
|
||||
|
||||
export type AppleTeamMutationResult = AppleTeamFragment & {
|
||||
account: AccountFragment;
|
||||
};
|
||||
|
||||
export const AppleTeamMutation = {
|
||||
async createAppleTeamAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appleTeamInput: AppleTeamInput,
|
||||
accountId: string
|
||||
): Promise<AppleTeamMutationResult> {
|
||||
): Promise<AppleTeamFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CreateAppleTeamMutation>(
|
||||
.mutation<CreateAppleTeamMutation, CreateAppleTeamMutationVariables>(
|
||||
gql`
|
||||
mutation CreateAppleTeamMutation($appleTeamInput: AppleTeamInput!, $accountId: ID!) {
|
||||
appleTeam {
|
||||
createAppleTeam(appleTeamInput: $appleTeamInput, accountId: $accountId) {
|
||||
id
|
||||
...AppleTeamFragment
|
||||
account {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppleTeamFragmentNode)}
|
||||
${print(AccountFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appleTeamInput,
|
||||
@@ -50,4 +43,35 @@ export const AppleTeamMutation = {
|
||||
);
|
||||
return data.appleTeam.createAppleTeam;
|
||||
},
|
||||
async updateAppleTeamAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appleTeamInput: AppleTeamUpdateInput,
|
||||
appleTeamEntityId: string
|
||||
): Promise<AppleTeamFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateAppleTeamMutation, UpdateAppleTeamMutationVariables>(
|
||||
gql`
|
||||
mutation UpdateAppleTeamMutation(
|
||||
$appleTeamInput: AppleTeamUpdateInput!
|
||||
$appleTeamEntityId: ID!
|
||||
) {
|
||||
appleTeam {
|
||||
updateAppleTeam(appleTeamUpdateInput: $appleTeamInput, id: $appleTeamEntityId) {
|
||||
id
|
||||
...AppleTeamFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppleTeamFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appleTeamInput,
|
||||
appleTeamEntityId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.appleTeam.updateAppleTeam;
|
||||
},
|
||||
};
|
||||
+328
-137
@@ -1,10 +1,20 @@
|
||||
import { BundleIdCapability } from '@expo/apple-utils';
|
||||
|
||||
import {
|
||||
CapabilityMapping,
|
||||
assertValidOptions,
|
||||
syncCapabilitiesForEntitlementsAsync,
|
||||
} from '../bundleIdCapabilities';
|
||||
import { assertValidOptions, syncCapabilitiesForEntitlementsAsync } from '../bundleIdCapabilities';
|
||||
import { CapabilityMapping } from '../capabilityList';
|
||||
|
||||
const noBroadcastNotificationOption = {
|
||||
usesBroadcastPushNotifications: false,
|
||||
};
|
||||
|
||||
// Helper function to create reusable bundleId objects for testing
|
||||
function createMockBundleId(id: string = 'XXX', capabilities: BundleIdCapability[] = []): any {
|
||||
return {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => capabilities),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe(assertValidOptions, () => {
|
||||
it(`adds a reason for asserting capability identifiers`, () => {
|
||||
@@ -13,7 +23,7 @@ describe(assertValidOptions, () => {
|
||||
)!;
|
||||
expect(() => {
|
||||
assertValidOptions(classifier, ['foobar']);
|
||||
}).toThrowError(/Expected an array of strings, where each string is prefixed with "merchant."/);
|
||||
}).toThrow(/Expected an array of strings, where each string is prefixed with "merchant."/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,17 +49,17 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
'com.apple.developer.associated-domains': ['applinks:packagename.fr'],
|
||||
};
|
||||
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => capabilities),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('U78L9459DG', capabilities);
|
||||
|
||||
await syncCapabilitiesForEntitlementsAsync(bundleId, entitlements, {
|
||||
usesBroadcastPushNotifications: false,
|
||||
});
|
||||
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
entitlements,
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(enabled).toStrictEqual(['Associated Domains']);
|
||||
expect(disabled).toStrictEqual([]);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
capabilityType: 'ASSOCIATED_DOMAINS',
|
||||
option: 'ON',
|
||||
@@ -57,12 +67,255 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe('capabilities with settings', () => {
|
||||
const ctx = { providerId: 123195, teamId: 'MyteamId' };
|
||||
const capabilities = [
|
||||
{
|
||||
id: 'U78L9459DG_APPLE_ID_AUTH',
|
||||
attributes: {
|
||||
ownerType: 'BUNDLE',
|
||||
settings: [
|
||||
{
|
||||
key: 'APPLE_ID_AUTH_APP_CONSENT',
|
||||
options: [
|
||||
{
|
||||
key: 'PRIMARY_APP_CONSENT',
|
||||
inputs: [
|
||||
{
|
||||
key: 'SERVER_TO_SERVER_NOTIF_URL',
|
||||
name: 'Server to Server Notification URL',
|
||||
description: 'Server to Server Notification URL',
|
||||
allowedInstances: 'SINGLE',
|
||||
minInstances: 0,
|
||||
displayOrder: 0,
|
||||
maxLimit: 1,
|
||||
values: [
|
||||
{
|
||||
value: 'https://example.com/path/to/endpoint',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
properties: [null],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
editable: true,
|
||||
inputs: null,
|
||||
enabled: true,
|
||||
responseId: '570fdbea-7996-4ace-8397-e318973d2f36',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'U78L9459DG_ICLOUD',
|
||||
attributes: {
|
||||
ownerType: 'BUNDLE',
|
||||
settings: [
|
||||
{
|
||||
key: 'ICLOUD_VERSION',
|
||||
options: [
|
||||
{
|
||||
key: 'XCODE_6',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
editable: true,
|
||||
inputs: null,
|
||||
enabled: true,
|
||||
responseId: '570fdbea-7996-4ace-8397-e318973d2f36',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'U78L9459DG_DATA_PROTECTION',
|
||||
attributes: {
|
||||
ownerType: 'BUNDLE',
|
||||
settings: [
|
||||
{
|
||||
key: 'DATA_PROTECTION_PERMISSION_LEVEL',
|
||||
options: [
|
||||
{
|
||||
key: 'COMPLETE_PROTECTION',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
editable: true,
|
||||
inputs: null,
|
||||
enabled: true,
|
||||
responseId: '570fdbea-7996-4ace-8397-e318973d2f36',
|
||||
},
|
||||
},
|
||||
].map(({ id, attributes }) => new BundleIdCapability(ctx, id, attributes as any));
|
||||
|
||||
const entitlements = {
|
||||
'com.apple.developer.applesignin': ['Default'],
|
||||
'com.apple.developer.default-data-protection': 'NSFileProtectionComplete',
|
||||
'com.apple.developer.icloud-container-identifiers': ['iCloud.com.vonovak.edfapp'],
|
||||
'com.apple.developer.icloud-services': ['CloudDocuments'],
|
||||
'com.apple.developer.ubiquity-container-identifiers': ['iCloud.com.vonovak.edfapp'],
|
||||
'com.apple.developer.ubiquity-kvstore-identifier':
|
||||
'$(TeamIdentifierPrefix)com.vonovak.edfapp',
|
||||
};
|
||||
|
||||
it(`given a nonempty set S of remote capabilities, and a local boolean capability that is disabled and not in S, it does not disable any capability from S`, async () => {
|
||||
// this is for an edge case
|
||||
const bundleId = createMockBundleId('U78L9459DG', capabilities);
|
||||
const result = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': false,
|
||||
...entitlements,
|
||||
},
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
});
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not sync a capability that is already enabled', async () => {
|
||||
const bundleId = createMockBundleId('U78L9459DG', capabilities);
|
||||
const result = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
entitlements,
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
});
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enables capabilities when they are not present', async () => {
|
||||
const bundleId = createMockBundleId('U78L9459DG', []);
|
||||
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
entitlements,
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'APPLE_ID_AUTH', option: 'ON' },
|
||||
{ capabilityType: 'DATA_PROTECTION', option: 'COMPLETE_PROTECTION' },
|
||||
{ capabilityType: 'ICLOUD', option: 'ON' },
|
||||
]);
|
||||
expect(enabled).toStrictEqual(['Sign In with Apple', 'Data Protection', 'iCloud']);
|
||||
expect(disabled).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('disables capabilities when no entitlements are provided', async () => {
|
||||
const bundleId = createMockBundleId('U78L9459DG', capabilities);
|
||||
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{}, // no entitlements
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'APPLE_ID_AUTH', option: 'OFF' },
|
||||
{ capabilityType: 'ICLOUD', option: 'OFF' },
|
||||
{ capabilityType: 'DATA_PROTECTION', option: 'OFF' },
|
||||
]);
|
||||
expect(enabled).toStrictEqual([]);
|
||||
expect(disabled).toStrictEqual(['Sign In with Apple', 'iCloud', 'Data Protection']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean capabilities: given a bundleId with no capabilities', () => {
|
||||
it('enables boolean capability when set to true', async () => {
|
||||
const bundleId = createMockBundleId();
|
||||
const result = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.networking.wifi-info': true,
|
||||
},
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'ACCESS_WIFI_INFORMATION', option: 'ON' },
|
||||
]);
|
||||
expect(result).toStrictEqual({
|
||||
enabled: ['Access WiFi Information'],
|
||||
disabled: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enable a capability when it is set to false', async () => {
|
||||
const bundleId = createMockBundleId();
|
||||
const result = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.networking.wifi-info': false,
|
||||
},
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
expect(result).toStrictEqual({
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a bundleId with a capability that is enabled', () => {
|
||||
const ctx = { providerId: 123195, teamId: 'MyteamId' };
|
||||
const remote = {
|
||||
id: 'UFJ54VZ75A_ACCESS_WIFI_INFORMATION',
|
||||
attributes: {
|
||||
ownerType: 'BUNDLE',
|
||||
settings: null,
|
||||
editable: true,
|
||||
inputs: null,
|
||||
enabled: true,
|
||||
responseId: '31746b7d-4728-49f5-a8f9-a81c0cecabb1',
|
||||
},
|
||||
};
|
||||
const capability = new BundleIdCapability(ctx, remote.id, remote.attributes as any);
|
||||
|
||||
it('and the entitlement is set to false, the capability is disabled', async () => {
|
||||
const bundleId = createMockBundleId('UFJ54VZ75A', [capability]);
|
||||
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.networking.wifi-info': false,
|
||||
},
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(enabled).toStrictEqual([]);
|
||||
expect(disabled).toStrictEqual(['Access WiFi Information']);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'ACCESS_WIFI_INFORMATION', option: 'OFF' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('and the entitlement is set to true, the capability is skipped', async () => {
|
||||
const bundleId = createMockBundleId('UFJ54VZ75A', [capability]);
|
||||
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.networking.wifi-info': true,
|
||||
},
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(enabled).toStrictEqual([]);
|
||||
expect(disabled).toStrictEqual([]);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('enables all capabilities', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => []),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId();
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
@@ -124,9 +377,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
'com.apple.developer.shared-with-you': true,
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
@@ -324,25 +575,19 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('skips simple duplicates', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: null }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(results.enabled).toStrictEqual([]);
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
@@ -369,9 +614,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
)
|
||||
).rejects.toThrowError(
|
||||
`https://developer-mdn.apple.com/account/resources/identifiers/bundleId/edit/XXX333`
|
||||
@@ -379,25 +622,19 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('cannot skip complex duplicates', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: [] }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: [] }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'HEALTHKIT', option: 'ON' },
|
||||
]);
|
||||
|
||||
@@ -406,25 +643,19 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('disables some capabilities', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_HOMEKIT', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_HOMEKIT', { settings: null }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'HEALTHKIT', option: 'ON' },
|
||||
{ capabilityType: 'HOMEKIT', option: 'OFF' },
|
||||
]);
|
||||
@@ -435,46 +666,34 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
|
||||
// We don't disable APNS, IAP, or GC
|
||||
it('does not disable special capabilities', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_IN_APP_PURCHASE', { settings: null }),
|
||||
new BundleIdCapability({}, 'XXX_GAME_CENTER', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_IN_APP_PURCHASE', { settings: null }),
|
||||
new BundleIdCapability({}, 'XXX_GAME_CENTER', { settings: null }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalledWith();
|
||||
|
||||
expect(results.enabled).toStrictEqual([]);
|
||||
expect(results.disabled).toStrictEqual([]);
|
||||
});
|
||||
// Only disable known capabilities
|
||||
it('does not disable unhandled capabilities', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_UNKNOWN_NEW_THING', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_UNKNOWN_NEW_THING', { settings: null }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'com.apple.developer.healthkit': true,
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
@@ -486,11 +705,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('enables push notifications capability with broadcast option when the capability is disabled and usesBroadcastPushNotifications is true', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => []),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId();
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
@@ -502,7 +717,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'PUSH_NOTIFICATION_FEATURE_BROADCAST' },
|
||||
]);
|
||||
|
||||
@@ -511,13 +726,9 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('updates push notifications capability with broadcast option when the capability is enabled without settings and usesBroadcastPushNotifications is true', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
@@ -529,7 +740,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'PUSH_NOTIFICATION_FEATURE_BROADCAST' },
|
||||
]);
|
||||
|
||||
@@ -538,13 +749,9 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('disables push notifications capability', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
@@ -554,7 +761,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
}
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'OFF' },
|
||||
]);
|
||||
|
||||
@@ -563,15 +770,11 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('does nothing when push notifications capability is enabled with broadcast options and usesBroadcastPushNotifications is true', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: [],
|
||||
}),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
@@ -590,24 +793,18 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('does nothing when push notifications capability is enabled without broadcast options and usesBroadcastPushNotifications is false', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: null,
|
||||
}),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
|
||||
@@ -617,27 +814,21 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
|
||||
});
|
||||
|
||||
it('updates push notifications capability without broadcast options when it is enabled and usesBroadcastPushNotifications is false', async () => {
|
||||
const bundleId = {
|
||||
getBundleIdCapabilitiesAsync: jest.fn(() => [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: [],
|
||||
}),
|
||||
]),
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
const bundleId = createMockBundleId('XXX', [
|
||||
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
|
||||
settings: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
const results = await syncCapabilitiesForEntitlementsAsync(
|
||||
bundleId,
|
||||
{
|
||||
'aps-environment': 'production',
|
||||
},
|
||||
{
|
||||
usesBroadcastPushNotifications: false,
|
||||
}
|
||||
noBroadcastNotificationOption
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
|
||||
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'ON' },
|
||||
]);
|
||||
|
||||
|
||||
+88
-54
@@ -1,56 +1,61 @@
|
||||
import { syncCapabilityIdentifiersForEntitlementsAsync } from '../capabilityIdentifiers';
|
||||
|
||||
const mockItems = {
|
||||
merchant: {
|
||||
expo: { id: 'XXX-merch-1', attributes: { identifier: 'merchant.expo' } },
|
||||
},
|
||||
appGroup: {
|
||||
expo: { id: 'XXX-group-1', attributes: { identifier: 'group.expo' } },
|
||||
},
|
||||
cloudContainer: {
|
||||
expo: { id: 'XXX-icloud-1', attributes: { identifier: 'iCloud.expo' } },
|
||||
},
|
||||
};
|
||||
|
||||
function mockCapabilities(Apple: any): void {
|
||||
Apple.MerchantId.getAsync = jest.fn(() => [
|
||||
{
|
||||
id: 'XXX-merch-1',
|
||||
const mockGetAsync = (existingItems: any[]): jest.Mock =>
|
||||
jest.fn(() => Promise.resolve(existingItems));
|
||||
|
||||
const mockCreateAsync = (newId: string): jest.Mock =>
|
||||
jest.fn((_ctx, { identifier }) => ({
|
||||
id: newId,
|
||||
attributes: {
|
||||
identifier: 'merchant.expo',
|
||||
identifier,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Apple.MerchantId.getAsync = mockGetAsync([mockItems.merchant.expo]);
|
||||
|
||||
Apple.MerchantId.createAsync = mockCreateAsync('XXX-merch-2');
|
||||
|
||||
Apple.AppGroup.getAsync = mockGetAsync([mockItems.appGroup.expo]);
|
||||
|
||||
Apple.AppGroup.createAsync = mockCreateAsync('XXX-group-2');
|
||||
|
||||
Apple.CloudContainer.getAsync = mockGetAsync([mockItems.cloudContainer.expo]);
|
||||
|
||||
Apple.CloudContainer.createAsync = mockCreateAsync('XXX-icloud-2');
|
||||
}
|
||||
|
||||
function mockCapabilitiesAlreadyCreated(Apple: any): void {
|
||||
Apple.MerchantId.getAsync.mockResolvedValue([
|
||||
mockItems.merchant.expo,
|
||||
{ id: 'XXX-merch-2', attributes: { identifier: 'merchant.bacon' } },
|
||||
]);
|
||||
|
||||
Apple.MerchantId.createAsync = jest.fn((_ctx, { identifier }) => ({
|
||||
id: 'XXX-merch-2',
|
||||
attributes: {
|
||||
identifier,
|
||||
},
|
||||
}));
|
||||
|
||||
Apple.AppGroup.getAsync = jest.fn(() => [
|
||||
{
|
||||
id: 'XXX-group-1',
|
||||
attributes: {
|
||||
identifier: 'group.expo',
|
||||
},
|
||||
},
|
||||
]);
|
||||
Apple.AppGroup.createAsync = jest.fn((_ctx, { identifier }) => ({
|
||||
id: 'XXX-group-2',
|
||||
attributes: {
|
||||
identifier,
|
||||
},
|
||||
}));
|
||||
|
||||
Apple.CloudContainer.getAsync = jest.fn(() => [
|
||||
{
|
||||
id: 'XXX-icloud-1',
|
||||
attributes: {
|
||||
identifier: 'iCloud.expo',
|
||||
},
|
||||
},
|
||||
Apple.AppGroup.getAsync.mockResolvedValue([
|
||||
mockItems.appGroup.expo,
|
||||
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
|
||||
]);
|
||||
|
||||
Apple.CloudContainer.createAsync = jest.fn((_ctx, { identifier }) => ({
|
||||
id: 'XXX-icloud-2',
|
||||
attributes: {
|
||||
identifier,
|
||||
},
|
||||
}));
|
||||
Apple.CloudContainer.getAsync.mockResolvedValue([
|
||||
mockItems.cloudContainer.expo,
|
||||
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
|
||||
]);
|
||||
}
|
||||
|
||||
describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
it(`creates missing capability identifiers`, async () => {
|
||||
it(`creates missing capability identifiers, and once they are created, they are not re-created`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
|
||||
@@ -60,10 +65,26 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.in-app-payments': ['merchant.bacon'],
|
||||
'com.apple.security.application-groups': ['group.bacon'],
|
||||
'com.apple.developer.icloud-container-identifiers': ['iCloud.bacon'],
|
||||
const syncCapabilities = (): Promise<{
|
||||
created: string[];
|
||||
linked: string[];
|
||||
}> =>
|
||||
syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.in-app-payments': ['merchant.bacon'],
|
||||
'com.apple.security.application-groups': ['group.bacon'],
|
||||
'com.apple.developer.icloud-container-identifiers': ['iCloud.bacon'],
|
||||
});
|
||||
|
||||
const result = await syncCapabilities();
|
||||
expect(result).toStrictEqual({
|
||||
created: ['group.bacon', 'merchant.bacon', 'iCloud.bacon'],
|
||||
linked: ['group.bacon', 'merchant.bacon', 'iCloud.bacon'],
|
||||
});
|
||||
mockCapabilitiesAlreadyCreated(Apple);
|
||||
const result2 = await syncCapabilities();
|
||||
expect(result2).toStrictEqual({
|
||||
created: [],
|
||||
linked: [],
|
||||
});
|
||||
|
||||
// Ensure we create missing ids
|
||||
@@ -73,6 +94,7 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
// props
|
||||
{ identifier: 'merchant.bacon' }
|
||||
);
|
||||
expect(Apple.MerchantId.createAsync).toHaveBeenCalledTimes(1);
|
||||
// Ensure we create missing ids
|
||||
expect(Apple.AppGroup.createAsync).toHaveBeenLastCalledWith(
|
||||
// auth context
|
||||
@@ -80,6 +102,7 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
// props
|
||||
{ identifier: 'group.bacon' }
|
||||
);
|
||||
expect(Apple.AppGroup.createAsync).toHaveBeenCalledTimes(1);
|
||||
// Ensure we create missing ids
|
||||
expect(Apple.CloudContainer.createAsync).toHaveBeenLastCalledWith(
|
||||
// auth context
|
||||
@@ -87,8 +110,8 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
// props
|
||||
{ identifier: 'iCloud.bacon' }
|
||||
);
|
||||
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
|
||||
expect(Apple.CloudContainer.createAsync).toHaveBeenCalledTimes(1);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
|
||||
{
|
||||
capabilityType: 'APP_GROUPS',
|
||||
option: 'ON',
|
||||
@@ -111,9 +134,10 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`removes local duplicates`, async () => {
|
||||
it(`doesn't perform duplicate CapabilityModel.getAsync calls`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
const bundleId = {
|
||||
@@ -122,28 +146,38 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.developer.in-app-payments': ['merchant.expo', 'merchant.expo'],
|
||||
});
|
||||
|
||||
// Only called once because we remove local duplicates to minimize network requests.
|
||||
expect(Apple.MerchantId.getAsync).toBeCalledTimes(1);
|
||||
expect(Apple.MerchantId.getAsync).toHaveBeenCalledTimes(1);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
expect(result).toStrictEqual({
|
||||
created: [],
|
||||
linked: [],
|
||||
});
|
||||
});
|
||||
|
||||
it(`creates missing capability identifiers`, async () => {
|
||||
it(`does not create capability identifiers (merchant.expo) when it already exists`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
const bundleId = {
|
||||
context: {},
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
};
|
||||
|
||||
await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId as any, {
|
||||
'com.apple.developer.in-app-payments': ['merchant.expo'],
|
||||
});
|
||||
|
||||
expect(Apple.MerchantId.createAsync).not.toBeCalled();
|
||||
expect(Apple.MerchantId.createAsync).not.toHaveBeenCalled();
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
|
||||
expect(result).toStrictEqual({
|
||||
created: [],
|
||||
linked: [],
|
||||
});
|
||||
});
|
||||
|
||||
it(`throws when creating a missing capability that is reserved`, async () => {
|
||||
|
||||
@@ -1,70 +1,22 @@
|
||||
import {
|
||||
AppGroup,
|
||||
BundleId,
|
||||
BundleIdCapability,
|
||||
CapabilityOptionMap,
|
||||
CapabilityType,
|
||||
CapabilityTypeDataProtectionOption,
|
||||
CapabilityTypeOption,
|
||||
CapabilityTypePushNotificationsOption,
|
||||
CloudContainer,
|
||||
MerchantId,
|
||||
} from '@expo/apple-utils';
|
||||
import { JSONObject, JSONValue } from '@expo/json-file';
|
||||
import { JSONObject } from '@expo/json-file';
|
||||
import getenv from 'getenv';
|
||||
import { inspect } from 'util';
|
||||
|
||||
import {
|
||||
CapabilityClassifier,
|
||||
CapabilityMapping,
|
||||
associatedDomainsCapabilityType,
|
||||
} from './capabilityList';
|
||||
import Log from '../../../log';
|
||||
|
||||
export const EXPO_NO_CAPABILITY_SYNC = getenv.boolish('EXPO_NO_CAPABILITY_SYNC', false);
|
||||
|
||||
type GetOptionsMethod<T extends CapabilityType = any> = (
|
||||
entitlement: JSONValue,
|
||||
entitlementsJson: JSONObject,
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications?: boolean;
|
||||
}
|
||||
) => CapabilityOptionMap[T];
|
||||
|
||||
const validateBooleanOptions = (options: any): boolean => {
|
||||
return typeof options === 'boolean';
|
||||
};
|
||||
|
||||
const validatePrefixedStringArrayOptions =
|
||||
(prefix: string) =>
|
||||
(options: any): boolean => {
|
||||
return (
|
||||
Array.isArray(options) &&
|
||||
options.every(option => typeof option === 'string' && option.startsWith(prefix))
|
||||
);
|
||||
};
|
||||
|
||||
const validateStringArrayOptions = (options: any): boolean => {
|
||||
return Array.isArray(options) && options.every(option => typeof option === 'string');
|
||||
};
|
||||
|
||||
const createValidateStringOptions =
|
||||
(allowed: string[]) =>
|
||||
(options: any): boolean => {
|
||||
return allowed.includes(options);
|
||||
};
|
||||
|
||||
const createValidateStringArrayOptions =
|
||||
(allowed: string[]) =>
|
||||
(options: any): boolean => {
|
||||
return Array.isArray(options) && options.every(option => allowed.includes(option));
|
||||
};
|
||||
|
||||
const validateDevProdString = createValidateStringOptions(['development', 'production']);
|
||||
|
||||
const getBooleanOptions: GetOptionsMethod = entitlement => {
|
||||
return entitlement === true ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
|
||||
};
|
||||
|
||||
const getDefinedOptions: GetOptionsMethod = entitlement => {
|
||||
return entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an entitlements JSON object, synchronizes the remote capabilities for a bundle identifier.
|
||||
*
|
||||
@@ -78,6 +30,7 @@ const getDefinedOptions: GetOptionsMethod = entitlement => {
|
||||
*
|
||||
* @param bundleId bundle identifier object
|
||||
* @param entitlements JSON representation of the entitlements plist
|
||||
* @param additionalOptions Additional options to consider when syncing capabilities.
|
||||
* @returns
|
||||
*/
|
||||
export async function syncCapabilitiesForEntitlementsAsync(
|
||||
@@ -105,7 +58,8 @@ export async function syncCapabilitiesForEntitlementsAsync(
|
||||
const { disabledCapabilityNames, request: modifiedRequest } = getCapabilitiesToDisable(
|
||||
bundleId,
|
||||
remainingCapabilities,
|
||||
request
|
||||
request,
|
||||
entitlements
|
||||
);
|
||||
|
||||
if (modifiedRequest.length) {
|
||||
@@ -133,21 +87,8 @@ interface CapabilitiesRequest {
|
||||
option: any;
|
||||
}
|
||||
|
||||
function shouldSkipPushNotificationsCapabilityUpdate(
|
||||
existing: BundleIdCapability,
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
}
|
||||
): boolean {
|
||||
// For push notifications, we should always update the capabaility if
|
||||
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
|
||||
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
|
||||
const noSettingsAttributes = existing.attributes.settings == null;
|
||||
return !noSettingsAttributes === additionalOptions.usesBroadcastPushNotifications;
|
||||
}
|
||||
|
||||
function getCapabilitiesToEnable(
|
||||
currentCapabilities: BundleIdCapability[],
|
||||
currentRemoteCapabilities: BundleIdCapability[],
|
||||
entitlements: JSONObject,
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
@@ -159,7 +100,7 @@ function getCapabilitiesToEnable(
|
||||
} {
|
||||
const enabledCapabilityNames: string[] = [];
|
||||
const request: { capabilityType: CapabilityType; option: any }[] = [];
|
||||
const remainingCapabilities = [...currentCapabilities];
|
||||
const remainingCapabilities = [...currentRemoteCapabilities];
|
||||
for (const [key, value] of Object.entries(entitlements)) {
|
||||
const staticCapabilityInfo = CapabilityMapping.find(
|
||||
capability => capability.entitlement === key
|
||||
@@ -172,47 +113,35 @@ function getCapabilitiesToEnable(
|
||||
continue;
|
||||
}
|
||||
|
||||
assertValidOptions(staticCapabilityInfo, value);
|
||||
|
||||
const existingIndex = remainingCapabilities.findIndex(existing =>
|
||||
existing.isType(staticCapabilityInfo.capability)
|
||||
);
|
||||
const existing = existingIndex > -1 ? remainingCapabilities[existingIndex] : null;
|
||||
const existingRemote = existingIndex > -1 ? remainingCapabilities[existingIndex] : null;
|
||||
|
||||
// Only skip if the existing capability is a simple boolean value,
|
||||
// if it has more complex settings then we should always update it.
|
||||
// If the `existing.attributes.settings` object is defined, then we can determine that it has extra configuration.
|
||||
// For push notifications, we should always update the capabaility if
|
||||
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
|
||||
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
|
||||
const isPushNotificationsCapability =
|
||||
staticCapabilityInfo.capability === CapabilityType.PUSH_NOTIFICATIONS;
|
||||
if (
|
||||
existing &&
|
||||
((!isPushNotificationsCapability && existing.attributes.settings == null) ||
|
||||
(isPushNotificationsCapability &&
|
||||
shouldSkipPushNotificationsCapabilityUpdate(existing, additionalOptions)))
|
||||
) {
|
||||
// Remove the item from the list of capabilities so we don't disable it.
|
||||
remainingCapabilities.splice(existingIndex, 1);
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Skipping existing capability: ${key} (${staticCapabilityInfo.name})`);
|
||||
Log.log(
|
||||
`Remaining to remove: `,
|
||||
remainingCapabilities.map(({ id }) => id)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
assertValidOptions(staticCapabilityInfo, value);
|
||||
|
||||
enabledCapabilityNames.push(staticCapabilityInfo.name);
|
||||
|
||||
const option = staticCapabilityInfo.getOptions(value, entitlements, additionalOptions);
|
||||
|
||||
request.push({
|
||||
capabilityType: staticCapabilityInfo.capability,
|
||||
option,
|
||||
const operation = staticCapabilityInfo.getSyncOperation({
|
||||
existingRemote,
|
||||
entitlements,
|
||||
entitlementValue: value,
|
||||
additionalOptions,
|
||||
});
|
||||
const { op } = operation;
|
||||
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Will ${op} remote capability: ${key} (${staticCapabilityInfo.name}.`);
|
||||
}
|
||||
if (op === 'enable') {
|
||||
enabledCapabilityNames.push(staticCapabilityInfo.name);
|
||||
|
||||
request.push({
|
||||
capabilityType: staticCapabilityInfo.capability,
|
||||
option: operation.option,
|
||||
});
|
||||
} else if (op === 'skip' && existingIndex >= 0) {
|
||||
// Remove the item from the list of capabilities so we don't disable it in the next step.
|
||||
remainingCapabilities.splice(existingIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { enabledCapabilityNames, request, remainingCapabilities };
|
||||
@@ -234,7 +163,8 @@ export function assertValidOptions(classifier: CapabilityClassifier, value: any)
|
||||
function getCapabilitiesToDisable(
|
||||
bundleId: BundleId,
|
||||
currentCapabilities: BundleIdCapability[],
|
||||
request: CapabilitiesRequest[]
|
||||
request: CapabilitiesRequest[],
|
||||
entitlements: JSONObject
|
||||
): { disabledCapabilityNames: string[]; request: CapabilitiesRequest[] } {
|
||||
if (Log.isDebug) {
|
||||
Log.log(
|
||||
@@ -257,9 +187,13 @@ function getCapabilitiesToDisable(
|
||||
}
|
||||
|
||||
if (existingCapability.attributes) {
|
||||
let adjustedType: string | undefined = existingCapability.attributes.capabilityType;
|
||||
if (!adjustedType) {
|
||||
adjustedType = existingCapability.id.replace(`${bundleId.id}_`, '');
|
||||
const adjustedType = getAdjustedCapabilityType(existingCapability, bundleId);
|
||||
if (
|
||||
adjustedType === CapabilityType.MDM_MANAGED_ASSOCIATED_DOMAINS &&
|
||||
entitlements[associatedDomainsCapabilityType.entitlement]
|
||||
) {
|
||||
// MDM Managed Associated Domains is a special case, it should not be disabled if Associated Domains is enabled.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only disable capabilities that we handle,
|
||||
@@ -287,579 +221,18 @@ function getCapabilitiesToDisable(
|
||||
return { disabledCapabilityNames, request };
|
||||
}
|
||||
|
||||
type CapabilityClassifier = {
|
||||
name: string;
|
||||
entitlement: string;
|
||||
capability: CapabilityType;
|
||||
validateOptions: (options: any) => boolean;
|
||||
getOptions: GetOptionsMethod;
|
||||
capabilityIdModel?: typeof MerchantId;
|
||||
capabilityIdPrefix?: string;
|
||||
options?: undefined;
|
||||
};
|
||||
|
||||
// NOTE(Bacon): From manually toggling values in Xcode and checking the git diff and network requests.
|
||||
// Last Updated: July 22nd, 2021
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements
|
||||
export const CapabilityMapping: CapabilityClassifier[] = [
|
||||
{
|
||||
name: 'HomeKit',
|
||||
entitlement: 'com.apple.developer.homekit',
|
||||
capability: CapabilityType.HOME_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Hotspot',
|
||||
entitlement: 'com.apple.developer.networking.HotspotConfiguration',
|
||||
capability: CapabilityType.HOT_SPOT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Multipath',
|
||||
entitlement: 'com.apple.developer.networking.multipath',
|
||||
capability: CapabilityType.MULTIPATH,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'SiriKit',
|
||||
entitlement: 'com.apple.developer.siri',
|
||||
capability: CapabilityType.SIRI_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Wireless Accessory Configuration',
|
||||
entitlement: 'com.apple.external-accessory.wireless-configuration',
|
||||
capability: CapabilityType.WIRELESS_ACCESSORY,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Extended Virtual Address Space',
|
||||
entitlement: 'com.apple.developer.kernel.extended-virtual-addressing',
|
||||
capability: CapabilityType.EXTENDED_VIRTUAL_ADDRESSING,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Access WiFi Information',
|
||||
entitlement: 'com.apple.developer.networking.wifi-info',
|
||||
capability: CapabilityType.ACCESS_WIFI,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Associated Domains',
|
||||
entitlement: 'com.apple.developer.associated-domains',
|
||||
capability: CapabilityType.ASSOCIATED_DOMAINS,
|
||||
validateOptions: validateStringArrayOptions,
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'AutoFill Credential Provider',
|
||||
entitlement: 'com.apple.developer.authentication-services.autofill-credential-provider',
|
||||
capability: CapabilityType.AUTO_FILL_CREDENTIAL,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'HealthKit',
|
||||
entitlement: 'com.apple.developer.healthkit',
|
||||
capability: CapabilityType.HEALTH_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
// {
|
||||
// // ?? -- adds UIRequiredDeviceCapabilities gamekit
|
||||
// // Always locked on in dev portal
|
||||
// name: 'Game Center',
|
||||
// entitlement: 'com.apple.developer.game-center',
|
||||
// capability: CapabilityType.GAME_CENTER,
|
||||
// validateOptions: validateBooleanOptions,
|
||||
// getOptions: getBooleanOptions,
|
||||
// },
|
||||
{
|
||||
name: 'App Groups',
|
||||
entitlement: 'com.apple.security.application-groups',
|
||||
capability: CapabilityType.APP_GROUP,
|
||||
// Ex: ['group.CY-A5149AC2-49FC-11E7-B3F3-0335A16FFB8D.com.cydia.Extender']
|
||||
validateOptions: validatePrefixedStringArrayOptions('group.'),
|
||||
getOptions: getDefinedOptions,
|
||||
capabilityIdModel: AppGroup,
|
||||
capabilityIdPrefix: 'group.',
|
||||
},
|
||||
{
|
||||
name: 'Apple Pay Payment Processing',
|
||||
entitlement: 'com.apple.developer.in-app-payments',
|
||||
capability: CapabilityType.APPLE_PAY,
|
||||
// Ex: ['merchant.com.example.development']
|
||||
validateOptions: validatePrefixedStringArrayOptions('merchant.'),
|
||||
getOptions: getDefinedOptions,
|
||||
capabilityIdModel: MerchantId,
|
||||
capabilityIdPrefix: 'merchant.',
|
||||
},
|
||||
{
|
||||
name: 'iCloud',
|
||||
entitlement: 'com.apple.developer.icloud-container-identifiers',
|
||||
capability: CapabilityType.ICLOUD,
|
||||
validateOptions: validatePrefixedStringArrayOptions('iCloud.'),
|
||||
// Only supports Xcode +6, 5 could be added if needed.
|
||||
getOptions: getDefinedOptions,
|
||||
capabilityIdModel: CloudContainer,
|
||||
capabilityIdPrefix: 'iCloud.',
|
||||
},
|
||||
{
|
||||
name: 'ClassKit',
|
||||
entitlement: 'com.apple.developer.ClassKit-environment',
|
||||
capability: CapabilityType.CLASS_KIT,
|
||||
validateOptions: validateDevProdString,
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'Communication Notifications',
|
||||
entitlement: 'com.apple.developer.usernotifications.communication',
|
||||
capability: CapabilityType.USER_NOTIFICATIONS_COMMUNICATION,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Time Sensitive Notifications',
|
||||
entitlement: 'com.apple.developer.usernotifications.time-sensitive',
|
||||
capability: CapabilityType.USER_NOTIFICATIONS_TIME_SENSITIVE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Group Activities',
|
||||
entitlement: 'com.apple.developer.group-session',
|
||||
capability: CapabilityType.GROUP_ACTIVITIES,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
name: 'Family Controls',
|
||||
entitlement: 'com.apple.developer.family-controls',
|
||||
capability: CapabilityType.FAMILY_CONTROLS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_default-data-protection
|
||||
name: 'Data Protection',
|
||||
entitlement: 'com.apple.developer.default-data-protection',
|
||||
capability: CapabilityType.DATA_PROTECTION,
|
||||
validateOptions: createValidateStringOptions([
|
||||
'NSFileProtectionCompleteUnlessOpen',
|
||||
'NSFileProtectionCompleteUntilFirstUserAuthentication',
|
||||
'NSFileProtectionNone',
|
||||
'NSFileProtectionComplete',
|
||||
]),
|
||||
getOptions(entitlement) {
|
||||
if (entitlement === 'NSFileProtectionComplete') {
|
||||
return CapabilityTypeDataProtectionOption.COMPLETE_PROTECTION;
|
||||
} else if (entitlement === 'NSFileProtectionCompleteUnlessOpen') {
|
||||
return CapabilityTypeDataProtectionOption.PROTECTED_UNLESS_OPEN;
|
||||
} else if (entitlement === 'NSFileProtectionCompleteUntilFirstUserAuthentication') {
|
||||
return CapabilityTypeDataProtectionOption.PROTECTED_UNTIL_FIRST_USER_AUTH;
|
||||
}
|
||||
// NSFileProtectionNone isn't documented, not sure how to handle
|
||||
function getAdjustedCapabilityType(
|
||||
existingCapability: BundleIdCapability,
|
||||
bundleId: BundleId
|
||||
): string {
|
||||
let adjustedType: string | undefined = existingCapability.attributes.capabilityType;
|
||||
if (!adjustedType) {
|
||||
if (process.env.NODE_ENV === 'test' && !existingCapability.id.startsWith(`${bundleId.id}_`)) {
|
||||
throw new Error(
|
||||
`iOS entitlement "com.apple.developer.default-data-protection" is using unsupported value "${entitlement}"`
|
||||
`Capability ID "${existingCapability.id}" does not start with the bundle ID "${bundleId.id}_". This is likely a test setup issue.`
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Deprecated
|
||||
name: 'Inter-App Audio',
|
||||
entitlement: 'inter-app-audio',
|
||||
capability: CapabilityType.INTER_APP_AUDIO,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_networkextension
|
||||
name: 'Network Extensions',
|
||||
entitlement: 'com.apple.developer.networking.networkextension',
|
||||
capability: CapabilityType.NETWORK_EXTENSIONS,
|
||||
validateOptions: createValidateStringArrayOptions([
|
||||
'dns-proxy',
|
||||
'app-proxy-provider',
|
||||
'content-filter-provider',
|
||||
'packet-tunnel-provider',
|
||||
'dns-proxy-systemextension',
|
||||
'app-proxy-provider-systemextension',
|
||||
'content-filter-provider-systemextension',
|
||||
'packet-tunnel-provider-systemextension',
|
||||
'dns-settings',
|
||||
'app-push-provider',
|
||||
]),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_nfc_readersession_formats
|
||||
name: 'NFC Tag Reading',
|
||||
entitlement: 'com.apple.developer.nfc.readersession.formats',
|
||||
capability: CapabilityType.NFC_TAG_READING,
|
||||
// Technically it seems only `TAG` is allowed, but many apps and packages tell users to add `NDEF` as well.
|
||||
validateOptions: createValidateStringArrayOptions(['NDEF', 'TAG']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'Personal VPN',
|
||||
entitlement: 'com.apple.developer.networking.vpn.api',
|
||||
capability: CapabilityType.PERSONAL_VPN,
|
||||
// Ex: ['allow-vpn']
|
||||
validateOptions: createValidateStringArrayOptions(['allow-vpn']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_vpn_api
|
||||
name: 'Push Notifications',
|
||||
// com.apple.developer.aps-environment
|
||||
entitlement: 'aps-environment',
|
||||
capability: CapabilityType.PUSH_NOTIFICATIONS,
|
||||
validateOptions: validateDevProdString,
|
||||
getOptions(entitlement, _entitlementsJson, { usesBroadcastPushNotifications }) {
|
||||
const option = entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
|
||||
if (option === CapabilityTypeOption.ON && usesBroadcastPushNotifications) {
|
||||
return CapabilityTypePushNotificationsOption.PUSH_NOTIFICATION_FEATURE_BROADCAST;
|
||||
}
|
||||
return option;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Wallet',
|
||||
entitlement: 'com.apple.developer.pass-type-identifiers',
|
||||
capability: CapabilityType.WALLET,
|
||||
// Ex: ['$(TeamIdentifierPrefix)*']
|
||||
validateOptions: validateStringArrayOptions,
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'Sign In with Apple',
|
||||
entitlement: 'com.apple.developer.applesignin',
|
||||
capability: CapabilityType.APPLE_ID_AUTH,
|
||||
// Ex: ['Default']
|
||||
validateOptions: createValidateStringArrayOptions(['Default']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'Fonts',
|
||||
entitlement: 'com.apple.developer.user-fonts',
|
||||
capability: CapabilityType.FONT_INSTALLATION,
|
||||
validateOptions: createValidateStringArrayOptions(['app-usage', 'system-installation']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'Apple Pay Later Merchandising',
|
||||
entitlement: 'com.apple.developer.pay-later-merchandising',
|
||||
capability: CapabilityType.APPLE_PAY_LATER_MERCHANDISING,
|
||||
validateOptions: createValidateStringArrayOptions(['payinfour-merchandising']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
name: 'Sensitive Content Analysis',
|
||||
entitlement: 'com.apple.developer.sensitivecontentanalysis.client',
|
||||
capability: CapabilityType.SENSITIVE_CONTENT_ANALYSIS,
|
||||
validateOptions: createValidateStringArrayOptions(['analysis']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
// Not in Xcode
|
||||
// https://developer-mdn.apple.com/documentation/devicecheck/preparing_to_use_the_app_attest_service
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_devicecheck_appattest-environment
|
||||
name: 'App Attest',
|
||||
entitlement: 'com.apple.developer.devicecheck.appattest-environment',
|
||||
capability: CapabilityType.APP_ATTEST,
|
||||
validateOptions: validateDevProdString,
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.coremedia.hls.low-latency',
|
||||
name: 'Low Latency HLS',
|
||||
capability: CapabilityType.HLS_LOW_LATENCY,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.associated-domains.mdm-managed',
|
||||
name: 'MDM Managed Associated Domains',
|
||||
capability: CapabilityType.MDM_MANAGED_ASSOCIATED_DOMAINS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.fileprovider.testing-mode',
|
||||
name: 'FileProvider TestingMode',
|
||||
capability: CapabilityType.FILE_PROVIDER_TESTING_MODE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.healthkit.recalibrate-estimates',
|
||||
name: 'Recalibrate Estimates',
|
||||
capability: CapabilityType.HEALTH_KIT_RECALIBRATE_ESTIMATES,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.maps',
|
||||
name: 'Maps',
|
||||
capability: CapabilityType.MAPS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.user-management',
|
||||
name: 'TV Services',
|
||||
capability: CapabilityType.USER_MANAGEMENT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.networking.custom-protocol',
|
||||
name: 'Custom Network Protocol',
|
||||
capability: CapabilityType.NETWORK_CUSTOM_PROTOCOL,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.system-extension.install',
|
||||
name: 'System Extension',
|
||||
capability: CapabilityType.SYSTEM_EXTENSION_INSTALL,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.push-to-talk',
|
||||
name: 'Push to Talk',
|
||||
capability: CapabilityType.PUSH_TO_TALK,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.transport.usb',
|
||||
name: 'DriverKit USB Transport (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_USB_TRANSPORT_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.kernel.increased-memory-limit',
|
||||
name: 'Increased Memory Limit',
|
||||
capability: CapabilityType.INCREASED_MEMORY_LIMIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.communicates-with-drivers',
|
||||
name: 'Communicates with Drivers',
|
||||
capability: CapabilityType.DRIVER_KIT_COMMUNICATES_WITH_DRIVERS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.media-device-discovery-extension',
|
||||
name: 'Media Device Discovery',
|
||||
capability: CapabilityType.MEDIA_DEVICE_DISCOVERY,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.allow-third-party-userclients',
|
||||
name: 'DriverKit Allow Third Party UserClients',
|
||||
capability: CapabilityType.DRIVER_KIT_ALLOW_THIRD_PARTY_USER_CLIENTS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.weatherkit',
|
||||
name: 'WeatherKit',
|
||||
capability: CapabilityType.WEATHER_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.on-demand-install-capable',
|
||||
name: 'On Demand Install Capable for App Clip Extensions',
|
||||
capability: CapabilityType.ON_DEMAND_INSTALL_EXTENSIONS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.scsicontroller',
|
||||
name: 'DriverKit Family SCSIController (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_SCSI_CONTROLLER_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.serial',
|
||||
name: 'DriverKit Family Serial (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_SERIAL_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.networking',
|
||||
name: 'DriverKit Family Networking (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_NETWORKING_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.hid.eventservice',
|
||||
name: 'DriverKit Family HID EventService (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_EVENT_SERVICE_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.hid.device',
|
||||
name: 'DriverKit Family HID Device (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_DEVICE_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit',
|
||||
name: 'DriverKit for Development',
|
||||
capability: CapabilityType.DRIVER_KIT_PUBLIC,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.transport.hid',
|
||||
name: 'DriverKit Transport HID (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_TRANSPORT_HID_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.audio',
|
||||
name: 'DriverKit Family Audio (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_AUDIO_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.shared-with-you',
|
||||
name: 'Shared with You',
|
||||
capability: CapabilityType.SHARED_WITH_YOU,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.shared-with-you.collaboration',
|
||||
name: 'Messages Collaboration',
|
||||
capability: CapabilityType.MESSAGES_COLLABORATION,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.submerged-shallow-depth-and-pressure',
|
||||
name: 'Shallow Depth and Pressure',
|
||||
capability: CapabilityType.SHALLOW_DEPTH_PRESSURE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.proximity-reader.identity.display',
|
||||
name: 'Tap to Present ID on iPhone (Display Only)',
|
||||
capability: CapabilityType.TAP_TO_DISPLAY_ID,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.proximity-reader.payment.acceptance',
|
||||
name: 'Tap to Pay on iPhone',
|
||||
capability: CapabilityType.TAP_TO_PAY_ON_IPHONE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.matter.allow-setup-payload',
|
||||
name: 'Matter Allow Setup Payload',
|
||||
capability: CapabilityType.MATTER_ALLOW_SETUP_PAYLOAD,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getOptions: getBooleanOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.journal.allow',
|
||||
name: 'Journaling Suggestions',
|
||||
capability: CapabilityType.JOURNALING_SUGGESTIONS,
|
||||
validateOptions: createValidateStringArrayOptions(['suggestions']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.managed-app-distribution.install-ui',
|
||||
name: 'Managed App Installation UI',
|
||||
capability: CapabilityType.MANAGED_APP_INSTALLATION_UI,
|
||||
validateOptions: createValidateStringArrayOptions(['managed-app']),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.networking.slicing.appcategory',
|
||||
name: '5G Network Slicing',
|
||||
capability: CapabilityType.NETWORK_SLICING,
|
||||
validateOptions: createValidateStringArrayOptions([
|
||||
'gaming-6014',
|
||||
'communication-9000',
|
||||
'streaming-9001',
|
||||
]),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.networking.slicing.trafficcategory',
|
||||
name: '5G Network Slicing',
|
||||
capability: CapabilityType.NETWORK_SLICING,
|
||||
validateOptions: createValidateStringArrayOptions([
|
||||
'defaultslice-1',
|
||||
'video-2',
|
||||
'background-3',
|
||||
'voice-4',
|
||||
'callsignaling-5',
|
||||
'responsivedata-6',
|
||||
'avstreaming-7',
|
||||
'responsiveav-8',
|
||||
]),
|
||||
getOptions: getDefinedOptions,
|
||||
},
|
||||
// VMNET
|
||||
|
||||
// These don't appear to have entitlements, so it's unclear how we can automatically enable / disable them at this time.
|
||||
// TODO: Maybe add a warning about manually enabling features?
|
||||
// ?? -- links `StoreKit.framework`
|
||||
// Always locked on in dev portal
|
||||
// {
|
||||
// entitlement: '',
|
||||
// name: 'In-App Purchase',
|
||||
// capability: CapabilityType.IN_APP_PURCHASE,
|
||||
// },
|
||||
// {
|
||||
// entitlement: '',
|
||||
// name: 'HLS Interstitial Previews',
|
||||
// capability: 'HLS_INTERSTITIAL_PREVIEW',
|
||||
// },
|
||||
|
||||
// "Game Controllers" doesn't appear in Dev Portal but it does show up in Xcode,
|
||||
// toggling in Xcode causes no network request to be sent.
|
||||
// Therefore it seems that it's a mistake in Xcode,
|
||||
// the key `GCSupportsControllerUserInteraction` just needs to be present in Info.plist
|
||||
|
||||
// "Keychain Sharing" doesn't appear in Dev Portal but it does show up in Xcode,
|
||||
// toggling in Xcode causes no network request to be sent.
|
||||
// Adding to Xcode puts 'keychain-access-groups' into the entitlements so
|
||||
// it's not clear if it needs to be updated.
|
||||
|
||||
// "Contact Notes" requires the user to ask Apple in a form:
|
||||
// https://developer-mdn.apple.com/contact/request/contact-note-field
|
||||
// com.apple.developer.contacts.notes: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_contacts_notes/
|
||||
|
||||
// "Exposure Notification" requires the user to ask Apple in a form:
|
||||
// https://developer-mdn.apple.com/contact/request/exposure-notification-entitlement
|
||||
// com.apple.developer.exposure-notification: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_exposure-notification/
|
||||
];
|
||||
}
|
||||
adjustedType = existingCapability.id.replace(`${bundleId.id}_`, '');
|
||||
}
|
||||
return adjustedType;
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { BundleId, CapabilityTypeOption } from '@expo/apple-utils';
|
||||
import { JSONObject } from '@expo/json-file';
|
||||
|
||||
import {
|
||||
CapabilityMapping,
|
||||
EXPO_NO_CAPABILITY_SYNC,
|
||||
assertValidOptions,
|
||||
} from './bundleIdCapabilities';
|
||||
import { EXPO_NO_CAPABILITY_SYNC, assertValidOptions } from './bundleIdCapabilities';
|
||||
import { CapabilityMapping } from './capabilityList';
|
||||
import Log from '../../../log';
|
||||
import { isAppStoreConnectTokenOnlyContext } from '../utils/authType';
|
||||
|
||||
@@ -42,6 +39,7 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
|
||||
const createdIds: string[] = [];
|
||||
const linkedIds: string[] = [];
|
||||
// these are only APPLE_PAY, ICLOUD, APP_GROUPS
|
||||
const CapabilityIdMapping = CapabilityMapping.filter(capability => capability.capabilityIdModel);
|
||||
|
||||
const updateRequest: UpdateCapabilityRequest = [];
|
||||
@@ -78,51 +76,53 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
// A list of server IDs for linking.
|
||||
const capabilityIdOpaqueIds = [];
|
||||
|
||||
const capabilitiesWithoutRemoteModels = capabilityIds.filter(
|
||||
localId => existingIds.find(model => model.attributes.identifier === localId) === undefined
|
||||
);
|
||||
// Iterate through all the local IDs and see if they exist on the server.
|
||||
for (const localId of capabilityIds) {
|
||||
let remoteIdModel = existingIds.find(model => model.attributes.identifier === localId);
|
||||
for (const localId of capabilitiesWithoutRemoteModels) {
|
||||
let remoteIdModel = undefined;
|
||||
|
||||
// If a remote ID exists, then create it.
|
||||
if (!remoteIdModel) {
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
|
||||
}
|
||||
try {
|
||||
remoteIdModel = await CapabilityModel.createAsync(bundleId.context, {
|
||||
identifier: localId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Add a more helpful error message.
|
||||
error.message += `\n\nRemove the value '${localId}' from the array '${classifier.entitlement}' in the iOS project entitlements.\nIf you know that the ID is registered to one of your apps, try again with a different Apple account.`;
|
||||
throw error;
|
||||
}
|
||||
// Create a list of newly created IDs for displaying in the CLI.
|
||||
createdIds.push(localId);
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Created capability ID: ${remoteIdModel.id}`);
|
||||
}
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
|
||||
}
|
||||
try {
|
||||
remoteIdModel = await CapabilityModel.createAsync(bundleId.context, {
|
||||
identifier: localId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Add a more helpful error message.
|
||||
error.message += `\n\nRemove the value '${localId}' from the array '${classifier.entitlement}' in the iOS project entitlements.\nIf you know that the ID is registered to one of your apps, try again with a different Apple account.`;
|
||||
throw error;
|
||||
}
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Linking ID to ${CapabilityModel.type}: ${localId} (${remoteIdModel.id})`);
|
||||
Log.log(`Created capability ID: ${remoteIdModel.id}`);
|
||||
}
|
||||
// Create a list of linked IDs for displaying in the CLI.
|
||||
// add to a list of newly created IDs for displaying in the CLI.
|
||||
createdIds.push(localId);
|
||||
// add to a list of linked IDs for displaying in the CLI.
|
||||
linkedIds.push(remoteIdModel.attributes.identifier);
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
}
|
||||
|
||||
updateRequest.push({
|
||||
capabilityType: classifier.capability,
|
||||
option: CapabilityTypeOption.ON,
|
||||
relationships: {
|
||||
// One of: `merchantIds`, `appGroups`, `cloudContainers`.
|
||||
[CapabilityModel.type]: capabilityIdOpaqueIds,
|
||||
},
|
||||
});
|
||||
if (capabilityIdOpaqueIds.length) {
|
||||
updateRequest.push({
|
||||
capabilityType: classifier.capability,
|
||||
option: CapabilityTypeOption.ON,
|
||||
relationships: {
|
||||
// One of: `merchantIds`, `appGroups`, `cloudContainers`.
|
||||
[CapabilityModel.type]: capabilityIdOpaqueIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updateRequest.length) {
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Updating bundle identifier with capability identifiers:`, updateRequest);
|
||||
Log.log(
|
||||
`Updating bundle identifier with capability identifiers:`,
|
||||
JSON.stringify(updateRequest, null, 2)
|
||||
);
|
||||
}
|
||||
await bundleId.updateBundleIdCapabilityAsync(updateRequest);
|
||||
} else if (Log.isDebug) {
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
import {
|
||||
AppGroup,
|
||||
BundleIdCapability,
|
||||
CapabilityType,
|
||||
CapabilityTypeDataProtectionOption,
|
||||
CapabilityTypeOption,
|
||||
CapabilityTypePushNotificationsOption,
|
||||
CloudContainer,
|
||||
MerchantId,
|
||||
} from '@expo/apple-utils';
|
||||
import { JSONObject, JSONValue } from '@expo/json-file';
|
||||
import { invariant } from 'graphql/jsutils/invariant';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
const validateBooleanOptions = (options: any): boolean => {
|
||||
return typeof options === 'boolean';
|
||||
};
|
||||
|
||||
const validatePrefixedStringArrayOptions =
|
||||
(prefix: string) =>
|
||||
(options: any): boolean => {
|
||||
return (
|
||||
Array.isArray(options) &&
|
||||
options.every(option => typeof option === 'string' && option.startsWith(prefix))
|
||||
);
|
||||
};
|
||||
|
||||
const validateStringArrayOptions = (options: any): boolean => {
|
||||
return Array.isArray(options) && options.every(option => typeof option === 'string');
|
||||
};
|
||||
|
||||
const createValidateStringOptions =
|
||||
(allowed: string[]) =>
|
||||
(options: any): boolean => {
|
||||
return allowed.includes(options);
|
||||
};
|
||||
|
||||
const createValidateStringArrayOptions =
|
||||
(allowed: string[]) =>
|
||||
(options: any): boolean => {
|
||||
return Array.isArray(options) && options.every(option => allowed.includes(option));
|
||||
};
|
||||
|
||||
const validateDevProdString = createValidateStringOptions(['development', 'production']);
|
||||
|
||||
const skipOp = { op: 'skip' } as const;
|
||||
const disableOp = { op: 'disable' } as const;
|
||||
const enableOp = { op: 'enable', option: CapabilityTypeOption.ON } as const;
|
||||
|
||||
type GetSyncOperation = (opts: {
|
||||
existingRemote: BundleIdCapability | null;
|
||||
entitlementValue: JSONValue;
|
||||
entitlements: JSONObject;
|
||||
additionalOptions: {
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
};
|
||||
}) => { op: 'enable'; option: JSONValue } | typeof skipOp | typeof disableOp;
|
||||
|
||||
const getBooleanSyncOperation: GetSyncOperation = ({ existingRemote, entitlementValue }) => {
|
||||
if (existingRemote) {
|
||||
// If the remote capability exists, we only disable it if the value is false.
|
||||
// Otherwise, we skip it.
|
||||
if ('enabled' in existingRemote.attributes) {
|
||||
return existingRemote.attributes['enabled'] === entitlementValue ? skipOp : disableOp;
|
||||
}
|
||||
return existingRemote.attributes.settings === null ? skipOp : enableOp;
|
||||
} else {
|
||||
return entitlementValue === false ? skipOp : enableOp;
|
||||
}
|
||||
};
|
||||
|
||||
const getDefinedValueSyncOperation: GetSyncOperation = ({ existingRemote }) => {
|
||||
if (!existingRemote) {
|
||||
// If the remote capability does not exist, we create it.
|
||||
return enableOp;
|
||||
}
|
||||
return existingRemote.attributes.settings === null ? skipOp : enableOp;
|
||||
};
|
||||
|
||||
const capabilityWithSettingsSyncOperation: GetSyncOperation = ({
|
||||
existingRemote,
|
||||
entitlementValue,
|
||||
}) => {
|
||||
// settings are defined for only a few capabilities: iCloud, data protection and Sign in with Apple
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/capabilitysetting
|
||||
if (!existingRemote) {
|
||||
return enableOp;
|
||||
}
|
||||
invariant(
|
||||
'enabled' in existingRemote.attributes,
|
||||
`Expected "enabled" attribute in ${existingRemote.id}`
|
||||
);
|
||||
const existingEnabled = existingRemote.attributes.enabled === true;
|
||||
const newOption = entitlementValue ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
|
||||
|
||||
// If both are enabled and the existing one has settings, skip the update
|
||||
if (existingEnabled && entitlementValue && existingRemote.attributes.settings) {
|
||||
return skipOp;
|
||||
}
|
||||
|
||||
// If the states don't match, we need to update
|
||||
const newEnabled = newOption === CapabilityTypeOption.ON;
|
||||
return existingEnabled === newEnabled ? skipOp : { op: 'enable', option: newOption };
|
||||
};
|
||||
|
||||
export type CapabilityClassifier = {
|
||||
name: string;
|
||||
entitlement: string;
|
||||
capability: CapabilityType;
|
||||
validateOptions: (options: any) => boolean;
|
||||
capabilityIdModel?: typeof MerchantId;
|
||||
getSyncOperation: GetSyncOperation;
|
||||
capabilityIdPrefix?: string;
|
||||
options?: undefined;
|
||||
};
|
||||
|
||||
// NOTE(Bacon): From manually toggling values in Xcode and checking the git diff and network requests.
|
||||
// Last Updated: July 22nd, 2021
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements
|
||||
export const CapabilityMapping: CapabilityClassifier[] = [
|
||||
{
|
||||
name: 'HomeKit',
|
||||
entitlement: 'com.apple.developer.homekit',
|
||||
capability: CapabilityType.HOME_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Hotspot',
|
||||
entitlement: 'com.apple.developer.networking.HotspotConfiguration',
|
||||
capability: CapabilityType.HOT_SPOT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Multipath',
|
||||
entitlement: 'com.apple.developer.networking.multipath',
|
||||
capability: CapabilityType.MULTIPATH,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'SiriKit',
|
||||
entitlement: 'com.apple.developer.siri',
|
||||
capability: CapabilityType.SIRI_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Wireless Accessory Configuration',
|
||||
entitlement: 'com.apple.external-accessory.wireless-configuration',
|
||||
capability: CapabilityType.WIRELESS_ACCESSORY,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Extended Virtual Address Space',
|
||||
entitlement: 'com.apple.developer.kernel.extended-virtual-addressing',
|
||||
capability: CapabilityType.EXTENDED_VIRTUAL_ADDRESSING,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Access WiFi Information',
|
||||
entitlement: 'com.apple.developer.networking.wifi-info',
|
||||
capability: CapabilityType.ACCESS_WIFI,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Associated Domains',
|
||||
entitlement: 'com.apple.developer.associated-domains',
|
||||
capability: CapabilityType.ASSOCIATED_DOMAINS,
|
||||
validateOptions: validateStringArrayOptions,
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'AutoFill Credential Provider',
|
||||
entitlement: 'com.apple.developer.authentication-services.autofill-credential-provider',
|
||||
capability: CapabilityType.AUTO_FILL_CREDENTIAL,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'HealthKit',
|
||||
entitlement: 'com.apple.developer.healthkit',
|
||||
capability: CapabilityType.HEALTH_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
// {
|
||||
// // ?? -- adds UIRequiredDeviceCapabilities gamekit
|
||||
// // Always locked on in dev portal
|
||||
// name: 'Game Center',
|
||||
// entitlement: 'com.apple.developer.game-center',
|
||||
// capability: CapabilityType.GAME_CENTER,
|
||||
// validateOptions: validateBooleanOptions,
|
||||
// getSyncOperation: getBooleanSyncOperation,
|
||||
// },
|
||||
{
|
||||
name: 'App Groups',
|
||||
entitlement: 'com.apple.security.application-groups',
|
||||
capability: CapabilityType.APP_GROUP,
|
||||
// Ex: ['group.CY-A5149AC2-49FC-11E7-B3F3-0335A16FFB8D.com.cydia.Extender']
|
||||
validateOptions: validatePrefixedStringArrayOptions('group.'),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
capabilityIdModel: AppGroup,
|
||||
capabilityIdPrefix: 'group.',
|
||||
},
|
||||
{
|
||||
name: 'Apple Pay Payment Processing',
|
||||
entitlement: 'com.apple.developer.in-app-payments',
|
||||
capability: CapabilityType.APPLE_PAY,
|
||||
// Ex: ['merchant.com.example.development']
|
||||
validateOptions: validatePrefixedStringArrayOptions('merchant.'),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
capabilityIdModel: MerchantId,
|
||||
capabilityIdPrefix: 'merchant.',
|
||||
},
|
||||
{
|
||||
name: 'iCloud',
|
||||
entitlement: 'com.apple.developer.icloud-container-identifiers',
|
||||
capability: CapabilityType.ICLOUD,
|
||||
validateOptions: validatePrefixedStringArrayOptions('iCloud.'),
|
||||
// Only supports Xcode +6
|
||||
getSyncOperation: capabilityWithSettingsSyncOperation,
|
||||
capabilityIdModel: CloudContainer,
|
||||
capabilityIdPrefix: 'iCloud.',
|
||||
},
|
||||
{
|
||||
name: 'ClassKit',
|
||||
entitlement: 'com.apple.developer.ClassKit-environment',
|
||||
capability: CapabilityType.CLASS_KIT,
|
||||
validateOptions: validateDevProdString,
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Communication Notifications',
|
||||
entitlement: 'com.apple.developer.usernotifications.communication',
|
||||
capability: CapabilityType.USER_NOTIFICATIONS_COMMUNICATION,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Time Sensitive Notifications',
|
||||
entitlement: 'com.apple.developer.usernotifications.time-sensitive',
|
||||
capability: CapabilityType.USER_NOTIFICATIONS_TIME_SENSITIVE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Group Activities',
|
||||
entitlement: 'com.apple.developer.group-session',
|
||||
capability: CapabilityType.GROUP_ACTIVITIES,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Family Controls',
|
||||
entitlement: 'com.apple.developer.family-controls',
|
||||
capability: CapabilityType.FAMILY_CONTROLS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_default-data-protection
|
||||
name: 'Data Protection',
|
||||
entitlement: 'com.apple.developer.default-data-protection',
|
||||
capability: CapabilityType.DATA_PROTECTION,
|
||||
validateOptions: createValidateStringOptions([
|
||||
'NSFileProtectionCompleteUnlessOpen',
|
||||
'NSFileProtectionCompleteUntilFirstUserAuthentication',
|
||||
'NSFileProtectionNone',
|
||||
'NSFileProtectionComplete',
|
||||
]),
|
||||
getSyncOperation: ({ existingRemote, entitlementValue: entitlement }) => {
|
||||
const newValue = (() => {
|
||||
if (entitlement === 'NSFileProtectionComplete') {
|
||||
return CapabilityTypeDataProtectionOption.COMPLETE_PROTECTION;
|
||||
} else if (entitlement === 'NSFileProtectionCompleteUnlessOpen') {
|
||||
return CapabilityTypeDataProtectionOption.PROTECTED_UNLESS_OPEN;
|
||||
} else if (entitlement === 'NSFileProtectionCompleteUntilFirstUserAuthentication') {
|
||||
return CapabilityTypeDataProtectionOption.PROTECTED_UNTIL_FIRST_USER_AUTH;
|
||||
}
|
||||
// NSFileProtectionNone isn't documented, not sure how to handle
|
||||
throw new Error(
|
||||
`iOS entitlement "com.apple.developer.default-data-protection" is using unsupported value "${entitlement}"`
|
||||
);
|
||||
})();
|
||||
const enableOp = { op: 'enable', option: newValue } as const;
|
||||
if (!existingRemote) {
|
||||
return enableOp;
|
||||
}
|
||||
invariant(existingRemote.attributes.settings?.[0].key === 'DATA_PROTECTION_PERMISSION_LEVEL');
|
||||
const oldValue = existingRemote.attributes.settings[0]?.options?.[0]?.key;
|
||||
return oldValue === newValue ? skipOp : enableOp;
|
||||
},
|
||||
},
|
||||
{
|
||||
// Deprecated
|
||||
name: 'Inter-App Audio',
|
||||
entitlement: 'inter-app-audio',
|
||||
capability: CapabilityType.INTER_APP_AUDIO,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_networkextension
|
||||
name: 'Network Extensions',
|
||||
entitlement: 'com.apple.developer.networking.networkextension',
|
||||
capability: CapabilityType.NETWORK_EXTENSIONS,
|
||||
validateOptions: createValidateStringArrayOptions([
|
||||
'dns-proxy',
|
||||
'app-proxy-provider',
|
||||
'content-filter-provider',
|
||||
'packet-tunnel-provider',
|
||||
'dns-proxy-systemextension',
|
||||
'app-proxy-provider-systemextension',
|
||||
'content-filter-provider-systemextension',
|
||||
'packet-tunnel-provider-systemextension',
|
||||
'dns-settings',
|
||||
'app-push-provider',
|
||||
]),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_nfc_readersession_formats
|
||||
name: 'NFC Tag Reading',
|
||||
entitlement: 'com.apple.developer.nfc.readersession.formats',
|
||||
capability: CapabilityType.NFC_TAG_READING,
|
||||
// Technically it seems only `TAG` is allowed, but many apps and packages tell users to add `NDEF` as well.
|
||||
validateOptions: createValidateStringArrayOptions(['NDEF', 'TAG']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Personal VPN',
|
||||
entitlement: 'com.apple.developer.networking.vpn.api',
|
||||
capability: CapabilityType.PERSONAL_VPN,
|
||||
// Ex: ['allow-vpn']
|
||||
validateOptions: createValidateStringArrayOptions(['allow-vpn']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_vpn_api
|
||||
name: 'Push Notifications',
|
||||
// com.apple.developer.aps-environment
|
||||
entitlement: 'aps-environment',
|
||||
capability: CapabilityType.PUSH_NOTIFICATIONS,
|
||||
validateOptions: validateDevProdString,
|
||||
getSyncOperation: ({
|
||||
existingRemote,
|
||||
entitlementValue: entitlement,
|
||||
additionalOptions: { usesBroadcastPushNotifications },
|
||||
}) => {
|
||||
const newOption = (() => {
|
||||
const option = entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
|
||||
if (option === CapabilityTypeOption.ON && usesBroadcastPushNotifications) {
|
||||
return CapabilityTypePushNotificationsOption.PUSH_NOTIFICATION_FEATURE_BROADCAST;
|
||||
}
|
||||
return option;
|
||||
})();
|
||||
const createOp = { op: 'enable', option: newOption } as const;
|
||||
if (!existingRemote) {
|
||||
// If the remote capability does not exist, we create it.
|
||||
return createOp;
|
||||
}
|
||||
// For push notifications, we should always update the capability if
|
||||
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
|
||||
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
|
||||
const noSettingsAttributes = existingRemote.attributes.settings == null;
|
||||
return !noSettingsAttributes === usesBroadcastPushNotifications ? skipOp : createOp;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Wallet',
|
||||
entitlement: 'com.apple.developer.pass-type-identifiers',
|
||||
capability: CapabilityType.WALLET,
|
||||
// Ex: ['$(TeamIdentifierPrefix)*']
|
||||
validateOptions: validateStringArrayOptions,
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Sign In with Apple',
|
||||
entitlement: 'com.apple.developer.applesignin',
|
||||
capability: CapabilityType.APPLE_ID_AUTH,
|
||||
// Ex: ['Default']
|
||||
validateOptions: createValidateStringArrayOptions(['Default']),
|
||||
getSyncOperation: capabilityWithSettingsSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Fonts',
|
||||
entitlement: 'com.apple.developer.user-fonts',
|
||||
capability: CapabilityType.FONT_INSTALLATION,
|
||||
validateOptions: createValidateStringArrayOptions(['app-usage', 'system-installation']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Apple Pay Later Merchandising',
|
||||
entitlement: 'com.apple.developer.pay-later-merchandising',
|
||||
capability: CapabilityType.APPLE_PAY_LATER_MERCHANDISING,
|
||||
validateOptions: createValidateStringArrayOptions(['payinfour-merchandising']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
name: 'Sensitive Content Analysis',
|
||||
entitlement: 'com.apple.developer.sensitivecontentanalysis.client',
|
||||
capability: CapabilityType.SENSITIVE_CONTENT_ANALYSIS,
|
||||
validateOptions: createValidateStringArrayOptions(['analysis']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
// Not in Xcode
|
||||
// https://developer-mdn.apple.com/documentation/devicecheck/preparing_to_use_the_app_attest_service
|
||||
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_devicecheck_appattest-environment
|
||||
name: 'App Attest',
|
||||
entitlement: 'com.apple.developer.devicecheck.appattest-environment',
|
||||
capability: CapabilityType.APP_ATTEST,
|
||||
validateOptions: validateDevProdString,
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.coremedia.hls.low-latency',
|
||||
name: 'Low Latency HLS',
|
||||
capability: CapabilityType.HLS_LOW_LATENCY,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.associated-domains.mdm-managed',
|
||||
name: 'MDM Managed Associated Domains',
|
||||
capability: CapabilityType.MDM_MANAGED_ASSOCIATED_DOMAINS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.fileprovider.testing-mode',
|
||||
name: 'FileProvider TestingMode',
|
||||
capability: CapabilityType.FILE_PROVIDER_TESTING_MODE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.healthkit.recalibrate-estimates',
|
||||
name: 'Recalibrate Estimates',
|
||||
capability: CapabilityType.HEALTH_KIT_RECALIBRATE_ESTIMATES,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.maps',
|
||||
name: 'Maps',
|
||||
capability: CapabilityType.MAPS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.user-management',
|
||||
name: 'TV Services',
|
||||
capability: CapabilityType.USER_MANAGEMENT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.networking.custom-protocol',
|
||||
name: 'Custom Network Protocol',
|
||||
capability: CapabilityType.NETWORK_CUSTOM_PROTOCOL,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.system-extension.install',
|
||||
name: 'System Extension',
|
||||
capability: CapabilityType.SYSTEM_EXTENSION_INSTALL,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.push-to-talk',
|
||||
name: 'Push to Talk',
|
||||
capability: CapabilityType.PUSH_TO_TALK,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.transport.usb',
|
||||
name: 'DriverKit USB Transport (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_USB_TRANSPORT_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.kernel.increased-memory-limit',
|
||||
name: 'Increased Memory Limit',
|
||||
capability: CapabilityType.INCREASED_MEMORY_LIMIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.communicates-with-drivers',
|
||||
name: 'Communicates with Drivers',
|
||||
capability: CapabilityType.DRIVER_KIT_COMMUNICATES_WITH_DRIVERS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.media-device-discovery-extension',
|
||||
name: 'Media Device Discovery',
|
||||
capability: CapabilityType.MEDIA_DEVICE_DISCOVERY,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.allow-third-party-userclients',
|
||||
name: 'DriverKit Allow Third Party UserClients',
|
||||
capability: CapabilityType.DRIVER_KIT_ALLOW_THIRD_PARTY_USER_CLIENTS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.weatherkit',
|
||||
name: 'WeatherKit',
|
||||
capability: CapabilityType.WEATHER_KIT,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.on-demand-install-capable',
|
||||
name: 'On Demand Install Capable for App Clip Extensions',
|
||||
capability: CapabilityType.ON_DEMAND_INSTALL_EXTENSIONS,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.scsicontroller',
|
||||
name: 'DriverKit Family SCSIController (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_SCSI_CONTROLLER_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.serial',
|
||||
name: 'DriverKit Family Serial (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_SERIAL_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.networking',
|
||||
name: 'DriverKit Family Networking (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_NETWORKING_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.hid.eventservice',
|
||||
name: 'DriverKit Family HID EventService (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_EVENT_SERVICE_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.hid.device',
|
||||
name: 'DriverKit Family HID Device (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_DEVICE_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit',
|
||||
name: 'DriverKit for Development',
|
||||
capability: CapabilityType.DRIVER_KIT_PUBLIC,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.transport.hid',
|
||||
name: 'DriverKit Transport HID (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_TRANSPORT_HID_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.driverkit.family.audio',
|
||||
name: 'DriverKit Family Audio (development)',
|
||||
capability: CapabilityType.DRIVER_KIT_FAMILY_AUDIO_PUB,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.shared-with-you',
|
||||
name: 'Shared with You',
|
||||
capability: CapabilityType.SHARED_WITH_YOU,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.shared-with-you.collaboration',
|
||||
name: 'Messages Collaboration',
|
||||
capability: CapabilityType.MESSAGES_COLLABORATION,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.submerged-shallow-depth-and-pressure',
|
||||
name: 'Shallow Depth and Pressure',
|
||||
capability: CapabilityType.SHALLOW_DEPTH_PRESSURE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.proximity-reader.identity.display',
|
||||
name: 'Tap to Present ID on iPhone (Display Only)',
|
||||
capability: CapabilityType.TAP_TO_DISPLAY_ID,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.proximity-reader.payment.acceptance',
|
||||
name: 'Tap to Pay on iPhone',
|
||||
capability: CapabilityType.TAP_TO_PAY_ON_IPHONE,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.matter.allow-setup-payload',
|
||||
name: 'Matter Allow Setup Payload',
|
||||
capability: CapabilityType.MATTER_ALLOW_SETUP_PAYLOAD,
|
||||
validateOptions: validateBooleanOptions,
|
||||
getSyncOperation: getBooleanSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.journal.allow',
|
||||
name: 'Journaling Suggestions',
|
||||
capability: CapabilityType.JOURNALING_SUGGESTIONS,
|
||||
validateOptions: createValidateStringArrayOptions(['suggestions']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.managed-app-distribution.install-ui',
|
||||
name: 'Managed App Installation UI',
|
||||
capability: CapabilityType.MANAGED_APP_INSTALLATION_UI,
|
||||
validateOptions: createValidateStringArrayOptions(['managed-app']),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.networking.slicing.appcategory',
|
||||
name: '5G Network Slicing',
|
||||
capability: CapabilityType.NETWORK_SLICING,
|
||||
validateOptions: createValidateStringArrayOptions([
|
||||
'gaming-6014',
|
||||
'communication-9000',
|
||||
'streaming-9001',
|
||||
]),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
{
|
||||
entitlement: 'com.apple.developer.networking.slicing.trafficcategory',
|
||||
name: '5G Network Slicing',
|
||||
capability: CapabilityType.NETWORK_SLICING,
|
||||
validateOptions: createValidateStringArrayOptions([
|
||||
'defaultslice-1',
|
||||
'video-2',
|
||||
'background-3',
|
||||
'voice-4',
|
||||
'callsignaling-5',
|
||||
'responsivedata-6',
|
||||
'avstreaming-7',
|
||||
'responsiveav-8',
|
||||
]),
|
||||
getSyncOperation: getDefinedValueSyncOperation,
|
||||
},
|
||||
// VMNET
|
||||
|
||||
// These don't appear to have entitlements, so it's unclear how we can automatically enable / disable them at this time.
|
||||
// TODO: Maybe add a warning about manually enabling features?
|
||||
// ?? -- links `StoreKit.framework`
|
||||
// Always locked on in dev portal
|
||||
// {
|
||||
// entitlement: '',
|
||||
// name: 'In-App Purchase',
|
||||
// capability: CapabilityType.IN_APP_PURCHASE,
|
||||
// },
|
||||
// {
|
||||
// entitlement: '',
|
||||
// name: 'HLS Interstitial Previews',
|
||||
// capability: 'HLS_INTERSTITIAL_PREVIEW',
|
||||
// },
|
||||
|
||||
// "Game Controllers" doesn't appear in Dev Portal but it does show up in Xcode,
|
||||
// toggling in Xcode causes no network request to be sent.
|
||||
// Therefore it seems that it's a mistake in Xcode,
|
||||
// the key `GCSupportsControllerUserInteraction` just needs to be present in Info.plist
|
||||
|
||||
// "Keychain Sharing" doesn't appear in Dev Portal but it does show up in Xcode,
|
||||
// toggling in Xcode causes no network request to be sent.
|
||||
// Adding to Xcode puts 'keychain-access-groups' into the entitlements so
|
||||
// it's not clear if it needs to be updated.
|
||||
|
||||
// "Contact Notes" requires the user to ask Apple in a form:
|
||||
// https://developer-mdn.apple.com/contact/request/contact-note-field
|
||||
// com.apple.developer.contacts.notes: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_contacts_notes/
|
||||
|
||||
// "Exposure Notification" requires the user to ask Apple in a form:
|
||||
// https://developer-mdn.apple.com/contact/request/exposure-notification-entitlement
|
||||
// com.apple.developer.exposure-notification: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_exposure-notification/
|
||||
];
|
||||
|
||||
export const associatedDomainsCapabilityType = nullthrows(
|
||||
CapabilityMapping.find(it => it.capability === CapabilityType.ASSOCIATED_DOMAINS)
|
||||
);
|
||||
@@ -9,7 +9,15 @@ import { App, Target } from '../../types';
|
||||
import { displayIosCredentials } from '../printCredentials';
|
||||
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('chalk', () => ({ bold: jest.fn(log => log), cyan: { bold: jest.fn(log => log) } }));
|
||||
jest.mock('chalk', () => ({
|
||||
red: jest.fn(text => `red(${text})`),
|
||||
blue: jest.fn(text => `blue(${text})`),
|
||||
yellow: jest.fn(text => `yellow(${text})`),
|
||||
green: jest.fn(text => `green(${text})`),
|
||||
gray: jest.fn(text => `gray(${text})`),
|
||||
bold: jest.fn(log => log),
|
||||
cyan: { bold: jest.fn(log => log) },
|
||||
}));
|
||||
|
||||
jest.mock('../../api/graphql/queries/IosAppCredentialsQuery');
|
||||
mockdate.set(new Date('4/20/2021'));
|
||||
|
||||
@@ -4,9 +4,8 @@ import chalk from 'chalk';
|
||||
import DeviceCreateAction from './actions/create/action';
|
||||
import { DeviceManagerContext } from './context';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleTeamMutation } from '../credentials/ios/api/graphql/mutations/AppleTeamMutation';
|
||||
import { AppleTeamQuery } from '../credentials/ios/api/graphql/queries/AppleTeamQuery';
|
||||
import { AccountFragment, AppleTeamFragment } from '../graphql/generated';
|
||||
import { createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync } from '../credentials/ios/api/GraphqlClient';
|
||||
import { AccountFragment } from '../graphql/generated';
|
||||
import Log from '../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { Choice, confirmAsync, promptAsync } from '../prompts';
|
||||
@@ -29,10 +28,14 @@ export default class DeviceManager {
|
||||
|
||||
const account = await this.resolveAccountAsync();
|
||||
const appleAuthCtx = await this.ctx.appStore.ensureAuthenticatedAsync();
|
||||
const appleTeam = await ensureAppleTeamExistsAsync(this.ctx.graphqlClient, account.id, {
|
||||
appleTeamIdentifier: appleAuthCtx.team.id,
|
||||
appleTeamName: appleAuthCtx.team.name,
|
||||
});
|
||||
const appleTeam = await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
|
||||
this.ctx.graphqlClient,
|
||||
account.id,
|
||||
{
|
||||
appleTeamIdentifier: appleAuthCtx.team.id,
|
||||
appleTeamName: appleAuthCtx.team.name,
|
||||
}
|
||||
);
|
||||
const action = new DeviceCreateAction(
|
||||
this.ctx.graphqlClient,
|
||||
this.ctx.appStore,
|
||||
@@ -92,27 +95,3 @@ export class AccountResolver {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAppleTeamExistsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
{ appleTeamIdentifier, appleTeamName }: { appleTeamIdentifier: string; appleTeamName?: string }
|
||||
): Promise<AppleTeamFragment> {
|
||||
const appleTeam = await AppleTeamQuery.getByAppleTeamIdentifierAsync(
|
||||
graphqlClient,
|
||||
accountId,
|
||||
appleTeamIdentifier
|
||||
);
|
||||
if (appleTeam) {
|
||||
return appleTeam;
|
||||
} else {
|
||||
return await AppleTeamMutation.createAppleTeamAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appleTeamIdentifier,
|
||||
appleTeamName,
|
||||
},
|
||||
accountId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Agent } from 'https';
|
||||
import createHttpsProxyAgent from 'https-proxy-agent';
|
||||
import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch';
|
||||
|
||||
export * from 'node-fetch';
|
||||
export { Headers, RequestInfo, RequestInit, Response } from 'node-fetch';
|
||||
|
||||
export class RequestError extends Error {
|
||||
constructor(
|
||||
|
||||
+543
-204
File diff suppressed because it is too large.
Load diff
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user