mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
183
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ec54345a | ||
|
|
27b4710264 | ||
|
|
4132e92da5 | ||
|
|
264340a717 | ||
|
|
1d09c2c97d | ||
|
|
72cff1df1a | ||
|
|
6912e453ef | ||
|
|
839cbac47b | ||
|
|
5ba16dbc67 | ||
|
|
896b456dd3 | ||
|
|
f2c0202f06 | ||
|
|
497bac1cbf | ||
|
|
5b9734b60b | ||
|
|
3ac70d8977 | ||
|
|
32adc788d0 | ||
|
|
e465ed57cb | ||
|
|
df819fc232 | ||
|
|
539e201972 | ||
|
|
c14b3e179b | ||
|
|
f093197dea | ||
|
|
13834039a8 | ||
|
|
4d3023f7f1 | ||
|
|
eb7d3b5ecd | ||
|
|
8c721759bd | ||
|
|
c6a741bb8c | ||
|
|
af5b76fe9f | ||
|
|
aa428b7571 | ||
|
|
ba68f40922 | ||
|
|
1380b023ba | ||
|
|
adfc79abd4 | ||
|
|
92e6ecd03c | ||
|
|
05546f0bfd | ||
|
|
6c5c99b5d1 | ||
|
|
f27ea08839 | ||
|
|
15f47aea81 | ||
|
|
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
|
||||
|
||||
+309
-3
@@ -7,15 +7,321 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
- Support custom environment variable environments ([#3220](https://github.com/expo/eas-cli/pull/3220) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
-- 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 --force flag to eas env:push command to bypass confirmation prompts. ([#3135](https://github.com/expo/eas-cli/pull/3135) by [@giorgiofellipe](https://github.com/giorgiofellipe))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [16.22.0](https://github.com/expo/eas-cli/releases/tag/v16.22.0) - 2025-10-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improvements to workflow:create. ([#3194](https://github.com/expo/eas-cli/pull/3194) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.21.0](https://github.com/expo/eas-cli/releases/tag/v16.21.0) - 2025-10-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support `_expo/.routes.json` metadata file exported by `@expo/cli` for headers configuration on static deployments ([#3211](https://github.com/expo/eas-cli/pull/3211) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add thorough tests for the env commands ([#3217](https://github.com/expo/eas-cli/pull/3217) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.4](https://github.com/expo/eas-cli/releases/tag/v16.20.4) - 2025-10-08
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Allow workflow:run to run a workflow from a GitHub ref. ([#3203](https://github.com/expo/eas-cli/pull/3203) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix --environment flag in eas exec and pull ([#3216](https://github.com/expo/eas-cli/pull/3216) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.3](https://github.com/expo/eas-cli/releases/tag/v16.20.3) - 2025-10-07
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix --environment flag in eas env commands ([#3213](https://github.com/expo/eas-cli/pull/3213) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.2](https://github.com/expo/eas-cli/releases/tag/v16.20.2) - 2025-10-07
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Create an `EnvironmentVariableEnvironment` enum and use it instead of the graphQL Enum ([#3208](https://github.com/expo/eas-cli/pull/3208) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.1](https://github.com/expo/eas-cli/releases/tag/v16.20.1) - 2025-10-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- skip syncing capabilities with settings if they don't contain the `enabled` attribute ([#3201](https://github.com/expo/eas-cli/pull/3201) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
## [16.20.0](https://github.com/expo/eas-cli/releases/tag/v16.20.0) - 2025-09-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improvements to workflow:validate. ([#3165](https://github.com/expo/eas-cli/pull/3165) by [@douglowder](https://github.com/douglowder))
|
||||
- Add `eas:new` command behind `hidden` flag. ([#3196](https://github.com/expo/eas-cli/pull/3196) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.19.3](https://github.com/expo/eas-cli/releases/tag/v16.19.3) - 2025-09-17
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Temporarily remove warnings when no compatible build found, need to refine the message. ([#3182](https://github.com/expo/eas-cli/pull/3182) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [16.19.2](https://github.com/expo/eas-cli/releases/tag/v16.19.2) - 2025-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add artifact URLs to workflow:view. ([#3175](https://github.com/expo/eas-cli/pull/3175) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [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
|
||||
|
||||
+14
-5
@@ -1,12 +1,21 @@
|
||||
# Development
|
||||
|
||||
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
|
||||
## Getting Started
|
||||
|
||||
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
|
||||
If this is your first time running `eas-cli` locally, make sure you install and build the packages. In the project root, run:
|
||||
|
||||
```sh
|
||||
yarn install
|
||||
yarn build
|
||||
```
|
||||
|
||||
From there, set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
|
||||
|
||||
> The only exception is if there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
|
||||
|
||||
Open your `.zshrc` or other config file and add:
|
||||
|
||||
```
|
||||
```sh
|
||||
alias easd="/PATH/TO/eas-cli/bin/run"
|
||||
```
|
||||
|
||||
@@ -14,7 +23,7 @@ Then use it with `easd` like `easd build`.
|
||||
|
||||
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
|
||||
|
||||
```
|
||||
```sh
|
||||
yarn start
|
||||
```
|
||||
|
||||
@@ -33,7 +42,7 @@ If it is easier for you to work with the code locally with compiler options set
|
||||
|
||||
you can consider using:
|
||||
|
||||
```
|
||||
```sh
|
||||
yarn start-allow-unused
|
||||
```
|
||||
|
||||
|
||||
+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.23.0",
|
||||
"$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;
|
||||
+502
-215
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
+3128
-959
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.23.0",
|
||||
"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.237",
|
||||
"@expo/eas-json": "16.23.0",
|
||||
"@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.231",
|
||||
"@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",
|
||||
@@ -84,13 +85,14 @@
|
||||
"tar": "6.2.1",
|
||||
"tar-stream": "3.1.7",
|
||||
"terminal-link": "2.1.1",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
"tslib": "2.6.2",
|
||||
"turndown": "7.1.2",
|
||||
"untildify": "4.0.0",
|
||||
"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 +109,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,14 +120,13 @@
|
||||
"@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",
|
||||
"mockdate": "3.0.5",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.3.3"
|
||||
@@ -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) {
|
||||
@@ -104,3 +108,7 @@ export function getEASUpdateURL(projectId: string): string {
|
||||
return new URL(projectId, `https://u.expo.dev`).href;
|
||||
}
|
||||
}
|
||||
|
||||
export function getExpoApiWorkflowSchemaURL(): string {
|
||||
return getExpoApiBaseUrl() + '/v2/workflows/schema';
|
||||
}
|
||||
@@ -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 { Android, 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';
|
||||
@@ -10,9 +11,9 @@ import { resolveRemoteVersionCodeAsync } from './version';
|
||||
import AndroidCredentialsProvider, {
|
||||
AndroidCredentials,
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMetadataInput, BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import {
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync,
|
||||
getApplicationIdAsync,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
prepareBuildRequestForPlatformAsync,
|
||||
} from '../build';
|
||||
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { logCredentialsSource } from '../utils/credentials';
|
||||
import {
|
||||
checkGoogleServicesFileAsync,
|
||||
@@ -65,6 +65,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);
|
||||
}
|
||||
@@ -119,10 +123,9 @@ export async function prepareAndroidBuildAsync(
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Android.Job,
|
||||
metadata: Metadata,
|
||||
graphqlMetadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createAndroidBuildAsync(ctx.graphqlClient, {
|
||||
appId,
|
||||
@@ -168,3 +171,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'));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
|
||||
|
||||
export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
return {
|
||||
@@ -30,7 +29,7 @@ export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
environment: buildProfileEnvironmentToEnvironment(job.environment),
|
||||
environment: job.environment,
|
||||
loggerLevel: job.loggerLevel
|
||||
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
|
||||
: undefined,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
BuildJob,
|
||||
FingerprintSource,
|
||||
Metadata,
|
||||
Platform,
|
||||
} from '@expo/eas-build-job';
|
||||
@@ -50,9 +49,11 @@ import { createFingerprintAsync } from '../fingerprint/cli';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildMetadataInput,
|
||||
BuildParamsInput,
|
||||
BuildPriority,
|
||||
BuildStatus,
|
||||
FingerprintSourceInput,
|
||||
UploadSessionType,
|
||||
} from '../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
@@ -94,7 +95,7 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends BuildJob
|
||||
sendBuildRequestAsync(
|
||||
appId: string,
|
||||
job: TJob,
|
||||
metadata: Metadata,
|
||||
metadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult>;
|
||||
}
|
||||
@@ -193,13 +194,20 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
|
||||
await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, {
|
||||
buildId: nullthrows(process.env.EAS_BUILD_ID),
|
||||
metadata: transformMetadata(metadata),
|
||||
metadata: transformMetadata({
|
||||
metadata,
|
||||
fingerprintSource: runtimeAndFingerprintMetadata.fingerprintSource ?? undefined,
|
||||
}),
|
||||
});
|
||||
printJsonOnlyOutput({ job, metadata });
|
||||
return undefined;
|
||||
} else if (!ctx.localBuildOptions.localBuildMode) {
|
||||
try {
|
||||
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
|
||||
const graphqlMetadata = transformMetadata({
|
||||
metadata,
|
||||
fingerprintSource: runtimeAndFingerprintMetadata.fingerprintSource ?? undefined,
|
||||
});
|
||||
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
|
||||
} catch (error: any) {
|
||||
handleBuildRequestError(error, job.platform);
|
||||
}
|
||||
@@ -362,7 +370,7 @@ async function sendBuildRequestAsync<
|
||||
>(
|
||||
builder: Builder<TPlatform, Credentials, TJob>,
|
||||
job: TJob,
|
||||
metadata: Metadata,
|
||||
graphqlMetadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildFragment> {
|
||||
const { ctx } = builder;
|
||||
@@ -376,7 +384,7 @@ async function sendBuildRequestAsync<
|
||||
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
|
||||
ctx.projectId,
|
||||
job,
|
||||
metadata,
|
||||
graphqlMetadata,
|
||||
buildParams
|
||||
);
|
||||
|
||||
@@ -500,11 +508,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();
|
||||
@@ -658,7 +662,7 @@ async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends
|
||||
): Promise<{
|
||||
runtimeVersion?: string | undefined;
|
||||
fingerprintHash?: string | undefined;
|
||||
fingerprintSource?: FingerprintSource | undefined;
|
||||
fingerprintSource?: FingerprintSourceInput | undefined;
|
||||
}> {
|
||||
const runtimeAndFingerprintMetadata =
|
||||
await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx);
|
||||
@@ -680,7 +684,7 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Pl
|
||||
ctx: BuildContext<T>
|
||||
): Promise<{
|
||||
runtimeVersion?: string;
|
||||
fingerprintSource?: FingerprintSource;
|
||||
fingerprintSource?: FingerprintSourceInput;
|
||||
fingerprintHash?: string;
|
||||
}> {
|
||||
const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({
|
||||
@@ -727,7 +731,7 @@ async function computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync<T extends
|
||||
{ debug }: { debug?: boolean } = {}
|
||||
): Promise<{
|
||||
fingerprintHash?: string;
|
||||
fingerprintSource?: FingerprintSource;
|
||||
fingerprintSource?: FingerprintSourceInput;
|
||||
}> {
|
||||
const fingerprint = await createFingerprintAsync(ctx.projectDir, {
|
||||
workflow: ctx.workflow,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { isEnvironment } from './utils/environment';
|
||||
import { DefaultEnvironment } from './utils/environment';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
|
||||
@@ -49,20 +48,9 @@ async function resolveEnvVarsAsync({
|
||||
projectId: string;
|
||||
}): Promise<Env> {
|
||||
const environment =
|
||||
buildProfile.environment?.toUpperCase() ??
|
||||
buildProfile.environment ??
|
||||
resolveSuggestedEnvironmentForBuildProfileConfiguration(buildProfile);
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
Log.log(
|
||||
`Loaded "env" configuration for the "${buildProfileName}" profile: ${
|
||||
buildProfile.env && Object.keys(buildProfile.env).length > 0
|
||||
? Object.keys(buildProfile.env).join(', ')
|
||||
: 'no environment variables specified'
|
||||
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
|
||||
);
|
||||
return { ...buildProfile.env };
|
||||
}
|
||||
|
||||
try {
|
||||
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
|
||||
graphqlClient,
|
||||
@@ -129,16 +117,16 @@ async function resolveEnvVarsAsync({
|
||||
|
||||
function resolveSuggestedEnvironmentForBuildProfileConfiguration(
|
||||
buildProfile: BuildProfile
|
||||
): EnvironmentVariableEnvironment {
|
||||
): DefaultEnvironment {
|
||||
const environment =
|
||||
buildProfile.distribution === 'store'
|
||||
? EnvironmentVariableEnvironment.Production
|
||||
? DefaultEnvironment.Production
|
||||
: buildProfile.developmentClient
|
||||
? EnvironmentVariableEnvironment.Development
|
||||
: EnvironmentVariableEnvironment.Preview;
|
||||
? DefaultEnvironment.Development
|
||||
: DefaultEnvironment.Preview;
|
||||
|
||||
Log.log(
|
||||
`Resolved "${environment.toLowerCase()}" environment for the build. ${learnMore(
|
||||
`Resolved "${environment}" environment for the build. ${learnMore(
|
||||
'https://docs.expo.dev/eas/environment-variables/#setting-the-environment-for-your-builds'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
FingerprintSourceType,
|
||||
Metadata,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
FingerprintSourceInput,
|
||||
BuildMode as GraphQLBuildMode,
|
||||
BuildTrigger as GraphQLBuildTrigger,
|
||||
FingerprintSourceType as GraphQLFingeprintSourceType,
|
||||
ProjectArchiveSourceInput,
|
||||
ProjectArchiveSourceType,
|
||||
WorkerLoggerLevel,
|
||||
@@ -42,11 +40,16 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
|
||||
}
|
||||
}
|
||||
|
||||
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
export function transformMetadata({
|
||||
metadata,
|
||||
fingerprintSource,
|
||||
}: {
|
||||
metadata: Metadata;
|
||||
fingerprintSource: FingerprintSourceInput | undefined;
|
||||
}): BuildMetadataInput {
|
||||
return {
|
||||
...metadata,
|
||||
fingerprintSource:
|
||||
metadata.fingerprintSource && transformFingerprintSource(metadata.fingerprintSource),
|
||||
fingerprintSource,
|
||||
credentialsSource:
|
||||
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
|
||||
distribution: metadata.distribution && transformDistribution(metadata.distribution),
|
||||
@@ -57,20 +60,6 @@ export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
};
|
||||
}
|
||||
|
||||
export function transformFingerprintSource(
|
||||
fingerprintSource: NonNullable<Metadata['fingerprintSource']>
|
||||
): FingerprintSourceInput | null {
|
||||
if (fingerprintSource.type !== FingerprintSourceType.GCS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: GraphQLFingeprintSourceType.Gcs,
|
||||
bucketKey: fingerprintSource.bucketKey,
|
||||
isDebugFingerprint: fingerprintSource.isDebugFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function transformCredentialsSource(
|
||||
credentialsSource: Metadata['credentialsSource']
|
||||
): BuildCredentialsSource {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ios, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Ios, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource } from '@expo/eas-json';
|
||||
|
||||
import { ensureIosCredentialsAsync } from './credentials';
|
||||
@@ -7,7 +7,7 @@ import { prepareJobAsync } from './prepareJob';
|
||||
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
|
||||
import { resolveRemoteBuildNumberAsync } from './version';
|
||||
import { IosCredentials } from '../../credentials/ios/types';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMetadataInput, BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../../project/ios/exemptEncryption';
|
||||
@@ -15,7 +15,6 @@ import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
|
||||
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommonContext, IosBuildContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import {
|
||||
checkGoogleServicesFileAsync,
|
||||
checkNodeEnvVariable,
|
||||
@@ -106,10 +105,9 @@ export async function prepareIosBuildAsync(
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Ios.Job,
|
||||
metadata: Metadata,
|
||||
graphqlMetadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createIosBuildAsync(ctx.graphqlClient, {
|
||||
appId,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
|
||||
|
||||
export function transformJob(job: Ios.Job): IosJobInput {
|
||||
return {
|
||||
@@ -32,7 +31,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
environment: buildProfileEnvironmentToEnvironment(job.environment),
|
||||
environment: job.environment,
|
||||
loggerLevel: job.loggerLevel
|
||||
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
|
||||
: undefined,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FingerprintSource, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { IosEnterpriseProvisioning } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
@@ -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';
|
||||
@@ -19,7 +22,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
runtimeAndFingerprintMetadata: {
|
||||
runtimeVersion?: string | undefined;
|
||||
fingerprintHash?: string | undefined;
|
||||
fingerprintSource?: FingerprintSource | undefined;
|
||||
}
|
||||
): Promise<Metadata> {
|
||||
const channelObject = await resolveChannelAsync(ctx);
|
||||
@@ -33,7 +35,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
sdkVersion: ctx.exp.sdkVersion,
|
||||
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
|
||||
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
|
||||
fingerprintSource: runtimeAndFingerprintMetadata?.fingerprintSource,
|
||||
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
|
||||
...channelObject,
|
||||
distribution,
|
||||
@@ -48,7 +49,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;
|
||||
}
|
||||
@@ -1,26 +1,5 @@
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
|
||||
type Environment = NonNullable<BuildProfile['environment']>;
|
||||
|
||||
const BuildProfileEnvironmentToEnvironment: Record<Environment, EnvironmentVariableEnvironment> = {
|
||||
production: EnvironmentVariableEnvironment.Production,
|
||||
preview: EnvironmentVariableEnvironment.Preview,
|
||||
development: EnvironmentVariableEnvironment.Development,
|
||||
};
|
||||
|
||||
export function isEnvironment(env: string): env is EnvironmentVariableEnvironment {
|
||||
return Object.values(EnvironmentVariableEnvironment).includes(
|
||||
env as EnvironmentVariableEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProfileEnvironmentToEnvironment(
|
||||
environment: BuildProfile['environment']
|
||||
): EnvironmentVariableEnvironment | null {
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
return BuildProfileEnvironmentToEnvironment[environment];
|
||||
export enum DefaultEnvironment {
|
||||
Development = 'development',
|
||||
Preview = 'preview',
|
||||
Production = 'production',
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
CommandEvent,
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
import Log, { link } from '../log';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { Client } from '../vcs/vcs';
|
||||
@@ -60,7 +59,7 @@ interface BaseGetContextAsyncArgs {
|
||||
|
||||
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
|
||||
extends BaseGetContextAsyncArgs {
|
||||
withServerSideEnvironment: EnvironmentVariableEnvironment | null;
|
||||
withServerSideEnvironment: string | null;
|
||||
}
|
||||
|
||||
interface GetContextAsyncArgsWithoutServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
@@ -11,7 +10,7 @@ export interface ContextOptions {
|
||||
/**
|
||||
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
|
||||
*/
|
||||
withServerSideEnvironment?: EnvironmentVariableEnvironment | null;
|
||||
withServerSideEnvironment?: string | null;
|
||||
}
|
||||
|
||||
export default abstract class ContextField<T> {
|
||||
|
||||
+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,
|
||||
},
|
||||
{
|
||||
|
||||
+4
-12
@@ -1,23 +1,15 @@
|
||||
import { ExpoGraphqlClient } from './createGraphqlClient';
|
||||
import { EnvironmentVariableEnvironment } from '../../../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
|
||||
const cachedServerSideEnvironmentVariables: Record<
|
||||
EnvironmentVariableEnvironment,
|
||||
Record<string, string> | null
|
||||
> = {
|
||||
[EnvironmentVariableEnvironment.Development]: null,
|
||||
[EnvironmentVariableEnvironment.Preview]: null,
|
||||
[EnvironmentVariableEnvironment.Production]: null,
|
||||
};
|
||||
const cachedServerSideEnvironmentVariables: Record<string, Record<string, string> | undefined> = {};
|
||||
|
||||
export async function loadServerSideEnvironmentVariablesAsync({
|
||||
environment,
|
||||
projectId,
|
||||
graphqlClient,
|
||||
}: {
|
||||
environment: EnvironmentVariableEnvironment;
|
||||
environment: string;
|
||||
projectId: string;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}): Promise<Record<string, string>> {
|
||||
@@ -42,13 +34,13 @@ export async function loadServerSideEnvironmentVariablesAsync({
|
||||
|
||||
if (Object.keys(serverEnvVars).length > 0) {
|
||||
Log.log(
|
||||
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
|
||||
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment}" environment on EAS: ${Object.keys(
|
||||
serverEnvVars
|
||||
).join(', ')}.`
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
|
||||
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment}" environment on EAS.`
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
|
||||
// NOTE: not exactly true, but, provided mapToLowercase and upperCaseAsync
|
||||
// are used in tandem, it saves on unnecessary typying in commands
|
||||
async function upperCaseAsync<T>(input: string): Promise<T> {
|
||||
return input.toUpperCase() as T;
|
||||
}
|
||||
|
||||
function mapToLowercase<T extends string>(options: T[]): T[] {
|
||||
return options.map(option => option.toLowerCase()) as T[];
|
||||
}
|
||||
|
||||
export const EasNonInteractiveAndJsonFlags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
|
||||
@@ -23,21 +11,17 @@ export const EasNonInteractiveAndJsonFlags = {
|
||||
};
|
||||
|
||||
export const EasEnvironmentFlagParameters = {
|
||||
description: "Environment variable's environment",
|
||||
parse: upperCaseAsync,
|
||||
options: mapToLowercase([
|
||||
EnvironmentVariableEnvironment.Development,
|
||||
EnvironmentVariableEnvironment.Preview,
|
||||
EnvironmentVariableEnvironment.Production,
|
||||
]),
|
||||
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
|
||||
};
|
||||
|
||||
export const EASEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>(EasEnvironmentFlagParameters),
|
||||
environment: Flags.string({
|
||||
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
|
||||
}),
|
||||
};
|
||||
|
||||
export const EASMultiEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>({
|
||||
environment: Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
multiple: true,
|
||||
}),
|
||||
@@ -81,12 +65,10 @@ export const EasJsonOnlyFlag = {
|
||||
};
|
||||
|
||||
export const EasUpdateEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment | null>({
|
||||
environment: Flags.string({
|
||||
description:
|
||||
'Environment to use for the server-side defined EAS environment variables during command execution.',
|
||||
options: mapToLowercase(Object.values(EnvironmentVariableEnvironment)),
|
||||
parse: upperCaseAsync,
|
||||
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development"',
|
||||
required: false,
|
||||
default: null,
|
||||
default: undefined,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { BuildProfile, EasJsonAccessor, EasJsonUtils, Platform } from '@expo/eas-json';
|
||||
import { AndroidBuildProfile, IosBuildProfile } from '@expo/eas-json/build/build/types';
|
||||
|
||||
import BuildConfigure from '../../commands/build/configure';
|
||||
import UpdateConfigure from '../../commands/update/configure';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export async function buildProfileNamesFromProjectAsync(projectDir: string): Promise<Set<string>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
|
||||
const buildProfileNames = new Set(
|
||||
easJsonAccessor && (await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor))
|
||||
);
|
||||
return buildProfileNames;
|
||||
}
|
||||
export async function getBuildProfileAsync(
|
||||
projectDir: string,
|
||||
platform: Platform,
|
||||
profileName: string
|
||||
): Promise<BuildProfile<Platform>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
profileName
|
||||
);
|
||||
return buildProfile;
|
||||
}
|
||||
export async function buildProfilesFromProjectAsync(
|
||||
projectDir: string
|
||||
): Promise<Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }>> {
|
||||
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
|
||||
const buildProfiles: Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }> =
|
||||
new Map();
|
||||
for (const profileName of buildProfileNames) {
|
||||
const iosBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.IOS,
|
||||
profileName
|
||||
)) as IosBuildProfile;
|
||||
const androidBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.ANDROID,
|
||||
profileName
|
||||
)) as AndroidBuildProfile;
|
||||
buildProfiles.set(profileName, { android: androidBuildProfile, ios: iosBuildProfile });
|
||||
}
|
||||
return buildProfiles;
|
||||
}
|
||||
export function isBuildProfileForDevelopment(
|
||||
buildProfile: BuildProfile<Platform>,
|
||||
platform: Platform
|
||||
): boolean {
|
||||
if (buildProfile.developmentClient) {
|
||||
return true;
|
||||
}
|
||||
if (platform === Platform.ANDROID) {
|
||||
return (buildProfile as BuildProfile<Platform.ANDROID>).gradleCommand === ':app:assembleDebug';
|
||||
}
|
||||
if (platform === Platform.IOS) {
|
||||
return (buildProfile as BuildProfile<Platform.IOS>).buildConfiguration === 'Debug';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function isIosBuildProfileForSimulator(buildProfile: BuildProfile<Platform.IOS>): boolean {
|
||||
return buildProfile.simulator ?? false;
|
||||
}
|
||||
export async function addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
android: {
|
||||
gradleCommand: ':app:assembleDebug',
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
export async function addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string,
|
||||
simulator: boolean
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
buildConfiguration: 'Debug',
|
||||
simulator,
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
|
||||
export async function addProductionBuildProfileToEasJsonIfNeededAsync(
|
||||
projectDir: string
|
||||
): Promise<boolean> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
let profileAdded = false;
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
if (!easJsonRawObject.build?.production) {
|
||||
profileAdded = true;
|
||||
easJsonRawObject.build = {
|
||||
...(easJsonRawObject.build ?? {}),
|
||||
production: {},
|
||||
};
|
||||
// Also add the profile to submit
|
||||
easJsonRawObject.submit = {
|
||||
...(easJsonRawObject.submit ?? {}),
|
||||
production: {},
|
||||
};
|
||||
}
|
||||
return easJsonRawObject;
|
||||
});
|
||||
if (profileAdded) {
|
||||
Log.log('Added missing production build profile to eas.json');
|
||||
}
|
||||
await easJsonAccessor.writeAsync();
|
||||
return profileAdded;
|
||||
}
|
||||
|
||||
export async function hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
// Is there a project ID in the Expo config?
|
||||
if (!expoConfig.extra?.eas?.projectId) {
|
||||
return false;
|
||||
}
|
||||
// Is there an eas.json?
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
try {
|
||||
await easJsonAccessor.readAsync();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
// Does the Expo config have an updates URL?
|
||||
if (!expoConfig.updates?.url) {
|
||||
return false;
|
||||
}
|
||||
// Does at least one build profile have a channel?
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
try {
|
||||
const easJson = await easJsonAccessor.readAsync();
|
||||
return Object.values(easJson.build ?? {}).some(buildProfile => !!buildProfile.channel);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs update:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
|
||||
export async function runUpdateConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Update configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Update and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Update is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await UpdateConfigure.run([]);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Runs build:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
export async function runBuildConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Build configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Build and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Build is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await BuildConfigure.run(['-p', 'all']);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
addAndroidDevelopmentBuildProfileToEasJsonAsync,
|
||||
addIosDevelopmentBuildProfileToEasJsonAsync,
|
||||
addProductionBuildProfileToEasJsonIfNeededAsync,
|
||||
buildProfilesFromProjectAsync,
|
||||
hasBuildConfigureBeenRunAsync,
|
||||
hasUpdateConfigureBeenRunAsync,
|
||||
isBuildProfileForDevelopment,
|
||||
isIosBuildProfileForSimulator,
|
||||
} from './buildProfileUtils';
|
||||
import Log, { link } from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
export enum WorkflowStarterName {
|
||||
BUILD = 'build',
|
||||
UPDATE = 'update',
|
||||
CUSTOM = 'custom',
|
||||
DEPLOY = 'deploy',
|
||||
}
|
||||
|
||||
export type WorkflowStarter = {
|
||||
name: WorkflowStarterName;
|
||||
displayName: string;
|
||||
defaultFileName: string;
|
||||
template: any;
|
||||
header: string;
|
||||
nextSteps?: string[];
|
||||
};
|
||||
|
||||
const createdByEASCLI = `# Created by EAS CLI v${easCliVersion}`;
|
||||
|
||||
const CUSTOM_TEMPLATE = {
|
||||
name: 'Custom workflow',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
custom_build: {
|
||||
name: 'Custom job',
|
||||
steps: [
|
||||
{
|
||||
uses: 'eas/checkout',
|
||||
},
|
||||
{
|
||||
name: 'Hello World',
|
||||
id: 'hello_world',
|
||||
run: '# Custom script\necho "Hello, World"\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CUSTOM_TEMPLATE_HEADER = `
|
||||
# Custom job
|
||||
#
|
||||
# This workflow shows how to write custom jobs.
|
||||
# It contains a predefined workflow step and a shell command to print "Hello, World!".
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to the main branch.
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run custom.yml
|
||||
# - Runs eas/checkout then a custom "echo" command
|
||||
#
|
||||
# For detailed documentation on workflow syntax and available step types, visit:
|
||||
# https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idstepsstepuses
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const BUILD_TEMPLATE = {
|
||||
name: 'Create development builds',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {},
|
||||
};
|
||||
|
||||
const BUILD_TEMPLATE_HEADER = `
|
||||
# Create development builds
|
||||
#
|
||||
# This workflow shows how to create development builds.
|
||||
#
|
||||
# Key features:
|
||||
# - Can be triggered manually with eas workflow:run create-development-builds.yml
|
||||
# - Runs the pre-packaged build job to create Android and iOS development builds
|
||||
# for Android emulators, Android and iOS devices, and iOS simulators
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/develop/development-builds/introduction/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE = {
|
||||
name: 'Publish preview update',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['*'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
publish_preview_update: {
|
||||
name: 'Publish preview update',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: '${{ github.ref_name || "test" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE_HEADER = `
|
||||
# Publish preview update
|
||||
#
|
||||
# This workflow shows how to publish preview updates.
|
||||
# Learn more: https://docs.expo.dev/review/share-previews-with-your-team/
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to all branches
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run publish-preview-update.yml
|
||||
# - Runs the pre-packaged update job
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const DEPLOY_TEMPLATE = {
|
||||
name: 'Deploy to production',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
fingerprint: {
|
||||
name: 'Fingerprint',
|
||||
type: 'fingerprint',
|
||||
},
|
||||
get_android_build: {
|
||||
name: 'Check for existing android build',
|
||||
needs: ['fingerprint'],
|
||||
type: 'get-build',
|
||||
params: {
|
||||
fingerprint_hash: '${{ needs.fingerprint.outputs.android_fingerprint_hash }}',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
get_ios_build: {
|
||||
name: 'Check for existing ios build',
|
||||
needs: ['fingerprint'],
|
||||
type: 'get-build',
|
||||
params: {
|
||||
fingerprint_hash: '${{ needs.fingerprint.outputs.ios_fingerprint_hash }}',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
build_android: {
|
||||
name: 'Build Android',
|
||||
needs: ['get_android_build'],
|
||||
if: '${{ !needs.get_android_build.outputs.build_id }}',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'android',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
build_ios: {
|
||||
name: 'Build iOS',
|
||||
needs: ['get_ios_build'],
|
||||
if: '${{ !needs.get_ios_build.outputs.build_id }}',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'ios',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
submit_android_build: {
|
||||
name: 'Submit Android Build',
|
||||
needs: ['build_android'],
|
||||
type: 'submit',
|
||||
params: {
|
||||
build_id: '${{ needs.build_android.outputs.build_id }}',
|
||||
},
|
||||
},
|
||||
submit_ios_build: {
|
||||
name: 'Submit iOS Build',
|
||||
needs: ['build_ios'],
|
||||
type: 'submit',
|
||||
params: {
|
||||
build_id: '${{ needs.build_ios.outputs.build_id }}',
|
||||
},
|
||||
},
|
||||
publish_android_update: {
|
||||
name: 'Publish Android update',
|
||||
needs: ['get_android_build'],
|
||||
if: '${{ needs.get_android_build.outputs.build_id }}',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: 'production',
|
||||
platform: 'android',
|
||||
},
|
||||
},
|
||||
publish_ios_update: {
|
||||
name: 'Publish iOS update',
|
||||
needs: ['get_ios_build'],
|
||||
if: '${{ needs.get_ios_build.outputs.build_id }}',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: 'production',
|
||||
platform: 'ios',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DEPLOY_TEMPLATE_HEADER = `
|
||||
# Deploy to production
|
||||
#
|
||||
# This workflow shows how to build and submit to app stores.
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to "main" (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run deploy-to-production.yml
|
||||
# - Creates builds and submits them to app stores when native changes are detected, otherwise sends an over-the-air update.
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
|
||||
`;
|
||||
|
||||
function nextStepForDeviceBuildProfile(buildProfileName: string): string {
|
||||
return `A build job in this workflow uses the build profile "${buildProfileName}"
|
||||
to build your app for real devices, and may require credentials.
|
||||
You can configure your credentials with the command "eas credentials".
|
||||
For more information, please see ${link('https://docs.expo.dev/app-signing/app-credentials/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
function nextStepForAppSubmission(): string {
|
||||
return `This workflow includes a job to submit your app to app stores.
|
||||
You will need to configure your App Store/Play Store credentials
|
||||
before you can run this workflow.
|
||||
Please see ${link('https://docs.expo.dev/deploy/submit-to-app-stores/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
export function howToRunWorkflow(
|
||||
workflowFileName: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): string {
|
||||
const lines = [
|
||||
`To run this workflow manually, use the command "eas workflow:run .eas/workflows/${workflowFileName}".`,
|
||||
];
|
||||
if (workflowStarter.template?.on?.push?.branches?.length > 0) {
|
||||
const branches = workflowStarter.template.on.push.branches;
|
||||
if (branches.length === 1 && branches[0] === '*') {
|
||||
lines.push(
|
||||
'This workflow is also configured to run automatically when code is pushed to any branch.'
|
||||
);
|
||||
} else if (branches.length === 1) {
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the "${branches[0]}" branch.`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the following branches: ${branches.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const workflowStarters: WorkflowStarter[] = [
|
||||
{
|
||||
displayName: 'Custom',
|
||||
name: WorkflowStarterName.CUSTOM,
|
||||
defaultFileName: 'custom.yml',
|
||||
template: CUSTOM_TEMPLATE,
|
||||
header: CUSTOM_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Create development builds',
|
||||
name: WorkflowStarterName.BUILD,
|
||||
defaultFileName: 'create-development-builds.yml',
|
||||
template: BUILD_TEMPLATE,
|
||||
header: BUILD_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Publish updates',
|
||||
name: WorkflowStarterName.UPDATE,
|
||||
defaultFileName: 'publish-updates.yml',
|
||||
template: PUBLISH_UPDATE_TEMPLATE,
|
||||
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Deploy to production',
|
||||
name: WorkflowStarterName.DEPLOY,
|
||||
defaultFileName: 'deploy-to-production.yml',
|
||||
template: DEPLOY_TEMPLATE,
|
||||
header: DEPLOY_TEMPLATE_HEADER,
|
||||
},
|
||||
];
|
||||
|
||||
export async function addBuildJobsToDevelopmentBuildTemplateAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
const buildProfiles = await buildProfilesFromProjectAsync(projectDir);
|
||||
// android_development_build
|
||||
|
||||
const nextSteps: Set<string> = new Set();
|
||||
|
||||
let androidDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.android;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (isBuildProfileForDevelopment(profile, Platform.ANDROID)) {
|
||||
androidDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!androidDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an Android development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an Android development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let androidDevelopmentBuildProfileName = 'android_development';
|
||||
while (buildProfiles.has(androidDevelopmentBuildProfileName)) {
|
||||
androidDevelopmentBuildProfileName = `${androidDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
androidDevelopmentBuildProfileName
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping Android development build job...');
|
||||
}
|
||||
}
|
||||
if (androidDevelopmentBuildProfileName) {
|
||||
Log.log(`Using Android development build profile: ${androidDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.android_development_build = {
|
||||
name: `Build ${androidDevelopmentBuildProfileName} for android`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: androidDevelopmentBuildProfileName,
|
||||
platform: 'android',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(androidDevelopmentBuildProfileName));
|
||||
}
|
||||
|
||||
// ios_simulator_development_build
|
||||
let iosSimulatorDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosSimulatorDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS simulator development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS simulator development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosSimulatorDevelopmentBuildProfileName = 'ios_simulator_development';
|
||||
while (buildProfiles.has(iosSimulatorDevelopmentBuildProfileName)) {
|
||||
iosSimulatorDevelopmentBuildProfileName = `${iosSimulatorDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosSimulatorDevelopmentBuildProfileName,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS simulator development build job...');
|
||||
}
|
||||
}
|
||||
if (iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.log(
|
||||
`Using iOS simulator development build profile: ${iosSimulatorDevelopmentBuildProfileName}`
|
||||
);
|
||||
workflowStarter.template.jobs.ios_simulator_development_build = {
|
||||
name: `Build ${iosSimulatorDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosSimulatorDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
}
|
||||
// ios_device_development_build
|
||||
let iosDeviceDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
!isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosDeviceDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS device development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS device development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosDeviceDevelopmentBuildProfileName = 'ios_device_development';
|
||||
while (buildProfiles.has(iosDeviceDevelopmentBuildProfileName)) {
|
||||
iosDeviceDevelopmentBuildProfileName = `${iosDeviceDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosDeviceDevelopmentBuildProfileName,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS device development build job...');
|
||||
}
|
||||
}
|
||||
if (iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.log(`Using iOS device development build profile: ${iosDeviceDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.ios_development_build = {
|
||||
name: `Build ${iosDeviceDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosDeviceDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(iosDeviceDevelopmentBuildProfileName));
|
||||
}
|
||||
workflowStarter.nextSteps = [...nextSteps];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function ensureProductionBuildProfileExistsAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
await addProductionBuildProfileToEasJsonIfNeededAsync(projectDir);
|
||||
workflowStarter.nextSteps = [
|
||||
nextStepForDeviceBuildProfile('production'),
|
||||
nextStepForAppSubmission(),
|
||||
];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function customizeTemplateIfNeededAsync(
|
||||
workflowStarter: WorkflowStarter,
|
||||
projectDir: string,
|
||||
expoConfig: ExpoConfig
|
||||
): Promise<any> {
|
||||
// Ensure EAS Build is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Build is not configured for this project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Ensure EAS Update is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Update is not configured for this project. Please run "eas update:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Customize template
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
Log.debug('Adding build jobs to template...');
|
||||
return await addBuildJobsToDevelopmentBuildTemplateAsync(projectDir, workflowStarter);
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
return await ensureProductionBuildProfileExistsAsync(projectDir, workflowStarter);
|
||||
default:
|
||||
return workflowStarter;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/errors';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import { buildProfileNamesFromProjectAsync } from './buildProfileUtils';
|
||||
import { getExpoApiWorkflowSchemaURL } from '../../api';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import Log from '../../log';
|
||||
import { createValidator, getReadableErrors } from '../../metadata/utils/ajv';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
|
||||
|
||||
const buildProfileIsInterpolated = (profileName: string): boolean => {
|
||||
return profileName.includes('${{') && profileName.includes('}}');
|
||||
};
|
||||
|
||||
export async function validateWorkflowFileAsync(
|
||||
workflowFileContents: { yamlConfig: string; filePath: string },
|
||||
projectDir: string,
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
const parsedYaml = parsedYamlFromWorkflowContents(workflowFileContents);
|
||||
Log.debug(`Parsed workflow: ${JSON.stringify(parsedYaml, null, 2)}`);
|
||||
|
||||
// Check if the parsed result is empty or null
|
||||
Log.debug(`Validating workflow is not empty...`);
|
||||
validateWorkflowIsNotEmpty(parsedYaml);
|
||||
|
||||
const workflowSchema = await fetchWorkflowSchemaAsync();
|
||||
|
||||
// Check that all job types are valid
|
||||
Log.debug(`Validating workflow job types...`);
|
||||
validateWorkflowJobTypes(parsedYaml, workflowSchema);
|
||||
|
||||
// Check for build jobs that do not match any EAS build profiles
|
||||
Log.debug(`Validating workflow build jobs...`);
|
||||
await validateWorkflowBuildJobsAsync(parsedYaml, projectDir);
|
||||
|
||||
// Check that result passes validation against workflow schema
|
||||
Log.debug(`Validating workflow structure...`);
|
||||
validateWorkflowStructure(parsedYaml, workflowSchema);
|
||||
|
||||
// Check for other errors using the server-side validation
|
||||
Log.debug(`Validating workflow on server...`);
|
||||
await validateWorkflowOnServerAsync(graphqlClient, projectId, workflowFileContents);
|
||||
}
|
||||
|
||||
export function logWorkflowValidationErrors(error: unknown): void {
|
||||
if (error instanceof MissingEasJsonError) {
|
||||
throw new Error(
|
||||
'Workflows require a valid eas.json. Please run "eas build:configure" to create it.'
|
||||
);
|
||||
} else if (error instanceof InvalidEasJsonError) {
|
||||
throw new Error(
|
||||
'Workflows require a valid eas.json. Please fix the errors in your eas.json and try again.\n\n' +
|
||||
error.message
|
||||
);
|
||||
} else if (error instanceof YAML.YAMLParseError) {
|
||||
Log.error(`YAML syntax error: ${error.message}`);
|
||||
} else if (error instanceof CombinedError) {
|
||||
WorkflowFile.maybePrintWorkflowFileValidationErrors({
|
||||
error,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} else if (error instanceof Error) {
|
||||
Log.error(`Error: ${error.message}`);
|
||||
} else {
|
||||
Log.error(`Unexpected error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowIsNotEmpty(parsedYaml: any): void {
|
||||
if (
|
||||
parsedYaml === null ||
|
||||
parsedYaml === undefined ||
|
||||
(typeof parsedYaml === 'object' && Object.keys(parsedYaml).length === 0)
|
||||
) {
|
||||
throw new Error('YAML file is empty or contains only comments.');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateWorkflowOnServerAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
workflowFileContents: { yamlConfig: string }
|
||||
): Promise<void> {
|
||||
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
yamlConfig: workflowFileContents.yamlConfig,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: string): Promise<void> {
|
||||
const jobs = jobsFromWorkflow(parsedYaml);
|
||||
const buildJobs = jobs.filter(job => jobTypesWithBuildProfile.has(job.value.type));
|
||||
if (buildJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
|
||||
|
||||
const invalidBuildJobs = buildJobs.filter(
|
||||
job =>
|
||||
!buildProfileNames.has(job.value.params.profile) &&
|
||||
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
|
||||
!buildProfileIsInterpolated(job.value.params.profile)
|
||||
);
|
||||
|
||||
if (invalidBuildJobs.length > 0) {
|
||||
const invalidBuildProfiles = new Set(invalidBuildJobs.map(job => job.value.params.profile));
|
||||
throw new Error(
|
||||
`The build jobs in this workflow refer to the following build profiles that are not present in your eas.json file: ${[
|
||||
...invalidBuildProfiles,
|
||||
].join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
const jobs = jobsFromWorkflow(parsedYaml);
|
||||
const jobTypes = jobTypesFromWorkflowSchema(workflowJsonSchema);
|
||||
const invalidJobs = jobs.filter(job => job.value.type && !jobTypes.includes(job.value.type));
|
||||
if (invalidJobs.length > 0) {
|
||||
throw new Error(
|
||||
`The following jobs have invalid types: ${invalidJobs
|
||||
.map(job => job.key)
|
||||
.join(', ')}. Valid types are: ${jobTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
delete workflowJsonSchema['$schema'];
|
||||
|
||||
const ajv = createValidator();
|
||||
const validate = ajv.compile(workflowJsonSchema);
|
||||
const result = validate(parsedYaml);
|
||||
|
||||
if (!result) {
|
||||
Log.debug(
|
||||
JSON.stringify(
|
||||
{
|
||||
errors: validate.errors,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const readableErrors = getReadableErrors(validate.errors ?? []);
|
||||
const processedErrors = new Set<string>();
|
||||
for (const err of readableErrors) {
|
||||
if (err.message) {
|
||||
processedErrors.add(err.message);
|
||||
}
|
||||
}
|
||||
throw new Error([...processedErrors].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
|
||||
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
|
||||
return parsedYaml;
|
||||
}
|
||||
|
||||
export function workflowContentsFromParsedYaml(parsedYaml: any): string {
|
||||
return YAML.stringify(parsedYaml);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
async function fetchWorkflowSchemaAsync(): Promise<any> {
|
||||
// EXPO_TESTING_WORKFLOW_SCHEMA_PATH is used only for testing against a different schema
|
||||
if (process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH) {
|
||||
const schemaPath = path.resolve(process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH);
|
||||
Log.debug(`Loading workflow schema from ${schemaPath}`);
|
||||
const jsonString = await fs.readFile(schemaPath, 'utf-8');
|
||||
const jsonFromFile = JSON.parse(jsonString);
|
||||
return jsonFromFile.data;
|
||||
}
|
||||
// Otherwise, we fetch from <ApiBaseUrl>/v2/workflows/schema
|
||||
const schemaUrl = getExpoApiWorkflowSchemaURL();
|
||||
Log.debug(`Fetching workflow schema from ${schemaUrl}`);
|
||||
const response = await fetch(schemaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to fetch EAS Workflow schema, received status: ${response.status}`);
|
||||
}
|
||||
const jsonResponse: any = (await response.json()) as any;
|
||||
return jsonResponse.data;
|
||||
}
|
||||
|
||||
function jobsFromWorkflow(parsedYaml: any): any[] {
|
||||
return Object.entries(parsedYaml?.jobs).flatMap(([key, value]: [string, any]) => {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function jobTypesFromWorkflowSchema(workflowJsonSchema: any): string[] {
|
||||
return workflowJsonSchema?.properties?.jobs?.additionalProperties?.anyOf.map(
|
||||
(props: any) => props.properties.type.const
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -9,10 +10,10 @@ import Log, { learnMore } from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
|
||||
import { ensureEASUpdateIsConfiguredInEasJsonAsync } from '../../update/configure';
|
||||
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
|
||||
import { promptPlatformAsync } from '../../utils/prompts';
|
||||
|
||||
export default class BuildConfigure extends EasCommand {
|
||||
static override description = 'configure the project to support EAS Build';
|
||||
@@ -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
|
||||
@@ -107,24 +113,7 @@ export default class BuildConfigure extends EasCommand {
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
Log.addNewLineIfNone();
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
return await promptPlatformAsync({
|
||||
message: 'Which platforms would you like to configure for EAS Build?',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
title: 'All',
|
||||
value: RequestedPlatform.All,
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
value: RequestedPlatform.Ios,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
value: RequestedPlatform.Android,
|
||||
},
|
||||
],
|
||||
});
|
||||
return platform;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
BuildStatus,
|
||||
BuildWorkflow,
|
||||
DistributionType,
|
||||
EnvironmentVariableEnvironment,
|
||||
IosJobOverridesInput,
|
||||
ProjectArchiveSourceType,
|
||||
StatuspageServiceName,
|
||||
@@ -48,7 +47,7 @@ interface BuildResignFlags {
|
||||
sourceProfile?: string;
|
||||
maybeBuildId?: string;
|
||||
wait: boolean;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
}
|
||||
interface RawBuildResignFlags {
|
||||
json: boolean;
|
||||
@@ -60,7 +59,7 @@ interface RawBuildResignFlags {
|
||||
'source-profile': string | undefined;
|
||||
wait: boolean;
|
||||
id: string | undefined;
|
||||
environment: EnvironmentVariableEnvironment | undefined;
|
||||
environment: string | undefined;
|
||||
}
|
||||
|
||||
export default class BuildResign extends EasCommand {
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,10 @@ import * as path from 'node:path';
|
||||
import { getHostingDeploymentsUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
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 +19,10 @@ import {
|
||||
getSignedDeploymentUrlAsync,
|
||||
} from '../../worker/deployment';
|
||||
import {
|
||||
UploadParams,
|
||||
UploadPayload,
|
||||
batchUploadAsync,
|
||||
callUploadApiAsync,
|
||||
createProgressBar,
|
||||
uploadAsync,
|
||||
} from '../../worker/upload';
|
||||
import {
|
||||
@@ -32,6 +31,8 @@ import {
|
||||
getDeploymentUrlFromFullName,
|
||||
} from '../../worker/utils/logs';
|
||||
|
||||
const MAX_UPLOAD_SIZE = 5e8; // 500MB
|
||||
|
||||
const isDirectory = (directoryPath: string): Promise<boolean> =>
|
||||
fs.promises
|
||||
.stat(directoryPath)
|
||||
@@ -43,7 +44,7 @@ interface DeployFlags {
|
||||
json: boolean;
|
||||
isProduction: boolean;
|
||||
aliasName?: string;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
deploymentIdentifier?: string;
|
||||
exportDir: string;
|
||||
dryRun: boolean;
|
||||
@@ -51,7 +52,7 @@ interface DeployFlags {
|
||||
|
||||
interface RawDeployFlags {
|
||||
'non-interactive': boolean;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
json: boolean;
|
||||
prod: boolean;
|
||||
alias?: string;
|
||||
@@ -60,11 +61,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 {
|
||||
@@ -131,6 +137,7 @@ export default class WorkerDeploy extends EasCommand {
|
||||
logExportedProjectInfo(projectDist);
|
||||
|
||||
async function* emitWorkerTarballAsync(params: {
|
||||
routesConfig: WorkerAssets.RoutesConfigEntry | null;
|
||||
assetMap: WorkerAssets.AssetMap;
|
||||
manifest: WorkerAssets.Manifest;
|
||||
}): AsyncGenerator<WorkerAssets.FileEntry> {
|
||||
@@ -141,6 +148,8 @@ export default class WorkerDeploy extends EasCommand {
|
||||
for await (const workerFile of workerFiles) {
|
||||
yield [`server/${workerFile.normalizedPath}`, workerFile.data];
|
||||
}
|
||||
} else if (projectDist.type === 'static' && params.routesConfig) {
|
||||
yield ['routes.json', JSON.stringify(params.routesConfig)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,14 +171,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 +194,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 +286,14 @@ export default class WorkerDeploy extends EasCommand {
|
||||
manifestResult.conflictingVariableNames.join(' ')
|
||||
);
|
||||
}
|
||||
assetMap = await WorkerAssets.createAssetMapAsync(
|
||||
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path
|
||||
);
|
||||
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
|
||||
assetFiles = await WorkerAssets.collectAssetsAsync(assetPath, {
|
||||
maxFileSize: MAX_UPLOAD_SIZE,
|
||||
});
|
||||
tarPath = await WorkerAssets.packFilesIterableAsync(
|
||||
emitWorkerTarballAsync({
|
||||
assetMap,
|
||||
routesConfig: await WorkerAssets.getRoutesConfigAsync(assetPath),
|
||||
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
|
||||
manifest: manifestResult.manifest,
|
||||
})
|
||||
);
|
||||
@@ -334,7 +351,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;
|
||||
|
||||
+52
-30
@@ -1,11 +1,11 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
@@ -47,6 +47,7 @@ describe(EnvCreate, () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
|
||||
@@ -56,6 +57,7 @@ describe(EnvCreate, () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.updateAsync)
|
||||
@@ -101,7 +103,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -138,7 +140,7 @@ describe(EnvCreate, () => {
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
},
|
||||
]);
|
||||
@@ -166,7 +168,7 @@ describe(EnvCreate, () => {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
});
|
||||
});
|
||||
@@ -199,8 +201,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
isGlobal: true,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -235,7 +236,7 @@ describe(EnvCreate, () => {
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: 'otherId',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
@@ -273,7 +274,7 @@ describe(EnvCreate, () => {
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
@@ -284,12 +285,12 @@ describe(EnvCreate, () => {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an account-wide variable and links it', async () => {
|
||||
it('creates an account-wide variable', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'--name',
|
||||
@@ -302,7 +303,6 @@ describe(EnvCreate, () => {
|
||||
'development',
|
||||
'--scope',
|
||||
'account',
|
||||
'--link',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
@@ -320,28 +320,12 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [
|
||||
EnvironmentVariableEnvironment.Production,
|
||||
EnvironmentVariableEnvironment.Development,
|
||||
],
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Development],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
isGlobal: true,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
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 () => {
|
||||
@@ -356,7 +340,7 @@ describe(EnvCreate, () => {
|
||||
jest
|
||||
.mocked(promptVariableEnvironmentAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [EnvironmentVariableEnvironment.Production]);
|
||||
.mockImplementation(async () => [DefaultEnvironment.Production]);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -374,7 +358,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -382,4 +366,42 @@ describe(EnvCreate, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
const command = new EnvCreate(
|
||||
[
|
||||
'development',
|
||||
'--name',
|
||||
'TEST_VAR',
|
||||
'--value',
|
||||
'test-value',
|
||||
'--visibility',
|
||||
'plaintext',
|
||||
'--scope',
|
||||
'project',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'TEST_VAR',
|
||||
value: 'test-value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
@@ -35,7 +37,7 @@ describe(EnvDelete, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -121,4 +123,40 @@ describe(EnvDelete, () => {
|
||||
|
||||
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
const mockVariable = {
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
|
||||
jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValueOnce({ id: 'var1' });
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
|
||||
const command = new EnvDelete(
|
||||
['development', '--variable-name', 'TEST_VAR_1', '--scope', 'project', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import EnvExec from '../exec';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvExec, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
|
||||
const mockEnvironmentVariables = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'EXPO_PUBLIC_API_URL',
|
||||
value: 'https://api.example.com',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
|
||||
// Mock GraphQL query
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
|
||||
.mockResolvedValue(mockEnvironmentVariables);
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
const command = new EnvExec(['development', 'echo $EXPO_PUBLIC_API_URL'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts custom environment when using positional argument', async () => {
|
||||
const command = new EnvExec(['custom-environment', 'echo $EXPO_PUBLIC_API_URL'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: 'custom-environment',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
+34
-4
@@ -1,10 +1,10 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
@@ -27,7 +27,7 @@ describe(EnvGet, () => {
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -82,7 +82,7 @@ describe(EnvGet, () => {
|
||||
it('prompts for variable name and environment if the name is ambigous', async () => {
|
||||
jest
|
||||
.mocked(promptVariableEnvironmentAsync)
|
||||
.mockResolvedValueOnce(EnvironmentVariableEnvironment.Production);
|
||||
.mockResolvedValueOnce(DefaultEnvironment.Production);
|
||||
jest.mocked(promptVariableNameAsync).mockResolvedValueOnce('TEST_VAR_1');
|
||||
|
||||
const command = new EnvGet([], mockConfig);
|
||||
@@ -98,7 +98,7 @@ describe(EnvGet, () => {
|
||||
id: 'var2',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -111,4 +111,34 @@ describe(EnvGet, () => {
|
||||
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
|
||||
expect(promptVariableNameAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
|
||||
.mockResolvedValueOnce(mockVariables);
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
|
||||
const command = new EnvGet(
|
||||
['development', '--variable-name', 'TEST_VAR_1', '--scope', 'project'],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['TEST_VAR_1'],
|
||||
includeFileContent: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
-28
@@ -1,11 +1,11 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptVariableEnvironmentAsync } from '../../../utils/prompts';
|
||||
import EnvList from '../list';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../utils/prompts');
|
||||
jest.mock('../../../log');
|
||||
|
||||
const mockVariables: EnvironmentVariableFragment[] = [
|
||||
@@ -24,7 +26,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
createdAt: undefined,
|
||||
@@ -35,7 +37,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
|
||||
id: 'var2',
|
||||
name: 'TEST_VAR_2',
|
||||
value: 'value2',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
createdAt: undefined,
|
||||
@@ -51,27 +53,19 @@ describe(EnvList, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
});
|
||||
|
||||
it('lists project environment variables successfully', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvList([], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: undefined,
|
||||
includeFileContent: false,
|
||||
});
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
|
||||
(jest.mocked(promptVariableEnvironmentAsync) as jest.MockedFunction<any>).mockImplementation(
|
||||
async (args: any) => {
|
||||
if (args.nonInteractive) {
|
||||
throw new Error(
|
||||
'The `--environment` flag must be set when running in `--non-interactive` mode.'
|
||||
);
|
||||
}
|
||||
if (args.multiple) {
|
||||
return [DefaultEnvironment.Production];
|
||||
}
|
||||
return DefaultEnvironment.Production;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('lists project environment variables in specified environments', async () => {
|
||||
@@ -88,13 +82,33 @@ describe(EnvList, () => {
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: EnvironmentVariableEnvironment.Production,
|
||||
environment: DefaultEnvironment.Production,
|
||||
includeFileContent: false,
|
||||
});
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
|
||||
|
||||
const command = new EnvList(['development'], mockConfig); // Using positional argument
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
includeFileContent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists project environment variables including sensitive values', async () => {
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
|
||||
@@ -113,7 +127,7 @@ describe(EnvList, () => {
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: undefined,
|
||||
environment: DefaultEnvironment.Production,
|
||||
includeFileContent: false,
|
||||
}
|
||||
);
|
||||
@@ -135,7 +149,7 @@ describe(EnvList, () => {
|
||||
|
||||
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: undefined,
|
||||
environment: DefaultEnvironment.Production,
|
||||
includeFileContent: false,
|
||||
});
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
|
||||
@@ -158,7 +172,7 @@ describe(EnvList, () => {
|
||||
|
||||
expect(EnvironmentVariablesQuery.sharedWithSensitiveAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: undefined,
|
||||
environment: DefaultEnvironment.Production,
|
||||
includeFileContent: false,
|
||||
});
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import {
|
||||
EnvironmentVariableWithFileContent,
|
||||
EnvironmentVariablesQuery,
|
||||
} from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import EnvPull from '../pull';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe(EnvPull, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const testProjectDir = '/test/project';
|
||||
const testTargetPath = '.env.local';
|
||||
|
||||
const mockEnvironmentVariables: EnvironmentVariableWithFileContent[] = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'EXPO_PUBLIC_API_URL',
|
||||
value: 'https://api.example.com',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
{
|
||||
id: 'var2',
|
||||
name: 'DATABASE_URL',
|
||||
value: 'postgres://localhost:5432/mydb',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
{
|
||||
id: 'var3',
|
||||
name: 'SECRET_KEY',
|
||||
value: 'super-secret-key',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
{
|
||||
id: 'var4',
|
||||
name: 'CONFIG_FILE',
|
||||
value: 'base64-encoded-file-content',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
valueWithFileContent: Buffer.from('{"key": "value"}').toString('base64'),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
|
||||
// Mock fs-extra methods
|
||||
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(false));
|
||||
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.mkdir).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
|
||||
|
||||
// Mock GraphQL query
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
|
||||
.mockResolvedValue(mockEnvironmentVariables);
|
||||
});
|
||||
|
||||
describe('environment validation', () => {
|
||||
it('accepts development environment', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: 'development',
|
||||
includeFileContent: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts custom environment', async () => {
|
||||
const command = new EnvPull(['custom-environment'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: 'custom-environment',
|
||||
includeFileContent: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file operations', () => {
|
||||
it('writes environment variables to .env.local file', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
testTargetPath,
|
||||
expect.stringContaining('# Environment: development')
|
||||
);
|
||||
});
|
||||
|
||||
it('writes correct environment variable content', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Find the call that writes to .env.local (not the file variable)
|
||||
const writeFileCalls = jest.mocked(fs.writeFile).mock.calls;
|
||||
const envFileCall = writeFileCalls.find(call => call[0] === testTargetPath);
|
||||
expect(envFileCall).toBeDefined();
|
||||
const fileContent = envFileCall![1] as string;
|
||||
|
||||
// Check that the file contains the expected content
|
||||
expect(fileContent).toContain('# Environment: development');
|
||||
expect(fileContent).toContain('EXPO_PUBLIC_API_URL=https://api.example.com');
|
||||
expect(fileContent).toContain('DATABASE_URL=postgres://localhost:5432/mydb');
|
||||
expect(fileContent).toContain('# SECRET_KEY=***** (secret)');
|
||||
});
|
||||
|
||||
it('handles file variables correctly', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Should create .eas/.env directory for file variables
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(path.join(testProjectDir, '.eas', '.env'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Should write the file content (base64 encoded)
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join(testProjectDir, '.eas', '.env', 'CONFIG_FILE'),
|
||||
Buffer.from('{"key": "value"}').toString('base64'),
|
||||
'base64'
|
||||
);
|
||||
|
||||
// Should include file path in env file
|
||||
const writeFileCalls = jest.mocked(fs.writeFile).mock.calls;
|
||||
const envFileCall = writeFileCalls.find(call => call[0] === testTargetPath);
|
||||
expect(envFileCall).toBeDefined();
|
||||
const fileContent = envFileCall![1] as string;
|
||||
expect(fileContent).toContain('CONFIG_FILE=');
|
||||
expect(fileContent).toContain(path.join(testProjectDir, '.eas', '.env', 'CONFIG_FILE'));
|
||||
});
|
||||
|
||||
it('prompts for overwrite when file exists in interactive mode', async () => {
|
||||
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: `File ${testTargetPath} already exists. Do you want to overwrite it?`,
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts when user declines overwrite', async () => {
|
||||
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(`File ${testTargetPath} already exists.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('works in non-interactive mode', async () => {
|
||||
const command = new EnvPull(['development', '--non-interactive'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: 'development',
|
||||
includeFileContent: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('requires environment in non-interactive mode', async () => {
|
||||
const command = new EnvPull(['--non-interactive'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'The `--environment` flag must be set when running in `--non-interactive` mode.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom target path', () => {
|
||||
it('writes to custom path when specified', async () => {
|
||||
const customPath = '.env.custom';
|
||||
const command = new EnvPull(['development', '--path', customPath], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
customPath,
|
||||
expect.stringContaining('# Environment: development')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('logs success message with correct environment', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
`Pulled plain text and sensitive environment variables from "development" environment to ${testTargetPath}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('logs secret variables that were skipped', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('The following variables have the secret visibility')
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SECRET_KEY'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing .env.local file handling', () => {
|
||||
it('preserves existing secret values when they exist', async () => {
|
||||
const existingEnvContent = 'SECRET_KEY=existing-secret-value\nOTHER_VAR=other-value';
|
||||
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(existingEnvContent));
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Find the call that writes to .env.local (not the file variable)
|
||||
const writeFileCalls = jest.mocked(fs.writeFile).mock.calls;
|
||||
const envFileCall = writeFileCalls.find(call => call[0] === testTargetPath);
|
||||
expect(envFileCall).toBeDefined();
|
||||
const fileContent = envFileCall![1] as string;
|
||||
|
||||
// Should use existing secret value instead of placeholder
|
||||
expect(fileContent).toContain('SECRET_KEY=existing-secret-value');
|
||||
expect(fileContent).not.toContain('# SECRET_KEY=***** (secret)');
|
||||
|
||||
// Should log that it reused the local value
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Reused local values for following secrets: SECRET_KEY')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import EnvPush from '../push';
|
||||
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../prompts');
|
||||
|
||||
describe(EnvPush, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const testProjectDir = '/test/project';
|
||||
const testEnvPath = '.env.test';
|
||||
|
||||
const mockEnvContent = `EXPO_PUBLIC_API_URL=https://api.example.com
|
||||
VARIABLE_NAME=variable value
|
||||
SECRET_KEY=super-secret-key`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock fs-extra methods
|
||||
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(mockEnvContent));
|
||||
|
||||
// Mock GraphQL queries and mutations
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); // No existing variables
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync)
|
||||
.mockResolvedValue(true);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([]);
|
||||
|
||||
const command = new EnvPush(['development', '--path', testEnvPath], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['EXPO_PUBLIC_API_URL', 'VARIABLE_NAME', 'SECRET_KEY'],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts custom environment', async () => {
|
||||
const command = new EnvPush(['custom-environment', '--path', testEnvPath], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: 'custom-environment',
|
||||
filterNames: ['EXPO_PUBLIC_API_URL', 'VARIABLE_NAME', 'SECRET_KEY'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('--force option', () => {
|
||||
it('automatically overrides existing variables without confirmation when --force is used', async () => {
|
||||
const existingVariable = {
|
||||
id: 'var1',
|
||||
name: 'VARIABLE_NAME',
|
||||
value: 'old variable value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
|
||||
|
||||
const command = new EnvPush(['development', '--path', testEnvPath, '--force'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Should not call confirmAsync for existing variables
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
|
||||
// Should log that force flag is being used
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'Using --force flag: automatically overriding existing variables.'
|
||||
);
|
||||
|
||||
// Should call the mutation with overwrite flag
|
||||
expect(
|
||||
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
|
||||
).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'VARIABLE_NAME',
|
||||
value: 'variable value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
overwrite: true,
|
||||
}),
|
||||
]),
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('automatically overrides sensitive variables without confirmation when --force is used', async () => {
|
||||
const existingSensitiveVariable = {
|
||||
id: 'var1',
|
||||
name: 'SECRET_KEY',
|
||||
value: 'old-secret-key',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
.mockResolvedValue([existingSensitiveVariable]);
|
||||
|
||||
const command = new EnvPush(['development', '--path', testEnvPath, '--force'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Should not call confirmAsync for sensitive variables
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
|
||||
// Should log that force flag is being used for sensitive variables
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'Using --force flag: automatically overriding sensitive variables.'
|
||||
);
|
||||
|
||||
// Should call the mutation with overwrite flag
|
||||
expect(
|
||||
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
|
||||
).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'SECRET_KEY',
|
||||
value: 'super-secret-key',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
overwrite: true,
|
||||
}),
|
||||
]),
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('logs appropriate messages when using --force flag', async () => {
|
||||
const existingVariable = {
|
||||
id: 'var1',
|
||||
name: 'VARIABLE_NAME',
|
||||
value: 'old variable value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
|
||||
|
||||
const command = new EnvPush(['development', '--path', testEnvPath, '--force'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Should log both force messages
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'Using --force flag: automatically overriding existing variables.'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'Using --force flag: automatically overriding sensitive variables.'
|
||||
);
|
||||
});
|
||||
|
||||
it('still prompts for confirmation when --force is not used', async () => {
|
||||
const existingVariable = {
|
||||
id: 'var1',
|
||||
name: 'VARIABLE_NAME',
|
||||
value: 'old variable value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
const command = new EnvPush(['development', '--path', testEnvPath], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
// Should call confirmAsync for both existing variables and sensitive variables
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(2);
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message:
|
||||
'The VARIABLE_NAME environment variable already exists in development environment. Do you want to override it?',
|
||||
});
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message:
|
||||
'You are about to overwrite sensitive variables.\n- VARIABLE_NAME\n Do you want to continue?',
|
||||
});
|
||||
|
||||
// Should not log force messages
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
'Using --force flag: automatically overriding existing variables.'
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
'Using --force flag: automatically overriding sensitive variables.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { Config } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
@@ -11,12 +12,18 @@ import { EnvironmentVariableMutation } from '../../../graphql/mutations/Environm
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import {
|
||||
parseVisibility,
|
||||
promptVariableEnvironmentAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../../utils/prompts';
|
||||
import EnvUpdate from '../update';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../utils/prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
|
||||
@@ -33,6 +40,41 @@ describe(EnvUpdate, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
(jest.mocked(promptVariableEnvironmentAsync) as jest.MockedFunction<any>).mockImplementation(
|
||||
async (args: any) => {
|
||||
if (args.nonInteractive) {
|
||||
throw new Error(
|
||||
'The `--environment` flag must be set when running in `--non-interactive` mode.'
|
||||
);
|
||||
}
|
||||
if (args.multiple) {
|
||||
return ['production'];
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
);
|
||||
(jest.mocked(promptVariableVisibilityAsync) as jest.MockedFunction<any>).mockResolvedValue(
|
||||
EnvironmentVariableVisibility.Public
|
||||
);
|
||||
(jest.mocked(parseVisibility) as jest.MockedFunction<any>).mockImplementation(
|
||||
(visibility: string) => {
|
||||
switch (visibility) {
|
||||
case 'plaintext':
|
||||
return EnvironmentVariableVisibility.Public;
|
||||
case 'sensitive':
|
||||
return EnvironmentVariableVisibility.Sensitive;
|
||||
case 'secret':
|
||||
return EnvironmentVariableVisibility.Secret;
|
||||
default:
|
||||
throw new Error(`Invalid visibility: ${visibility}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
(jest.mocked(selectAsync) as jest.MockedFunction<any>).mockResolvedValue({
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a project variable by selected name in non-interactive mode', async () => {
|
||||
@@ -41,7 +83,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -73,7 +115,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'NEW_VARIABLE',
|
||||
value: 'new-value',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
});
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
@@ -87,7 +129,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -119,7 +161,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'NEW_VARIABLE',
|
||||
value: 'new-value',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
});
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
`Updated variable ${chalk.bold('TEST_VARIABLE')} on account testuser.`
|
||||
@@ -129,6 +171,7 @@ describe(EnvUpdate, () => {
|
||||
it('prompts for variable selection when selected name is not provided', async () => {
|
||||
const mockVariables = [
|
||||
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
|
||||
{ id: '2', name: 'ANOTHER_VARIABLE', scope: EnvironmentVariableScope.Project },
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
@@ -140,7 +183,7 @@ describe(EnvUpdate, () => {
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalled();
|
||||
expect(selectAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ id: variableId })
|
||||
@@ -162,4 +205,50 @@ describe(EnvUpdate, () => {
|
||||
'Variable with name NON_EXISTENT_VARIABLE does not exist on project @testuser/testpp.'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
const mockVariable = {
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
|
||||
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValueOnce(mockVariable);
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'development',
|
||||
'--variable-name',
|
||||
'TEST_VAR_1',
|
||||
'--value',
|
||||
'new-value',
|
||||
'--scope',
|
||||
'project',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['TEST_VAR_1'],
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
-85
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../graphql/generated';
|
||||
@@ -33,29 +33,26 @@ import {
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../utils/prompts';
|
||||
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
|
||||
|
||||
interface RawCreateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
link?: boolean;
|
||||
force?: boolean;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
interface CreateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
link?: boolean;
|
||||
force?: boolean;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EnvironmentVariableScope;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
@@ -66,7 +63,7 @@ export default class EnvCreate extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to create the variable in. One of 'production', 'preview', or 'development'.",
|
||||
"Environment to create the variable in. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -78,10 +75,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,
|
||||
@@ -107,6 +100,13 @@ export default class EnvCreate extends EasCommand {
|
||||
|
||||
const validatedFlags = this.sanitizeFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvCreate, {
|
||||
nonInteractive: validatedFlags['non-interactive'],
|
||||
});
|
||||
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
@@ -114,18 +114,10 @@ export default class EnvCreate extends EasCommand {
|
||||
'non-interactive': nonInteractive,
|
||||
environment: environments,
|
||||
visibility,
|
||||
link,
|
||||
force,
|
||||
type,
|
||||
fileName,
|
||||
} = await this.promptForMissingFlagsAsync(validatedFlags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvCreate, {
|
||||
nonInteractive,
|
||||
});
|
||||
} = await this.promptForMissingFlagsAsync(validatedFlags, args, { graphqlClient, projectId });
|
||||
|
||||
const [projectDisplayName, ownerAccount] = await Promise.all([
|
||||
getDisplayNameForProjectIdAsync(graphqlClient, projectId),
|
||||
@@ -154,29 +146,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 =
|
||||
@@ -250,7 +219,6 @@ export default class EnvCreate extends EasCommand {
|
||||
value,
|
||||
visibility,
|
||||
environments,
|
||||
isGlobal: true, // TODO: every account-wide variable is global for now so it's not user facing
|
||||
type: type ?? EnvironmentSecretType.String,
|
||||
},
|
||||
ownerAccount.id
|
||||
@@ -265,29 +233,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
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +271,8 @@ export default class EnvCreate extends EasCommand {
|
||||
type,
|
||||
...rest
|
||||
}: CreateFlags,
|
||||
{ environment }: { environment?: string }
|
||||
{ environment }: { environment?: string },
|
||||
{ graphqlClient, projectId }: { graphqlClient: ExpoGraphqlClient; projectId: string }
|
||||
): Promise<
|
||||
Required<
|
||||
Omit<CreateFlags, 'type' | 'visibility'> & {
|
||||
@@ -377,18 +323,16 @@ export default class EnvCreate extends EasCommand {
|
||||
|
||||
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
|
||||
|
||||
if (environment && !isEnvironment(environment.toUpperCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
let newEnvironments = environments
|
||||
? environments
|
||||
: environment
|
||||
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
|
||||
: undefined;
|
||||
let newEnvironments = environments ? environments : environment ? [environment] : undefined;
|
||||
|
||||
if (!newEnvironments) {
|
||||
newEnvironments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
|
||||
newEnvironments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!newEnvironments || newEnvironments.length === 0) {
|
||||
throw new Error('No environments selected');
|
||||
@@ -402,7 +346,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 +355,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:
|
||||
|
||||
+6
-13
@@ -9,23 +9,23 @@ import {
|
||||
EASNonInteractiveFlag,
|
||||
EasEnvironmentFlagParameters,
|
||||
} from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
|
||||
import { EnvironmentVariableScope } from '../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../prompts';
|
||||
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
|
||||
import { formatVariableName } from '../../utils/variableUtils';
|
||||
|
||||
interface DeleteFlags {
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'variable-environment'?: string;
|
||||
'non-interactive': boolean;
|
||||
scope: EnvironmentVariableScope;
|
||||
}
|
||||
|
||||
interface RawDeleteFlags {
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'variable-environment'?: string;
|
||||
'non-interactive': boolean;
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default class EnvDelete extends EasCommand {
|
||||
'variable-name': Flags.string({
|
||||
description: 'Name of the variable to delete',
|
||||
}),
|
||||
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
|
||||
'variable-environment': Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
description: 'Current environment of the variable to delete',
|
||||
}),
|
||||
@@ -49,7 +49,7 @@ export default class EnvDelete extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Current environment of the variable to delete. One of 'production', 'preview', or 'development'.",
|
||||
"Current environment of the variable to delete. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -165,13 +165,6 @@ export default class EnvDelete extends EasCommand {
|
||||
: EnvironmentVariableScope.Project;
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return { ...flags, 'variable-environment': environment, scope };
|
||||
}
|
||||
|
||||
|
||||
+9
-13
@@ -4,21 +4,19 @@ import chalk from 'chalk';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
type ParsedFlags =
|
||||
| {
|
||||
nonInteractive: true;
|
||||
environment: EnvironmentVariableEnvironment;
|
||||
environment: string;
|
||||
command: string;
|
||||
}
|
||||
| {
|
||||
nonInteractive: false;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
@@ -44,7 +42,7 @@ export default class EnvExec extends EasCommand {
|
||||
name: 'environment',
|
||||
required: true,
|
||||
description:
|
||||
"Environment to execute the command in. One of 'production', 'preview', or 'development'.",
|
||||
"Environment to execute the command in. Default environments are 'production', 'preview', and 'development'.",
|
||||
},
|
||||
{
|
||||
name: 'bash_command',
|
||||
@@ -70,7 +68,11 @@ export default class EnvExec extends EasCommand {
|
||||
|
||||
const environment =
|
||||
parsedFlags.environment ??
|
||||
(await promptVariableEnvironmentAsync({ nonInteractive: parsedFlags.nonInteractive }));
|
||||
(await promptVariableEnvironmentAsync({
|
||||
nonInteractive: parsedFlags.nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}));
|
||||
const environmentVariables = await this.loadEnvironmentVariablesAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
@@ -101,12 +103,6 @@ export default class EnvExec extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
environment = environment?.toUpperCase();
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const firstChar = bash_command[0];
|
||||
const lastChar = bash_command[bash_command.length - 1];
|
||||
const cleanCommand =
|
||||
@@ -198,7 +194,7 @@ export default class EnvExec extends EasCommand {
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
environment: EnvironmentVariableEnvironment;
|
||||
environment: string;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<Record<string, string>> {
|
||||
const environmentVariablesQueryResult =
|
||||
|
||||
+8
-13
@@ -11,7 +11,6 @@ import {
|
||||
EasEnvironmentFlagParameters,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
@@ -19,11 +18,11 @@ import {
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import { promptVariableEnvironmentAsync, promptVariableNameAsync } from '../../utils/prompts';
|
||||
import { formatVariable, formatVariableValue, isEnvironment } from '../../utils/variableUtils';
|
||||
import { formatVariable, formatVariableValue } from '../../utils/variableUtils';
|
||||
|
||||
interface RawGetFlags {
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'variable-environment'?: string;
|
||||
'non-interactive': boolean;
|
||||
format?: string;
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
@@ -31,7 +30,7 @@ interface RawGetFlags {
|
||||
|
||||
interface GetFlags {
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'variable-environment'?: string;
|
||||
'non-interactive': boolean;
|
||||
format?: string;
|
||||
scope: EnvironmentVariableScope;
|
||||
@@ -49,7 +48,7 @@ export default class EnvGet extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Current environment of the variable. One of 'production', 'preview', or 'development'.",
|
||||
"Current environment of the variable. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -58,7 +57,7 @@ export default class EnvGet extends EasCommand {
|
||||
'variable-name': Flags.string({
|
||||
description: 'Name of the variable',
|
||||
}),
|
||||
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
|
||||
'variable-environment': Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
description: 'Current environment of the variable',
|
||||
}),
|
||||
@@ -93,6 +92,8 @@ export default class EnvGet extends EasCommand {
|
||||
environment = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: false,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,12 +157,6 @@ export default class EnvGet extends EasCommand {
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project;
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return {
|
||||
...flags,
|
||||
'variable-environment': environment,
|
||||
@@ -177,7 +172,7 @@ async function getVariablesAsync(
|
||||
scope: string,
|
||||
projectId: string,
|
||||
name: string | undefined,
|
||||
environment: EnvironmentVariableEnvironment | undefined
|
||||
environment: string | undefined
|
||||
): Promise<EnvironmentVariableFragment[]> {
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
|
||||
-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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -9,7 +9,7 @@ import {
|
||||
EASMultiEnvironmentFlag,
|
||||
EASVariableFormatFlag,
|
||||
} from '../../commandUtils/flags';
|
||||
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
|
||||
import { EnvironmentVariableScope } from '../../graphql/generated';
|
||||
import {
|
||||
EnvironmentVariableWithFileContent,
|
||||
EnvironmentVariablesQuery,
|
||||
@@ -19,7 +19,6 @@ import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import {
|
||||
formatVariable,
|
||||
formatVariableValue,
|
||||
isEnvironment,
|
||||
performForEnvironmentsAsync,
|
||||
} from '../../utils/variableUtils';
|
||||
|
||||
@@ -35,7 +34,7 @@ async function getVariablesForScopeAsync(
|
||||
scope: EnvironmentVariableScope;
|
||||
includingSensitive: boolean;
|
||||
includeFileContent: boolean;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
projectId: string;
|
||||
}
|
||||
): Promise<EnvironmentVariableWithFileContent[]> {
|
||||
@@ -70,7 +69,7 @@ async function getVariablesForScopeAsync(
|
||||
interface RawListFlags {
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
format: string;
|
||||
environment: EnvironmentVariableEnvironment[] | undefined;
|
||||
environment: string[] | undefined;
|
||||
'include-sensitive': boolean;
|
||||
'include-file-content': boolean;
|
||||
'non-interactive'?: boolean;
|
||||
@@ -79,7 +78,7 @@ interface RawListFlags {
|
||||
interface ListFlags {
|
||||
scope: EnvironmentVariableScope;
|
||||
format: string;
|
||||
environment: EnvironmentVariableEnvironment[] | undefined;
|
||||
environment: string[] | undefined;
|
||||
'include-sensitive': boolean;
|
||||
'include-file-content': boolean;
|
||||
'non-interactive': boolean;
|
||||
@@ -111,7 +110,7 @@ export default class EnvList extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to list the variables from. One of 'production', 'preview', or 'development'.",
|
||||
"Environment to list the variables from. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -136,7 +135,12 @@ export default class EnvList extends EasCommand {
|
||||
});
|
||||
|
||||
if (!environments) {
|
||||
environments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
await performForEnvironmentsAsync(environments, async environment => {
|
||||
@@ -179,14 +183,10 @@ export default class EnvList extends EasCommand {
|
||||
flags: RawListFlags,
|
||||
{ environment }: { environment?: string }
|
||||
): ListFlags {
|
||||
if (environment && !isEnvironment(environment.toUpperCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments = flags.environment
|
||||
? flags.environment
|
||||
: environment
|
||||
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
|
||||
? [environment]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
+10
-11
@@ -13,7 +13,6 @@ import {
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
export default class EnvPull extends EasCommand {
|
||||
static override description =
|
||||
@@ -29,7 +28,7 @@ export default class EnvPull extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
|
||||
"Environment to pull variables from. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -49,15 +48,7 @@ export default class EnvPull extends EasCommand {
|
||||
flags: { environment: flagEnvironment, path: targetPath, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(EnvPull);
|
||||
|
||||
let environment = flagEnvironment?.toUpperCase() ?? argEnvironment?.toUpperCase();
|
||||
|
||||
if (!environment) {
|
||||
environment = await promptVariableEnvironmentAsync({ nonInteractive });
|
||||
}
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
let environment = flagEnvironment?.toLowerCase() ?? argEnvironment?.toLowerCase();
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -67,6 +58,14 @@ export default class EnvPull extends EasCommand {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
environment = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
targetPath = targetPath ?? '.env.local';
|
||||
|
||||
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
|
||||
|
||||
+63
-55
@@ -6,20 +6,16 @@ import path from 'path';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentVariableEnvironment,
|
||||
CreateEnvironmentVariableInput,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../graphql/generated';
|
||||
import {
|
||||
EnvironmentVariableMutation,
|
||||
EnvironmentVariablePushInput,
|
||||
} from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
export default class EnvPush extends EasCommand {
|
||||
static override description =
|
||||
@@ -36,13 +32,17 @@ export default class EnvPush extends EasCommand {
|
||||
description: 'Path to the input `.env` file',
|
||||
default: '.env.local',
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description: 'Skip confirmation and automatically override existing variables',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to push variables to. One of 'production', 'preview', or 'development'.",
|
||||
"Environment to push variables to. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -50,7 +50,7 @@ export default class EnvPush extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvPush);
|
||||
|
||||
let { environment: environments, path: envPath } = this.parseFlagsAndArgs(flags, args);
|
||||
let { environment: environments, path: envPath, force } = this.parseFlagsAndArgs(flags, args);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -63,11 +63,13 @@ export default class EnvPush extends EasCommand {
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive: false,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
const updateVariables: Record<string, EnvironmentVariablePushInput> =
|
||||
await this.parseEnvFileAsync(envPath, environments);
|
||||
const updateVariables = await this.parseEnvFileAsync(envPath, environments);
|
||||
|
||||
const variableNames = Object.keys(updateVariables);
|
||||
|
||||
@@ -117,40 +119,46 @@ export default class EnvPush extends EasCommand {
|
||||
Log.warn(`Some variables already exist in the ${displayedEnvironment} environment.`);
|
||||
const variableNames = existingDifferentVariables.map(variable => variable.name);
|
||||
|
||||
const confirmationMessage =
|
||||
variableNames.length > 1
|
||||
? `The ${variableNames.join(
|
||||
', '
|
||||
)} environment variables already exist in ${displayedEnvironment} environment. Do you want to override them all?`
|
||||
: `The ${variableNames[0]} environment variable already exists in ${displayedEnvironment} environment. Do you want to override it?`;
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: confirmationMessage,
|
||||
});
|
||||
|
||||
let variablesToOverwrite: string[] = [];
|
||||
|
||||
if (!confirm && existingDifferentVariables.length === 0) {
|
||||
throw new Error('No new variables to push.');
|
||||
}
|
||||
|
||||
if (confirm) {
|
||||
if (force) {
|
||||
// When --force is used, automatically override all existing variables
|
||||
Log.log('Using --force flag: automatically overriding existing variables.');
|
||||
variablesToOverwrite = existingDifferentVariables.map(variable => variable.name);
|
||||
} else {
|
||||
const promptResult = await promptAsync({
|
||||
type: 'multiselect',
|
||||
name: 'variablesToOverwrite',
|
||||
message: 'Select variables to overwrite:',
|
||||
// @ts-expect-error property missing from `@types/prompts`
|
||||
optionsPerPage: 20,
|
||||
choices: existingDifferentVariables.map(variable => ({
|
||||
title: `${variable.name}: ${updateVariables[variable.name].value} (was ${
|
||||
variable.value ?? '(secret)'
|
||||
})`,
|
||||
value: variable.name,
|
||||
})),
|
||||
const confirmationMessage =
|
||||
variableNames.length > 1
|
||||
? `The ${variableNames.join(
|
||||
', '
|
||||
)} environment variables already exist in ${displayedEnvironment} environment. Do you want to override them all?`
|
||||
: `The ${variableNames[0]} environment variable already exists in ${displayedEnvironment} environment. Do you want to override it?`;
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: confirmationMessage,
|
||||
});
|
||||
variablesToOverwrite = promptResult.variablesToOverwrite;
|
||||
|
||||
if (!confirm && existingDifferentVariables.length === 0) {
|
||||
throw new Error('No new variables to push.');
|
||||
}
|
||||
|
||||
if (confirm) {
|
||||
variablesToOverwrite = existingDifferentVariables.map(variable => variable.name);
|
||||
} else {
|
||||
const promptResult = await promptAsync({
|
||||
type: 'multiselect',
|
||||
name: 'variablesToOverwrite',
|
||||
message: 'Select variables to overwrite:',
|
||||
// @ts-expect-error property missing from `@types/prompts`
|
||||
optionsPerPage: 20,
|
||||
choices: existingDifferentVariables.map(variable => ({
|
||||
title: `${variable.name}: ${updateVariables[variable.name].value} (was ${
|
||||
variable.value ?? '(secret)'
|
||||
})`,
|
||||
value: variable.name,
|
||||
})),
|
||||
});
|
||||
variablesToOverwrite = promptResult.variablesToOverwrite;
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingVariable of existingVariables) {
|
||||
@@ -168,7 +176,7 @@ export default class EnvPush extends EasCommand {
|
||||
variable => variable.visibility !== EnvironmentVariableVisibility.Public
|
||||
);
|
||||
|
||||
if (existingSensitiveVariables.length > 0) {
|
||||
if (existingSensitiveVariables.length > 0 && !force) {
|
||||
const existingSensitiveVariablesNames = existingSensitiveVariables.map(
|
||||
variable => `- ${variable.name}`
|
||||
);
|
||||
@@ -180,6 +188,8 @@ export default class EnvPush extends EasCommand {
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting...');
|
||||
}
|
||||
} else if (existingSensitiveVariables.length > 0 && force) {
|
||||
Log.log('Using --force flag: automatically overriding sensitive variables.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,16 +209,14 @@ export default class EnvPush extends EasCommand {
|
||||
}
|
||||
|
||||
parseFlagsAndArgs(
|
||||
flags: { path: string; environment: EnvironmentVariableEnvironment[] | undefined },
|
||||
flags: {
|
||||
path: string;
|
||||
environment: string[] | undefined;
|
||||
force: boolean;
|
||||
},
|
||||
{ environment }: Record<string, string>
|
||||
): { environment?: EnvironmentVariableEnvironment[]; path: string } {
|
||||
if (environment && !isEnvironment(environment.toUpperCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments =
|
||||
flags.environment ??
|
||||
(environment ? [environment.toUpperCase() as EnvironmentVariableEnvironment] : undefined);
|
||||
): { environment?: string[]; path: string; force: boolean } {
|
||||
const environments = flags.environment ?? (environment ? [environment] : undefined);
|
||||
|
||||
return {
|
||||
...flags,
|
||||
@@ -218,12 +226,12 @@ export default class EnvPush extends EasCommand {
|
||||
|
||||
private async parseEnvFileAsync(
|
||||
envPath: string,
|
||||
environments: EnvironmentVariableEnvironment[]
|
||||
): Promise<Record<string, EnvironmentVariablePushInput>> {
|
||||
environments: string[]
|
||||
): Promise<Record<string, CreateEnvironmentVariableInput & { environments: string[] }>> {
|
||||
if (!(await fs.exists(envPath))) {
|
||||
throw new Error(`File ${envPath} does not exist.`);
|
||||
}
|
||||
const pushInput: Record<string, EnvironmentVariablePushInput> = {};
|
||||
const pushInput: Awaited<ReturnType<typeof this.parseEnvFileAsync>> = {};
|
||||
|
||||
const variables: Record<string, string> = dotenv.parse(await fs.readFile(envPath, 'utf8'));
|
||||
|
||||
@@ -246,9 +254,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;
|
||||
}
|
||||
}
|
||||
+26
-24
@@ -5,6 +5,7 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableEnvironment,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
@@ -36,17 +36,17 @@ import {
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../utils/prompts';
|
||||
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
|
||||
import { formatVariableName } from '../../utils/variableUtils';
|
||||
|
||||
interface RawUpdateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
environment?: string[];
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
type?: 'string' | 'file';
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'variable-environment'?: string;
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ interface UpdateFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
scope: EnvironmentVariableScope;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
environment?: string[];
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
type?: 'string' | 'file';
|
||||
'variable-name'?: string;
|
||||
'variable-environment'?: EnvironmentVariableEnvironment;
|
||||
'variable-environment'?: string;
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default class EnvUpdate extends EasCommand {
|
||||
'variable-name': Flags.string({
|
||||
description: 'Current name of the variable',
|
||||
}),
|
||||
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
|
||||
'variable-environment': Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
description: 'Current environment of the variable to update',
|
||||
}),
|
||||
@@ -93,7 +93,7 @@ export default class EnvUpdate extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Current environment of the variable to update. One of 'production', 'preview', or 'development'.",
|
||||
"Current environment of the variable to update. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -180,15 +180,19 @@ export default class EnvUpdate extends EasCommand {
|
||||
visibility: newVisibility,
|
||||
type: newType,
|
||||
fileName,
|
||||
} = await this.promptForMissingFlagsAsync(selectedVariable, {
|
||||
name,
|
||||
value: rawValue,
|
||||
environment: environments,
|
||||
visibility,
|
||||
'non-interactive': nonInteractive,
|
||||
type,
|
||||
scope,
|
||||
});
|
||||
} = await this.promptForMissingFlagsAsync(
|
||||
selectedVariable,
|
||||
{
|
||||
name,
|
||||
value: rawValue,
|
||||
environment: environments,
|
||||
visibility,
|
||||
'non-interactive': nonInteractive,
|
||||
type,
|
||||
scope,
|
||||
},
|
||||
{ graphqlClient, projectId }
|
||||
);
|
||||
|
||||
const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: selectedVariable.id,
|
||||
@@ -226,12 +230,6 @@ export default class EnvUpdate extends EasCommand {
|
||||
: EnvironmentVariableScope.Project;
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return {
|
||||
...flags,
|
||||
'variable-environment': environment,
|
||||
@@ -252,7 +250,8 @@ export default class EnvUpdate extends EasCommand {
|
||||
'non-interactive': nonInteractive,
|
||||
type,
|
||||
...rest
|
||||
}: UpdateFlags
|
||||
}: UpdateFlags,
|
||||
{ graphqlClient, projectId }: { graphqlClient: ExpoGraphqlClient; projectId: string }
|
||||
): Promise<
|
||||
Omit<UpdateFlags, 'type' | 'visibility'> & {
|
||||
type?: EnvironmentSecretType;
|
||||
@@ -316,7 +315,10 @@ export default class EnvUpdate extends EasCommand {
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
selectedEnvironments: selectedVariable.environments ?? [],
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
EnvironmentVariableEnvironment,
|
||||
FingerprintFragment,
|
||||
UpdateFragment,
|
||||
} from '../../graphql/generated';
|
||||
@@ -97,7 +96,7 @@ export default class FingerprintCompare extends EasCommand {
|
||||
open: Flags.boolean({
|
||||
description: 'Open the fingerprint comparison in the browser',
|
||||
}),
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>({
|
||||
environment: Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
description:
|
||||
'If generating a fingerprint from the local directory, use the specified environment.',
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getFingerprintInfoFromLocalProjectForPlatformsAsync,
|
||||
stringToAppPlatform,
|
||||
} from '../../fingerprint/utils';
|
||||
import { AppPlatform, EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
@@ -39,7 +39,7 @@ export default class FingerprintGenerate extends EasCommand {
|
||||
options: ['android', 'ios'],
|
||||
}),
|
||||
...EASEnvironmentFlag,
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>({
|
||||
environment: Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
exclusive: ['build-profile'],
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
import merge from 'ts-deepmerge';
|
||||
|
||||
import { getEASUpdateURL } from '../../../api';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { jester, jester2 } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { AppFragment, Role } from '../../../graphql/generated';
|
||||
import { AppMutation } from '../../../graphql/mutations/AppMutation';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
promptForPackageManagerAsync,
|
||||
} from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import { Ora, ora } from '../../../ora';
|
||||
import {
|
||||
createOrModifyExpoConfigAsync,
|
||||
getPrivateExpoConfigAsync,
|
||||
} from '../../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { Actor, getActorUsername } from '../../../user/User';
|
||||
import New, {
|
||||
cloneTemplateAsync,
|
||||
copyProjectTemplatesAsync,
|
||||
createProjectAsync,
|
||||
formatScriptCommand,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
getAccountChoices,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
mergeReadmeAsync,
|
||||
promptForTargetDirectoryAsync,
|
||||
updatePackageJsonAsync,
|
||||
} from '../new';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../graphql/mutations/AppMutation');
|
||||
jest.mock('../../../project/expoConfig');
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../../../user/User', () => ({
|
||||
getActorUsername: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
jest.mock('../../../api');
|
||||
jest.mock('../../../build/utils/url');
|
||||
jest.mock('../../../utils/easCli', () => ({
|
||||
easCliVersion: '5.0.0',
|
||||
}));
|
||||
// Remove Log module mocking entirely - use the actual module
|
||||
|
||||
describe(New.name, () => {
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Capture console.log calls
|
||||
consoleLogSpy = jest.spyOn(console, 'log');
|
||||
|
||||
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readJson).mockImplementation(() => Promise.resolve({}));
|
||||
jest.mocked(fs.writeJson).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.copy).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.remove).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Helper function to get all console output as strings
|
||||
const getConsoleOutput = (): string[] => {
|
||||
return consoleLogSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
|
||||
};
|
||||
|
||||
// Helper function to check if a specific message was logged
|
||||
const expectConsoleToContain = (message: string): void => {
|
||||
const output = getConsoleOutput();
|
||||
// strip out ANSI codes and special characters like the tick
|
||||
const outputWithoutAnsi = output.map(line =>
|
||||
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
|
||||
);
|
||||
// throw new Error(message + '\n' + outputWithoutAnsi.join('\n'));
|
||||
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
|
||||
};
|
||||
|
||||
// Helper function to verify prompt was called with expected message
|
||||
const expectPromptToHaveMessage = (expectedMessage: string): void => {
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expectedMessage,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to simulate user input via prompt
|
||||
const mockUserInput = (inputValue: any): void => {
|
||||
jest.mocked(promptAsync).mockResolvedValue(inputValue);
|
||||
};
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('promptForTargetDirectoryAsync', () => {
|
||||
it('should prompt for target directory', async () => {
|
||||
const promptedDirectory = '/test/prompted-project';
|
||||
mockUserInput({ targetProjectDir: promptedDirectory });
|
||||
|
||||
const result = await promptForTargetDirectoryAsync();
|
||||
|
||||
expectConsoleToContain(
|
||||
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
|
||||
);
|
||||
expectPromptToHaveMessage('Where would you like to create your new project directory?');
|
||||
expect(result).toBe(promptedDirectory);
|
||||
});
|
||||
|
||||
it('should handle the target directory from args', async () => {
|
||||
const providedDirectory = '/test/provided-project';
|
||||
const result = await promptForTargetDirectoryAsync(providedDirectory);
|
||||
|
||||
expectConsoleToContain(
|
||||
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
|
||||
);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toBe(providedDirectory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneTemplateAsync', () => {
|
||||
it('should clone the template project with ssh', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
|
||||
expectConsoleToContain(`📂 Cloning the project to ${targetProjectDir}`);
|
||||
expectConsoleToContain('We detected that ssh is your preferred git clone method');
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'ssh',
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
|
||||
it('should clone the template project with https', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
|
||||
expectConsoleToContain(`📂 Cloning the project to ${targetProjectDir}`);
|
||||
expectConsoleToContain('We detected that https is your preferred git clone method');
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'https',
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installProjectDependenciesAsync', () => {
|
||||
it('should install the project dependencies', async () => {
|
||||
const projectDir = '/test/project-directory';
|
||||
|
||||
jest.mocked(promptForPackageManagerAsync).mockResolvedValue('npm');
|
||||
jest.mocked(installDependenciesAsync).mockResolvedValue();
|
||||
jest.mocked(runCommandAsync).mockResolvedValue();
|
||||
|
||||
const packageManager = await installProjectDependenciesAsync(projectDir);
|
||||
expect(packageManager).toBe('npm');
|
||||
|
||||
expect(promptForPackageManagerAsync).toHaveBeenCalled();
|
||||
expect(installDependenciesAsync).toHaveBeenCalledWith({
|
||||
projectDir,
|
||||
packageManager: 'npm',
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-updates'],
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', '@expo/metro-runtime'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountChoices', () => {
|
||||
it('should return mapped account choices', () => {
|
||||
// Use jester fixture which has multiple accounts with different permissions
|
||||
const namesWithSufficientPermissions = new Set(['jester']); // Only personal account has permissions
|
||||
|
||||
const result = getAccountChoices(jester, namesWithSufficientPermissions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'jester (personal account)',
|
||||
value: { name: 'jester' },
|
||||
},
|
||||
{
|
||||
title: 'other',
|
||||
value: { name: 'other' },
|
||||
disabled: true,
|
||||
description:
|
||||
'You do not have the required permissions to create projects on this account.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProjectAsync', () => {
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
const projectDir = '/test/project-dir';
|
||||
const mockProjectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
// use single-account jester for most of the tests
|
||||
jest.mocked(getActorUsername).mockReturnValue('jester2');
|
||||
|
||||
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
|
||||
name: 'test-app',
|
||||
slug: 'test-app',
|
||||
extra: {},
|
||||
} as ExpoConfig);
|
||||
|
||||
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
|
||||
type: 'success' as const,
|
||||
message: 'Config updated',
|
||||
config: {
|
||||
name: 'test-app',
|
||||
slug: 'test-app',
|
||||
extra: { eas: { projectId: 'test-project-id' } },
|
||||
} as ExpoConfig,
|
||||
});
|
||||
|
||||
const mockSpinner: Partial<Ora> = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(mockSpinner as Ora);
|
||||
});
|
||||
|
||||
it('should create a project', async () => {
|
||||
const mockActor = jester2 as Actor;
|
||||
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue(mockProjectId);
|
||||
|
||||
const result = await createProjectAsync(mockGraphqlClient, mockActor, projectDir);
|
||||
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester2',
|
||||
'jester2-app'
|
||||
);
|
||||
|
||||
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
accountId: 'jester2-account-id',
|
||||
projectName: 'jester2-app',
|
||||
});
|
||||
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
{
|
||||
extra: { eas: { projectId: mockProjectId } },
|
||||
},
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
|
||||
expect(result).toBe(mockProjectId);
|
||||
});
|
||||
|
||||
it('should create a project when there are multiple accounts', async () => {
|
||||
// Use jester fixture which has multiple accounts
|
||||
const mockActor = jester;
|
||||
|
||||
jest.mocked(getActorUsername).mockReturnValue('jester');
|
||||
jest.mocked(promptAsync).mockResolvedValue({
|
||||
account: { name: 'jester' },
|
||||
});
|
||||
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue(mockProjectId);
|
||||
|
||||
const result = await createProjectAsync(mockGraphqlClient, mockActor, projectDir);
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices: expect.any(Array),
|
||||
});
|
||||
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'jester-app'
|
||||
);
|
||||
|
||||
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
accountId: 'jester-account-id',
|
||||
projectName: 'jester-app',
|
||||
});
|
||||
|
||||
expect(result).toBe(mockProjectId);
|
||||
});
|
||||
|
||||
it('should throw when the project already exists', async () => {
|
||||
const mockActor = jester2 as Actor;
|
||||
|
||||
const existingProjectId = 'existing-project-id';
|
||||
jest
|
||||
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
|
||||
.mockResolvedValue(existingProjectId);
|
||||
|
||||
await expect(createProjectAsync(mockGraphqlClient, mockActor, projectDir)).rejects.toThrow(
|
||||
`Existing project found: @jester2/jester2-app (ID: ${existingProjectId}). Project ID configuration canceled. Re-run the command to select a different account/project.`
|
||||
);
|
||||
|
||||
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when the user does not have permission to create projects on account', async () => {
|
||||
// Create a custom actor with ViewOnly permissions for this test
|
||||
const mockActor = merge(jester2, {
|
||||
accounts: [{ users: [{ actor: { id: 'view-only-jester' }, role: Role.ViewOnly }] }],
|
||||
}) as Actor;
|
||||
|
||||
jest.mocked(getActorUsername).mockReturnValue('view-only-jester');
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
await expect(createProjectAsync(mockGraphqlClient, mockActor, projectDir)).rejects.toThrow(
|
||||
`You don't have permission to create a new project on the jester account and no matching project already exists on the account.`
|
||||
);
|
||||
|
||||
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAppConfigAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
const mockUpdateUrl = 'https://u.expo.dev/test-project-id';
|
||||
|
||||
const createMockApp = (overrides: Partial<AppFragment> = {}): AppFragment => ({
|
||||
id: 'test-project-id',
|
||||
name: 'Test App',
|
||||
fullName: '@testowner/test-app',
|
||||
slug: 'test-app',
|
||||
ownerAccount: {
|
||||
id: 'testowner-account-id',
|
||||
name: 'testowner',
|
||||
users: [],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getEASUpdateURL).mockReturnValue(mockUpdateUrl);
|
||||
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
expo: {} as ExpoConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate the app config', async () => {
|
||||
const mockApp = createMockApp();
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
expo: {
|
||||
name: 'value-to-override',
|
||||
slug: 'value-to-override',
|
||||
icon: 'value-to-keep',
|
||||
} as ExpoConfig,
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(getEASUpdateURL).toHaveBeenCalledWith('test-project-id', null);
|
||||
|
||||
const expectedConfig = {
|
||||
expo: {
|
||||
name: 'Test App',
|
||||
slug: 'test-app',
|
||||
scheme: 'TestApp',
|
||||
icon: 'value-to-keep',
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: 'test-project-id',
|
||||
},
|
||||
},
|
||||
owner: 'testowner',
|
||||
updates: {
|
||||
url: mockUpdateUrl,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: 'com.testowner.testapp',
|
||||
},
|
||||
android: {
|
||||
package: 'com.testowner.testapp',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/app.json`, expectedConfig, {
|
||||
spaces: 2,
|
||||
});
|
||||
|
||||
expectConsoleToContain('Generated app.json');
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the bundle identifier', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'test-app-with-dashes',
|
||||
ownerAccount: {
|
||||
id: 'test-owner-account-id',
|
||||
name: 'test-owner',
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner.testappwithdashes',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the owner account name', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'testapp',
|
||||
ownerAccount: {
|
||||
id: 'test-owner-123-account-id',
|
||||
name: 'test-owner-123',
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner123.testapp',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the slug', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'test@app#with$symbols',
|
||||
fullName: '@testowner/test@app#with$symbols',
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner.testappwithsymbols',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEasConfigAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
it('should generate the eas config', async () => {
|
||||
const expectedEasConfig = {
|
||||
cli: {
|
||||
version: '>= 5.0.0',
|
||||
appVersionSource: 'remote',
|
||||
},
|
||||
build: {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
'development-simulator': {
|
||||
extends: 'development',
|
||||
ios: { simulator: true },
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
channel: 'main',
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
production: {
|
||||
channel: 'production',
|
||||
autoIncrement: true,
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
|
||||
await generateEasConfigAsync(projectDir);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/eas.json`, expectedEasConfig, {
|
||||
spaces: 2,
|
||||
});
|
||||
|
||||
expectConsoleToContain('Generated eas.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePackageJsonAsync', () => {
|
||||
it('should update the package.json', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const projectDir = '/test/project-dir';
|
||||
await updatePackageJsonAsync(projectDir);
|
||||
|
||||
const expectedPackageJson = {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
preview: 'npx eas-cli@latest workflow:run publish-preview-update.yml',
|
||||
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
|
||||
deploy: 'npx eas-cli@latest workflow:run deploy-to-production.yml',
|
||||
},
|
||||
};
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/package.json`,
|
||||
expectedPackageJson,
|
||||
{ spaces: 2 }
|
||||
);
|
||||
|
||||
expectConsoleToContain('Updated package.json with scripts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProjectTemplatesAsync', () => {
|
||||
it('should copy the project templates', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await copyProjectTemplatesAsync(projectDir);
|
||||
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('templates/.eas/workflows'),
|
||||
expect.stringContaining('.eas/workflows'),
|
||||
{ errorOnExist: false, overwrite: true }
|
||||
);
|
||||
|
||||
expectConsoleToContain('Created EAS workflow files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeReadmeAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
it('should merge readme when target section is found', async () => {
|
||||
const existingReadme = `# My App
|
||||
|
||||
This is my app.
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
Some content here.`;
|
||||
|
||||
const readmeAdditions = `## Workflows
|
||||
|
||||
This project uses EAS Workflows.`;
|
||||
|
||||
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(readmeAdditions);
|
||||
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(existingReadme);
|
||||
|
||||
await mergeReadmeAsync(projectDir);
|
||||
|
||||
const expectedMergedReadme = `# My App
|
||||
|
||||
This is my app.
|
||||
|
||||
## Workflows
|
||||
|
||||
This project uses EAS Workflows.
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
Some content here.`;
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(`${projectDir}/README.md`, expectedMergedReadme);
|
||||
|
||||
expectConsoleToContain('Updated README.md with EAS configuration details');
|
||||
});
|
||||
|
||||
it('should append readme when target section is not found', async () => {
|
||||
const existingReadme = `# My App
|
||||
|
||||
This is my app.`;
|
||||
|
||||
const readmeAdditions = `## Workflows
|
||||
|
||||
This project uses EAS Workflows.`;
|
||||
|
||||
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(readmeAdditions);
|
||||
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(existingReadme);
|
||||
|
||||
await mergeReadmeAsync(projectDir);
|
||||
|
||||
const expectedMergedReadme = `# My App
|
||||
|
||||
This is my app.
|
||||
|
||||
## Workflows
|
||||
|
||||
This project uses EAS Workflows.
|
||||
`;
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(`${projectDir}/README.md`, expectedMergedReadme);
|
||||
|
||||
expectConsoleToContain('Updated README.md with EAS configuration details');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeGitRepositoryAsync', () => {
|
||||
it('should initialize git repository', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await initializeGitRepositoryAsync(projectDir);
|
||||
|
||||
expect(fs.remove).toHaveBeenCalledWith(`${projectDir}/.git`);
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['init'],
|
||||
cwd: projectDir,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['add', '.'],
|
||||
cwd: projectDir,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['commit', '-m', 'Initial commit'],
|
||||
cwd: projectDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatScriptCommand', () => {
|
||||
it('should format script commands for package managers', () => {
|
||||
expect(formatScriptCommand('start', 'npm')).toBe('npm run start');
|
||||
expect(formatScriptCommand('start', 'yarn')).toBe('yarn start');
|
||||
expect(formatScriptCommand('start', 'pnpm')).toBe('pnpm start');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import merge from 'ts-deepmerge';
|
||||
|
||||
import { getEASUpdateURL } from '../../api';
|
||||
import { getProjectDashboardUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppFragment, Role } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
|
||||
import {
|
||||
PackageManager,
|
||||
installDependenciesAsync,
|
||||
promptForPackageManagerAsync,
|
||||
} from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { Choice, promptAsync } from '../../prompts';
|
||||
import { Actor, getActorUsername } from '../../user/User';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
export async function promptForTargetDirectoryAsync(
|
||||
targetProjectDirFromArgs?: string
|
||||
): Promise<string> {
|
||||
Log.log(
|
||||
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
if (targetProjectDirFromArgs) {
|
||||
return targetProjectDirFromArgs;
|
||||
}
|
||||
|
||||
const result = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'targetProjectDir',
|
||||
message: 'Where would you like to create your new project directory?',
|
||||
initial: path.join(process.cwd(), 'new-expo-project'),
|
||||
});
|
||||
|
||||
return result.targetProjectDir;
|
||||
}
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
const githubRepositoryName = 'expo-template-default';
|
||||
|
||||
Log.log(`📂 Cloning the project to ${targetProjectDir}`);
|
||||
Log.newLine();
|
||||
|
||||
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
|
||||
Log.newLine();
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
targetProjectDir,
|
||||
cloneMethod,
|
||||
});
|
||||
|
||||
return finalTargetProjectDirectory;
|
||||
}
|
||||
|
||||
export async function installProjectDependenciesAsync(projectDir: string): Promise<PackageManager> {
|
||||
const packageManager = await promptForPackageManagerAsync();
|
||||
await installDependenciesAsync({
|
||||
projectDir,
|
||||
packageManager,
|
||||
});
|
||||
|
||||
const dependencies = ['expo-updates', '@expo/metro-runtime'];
|
||||
for (const dependency of dependencies) {
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', dependency],
|
||||
});
|
||||
}
|
||||
return packageManager;
|
||||
}
|
||||
|
||||
export function getAccountChoices(
|
||||
actor: Actor,
|
||||
namesWithSufficientPermissions: Set<string>
|
||||
): Choice[] {
|
||||
const sortedAccounts = actor.accounts.sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
|
||||
return sortedAccounts.map(account => {
|
||||
const isPersonalAccount = actor.__typename === 'User' && account.name === actor.username;
|
||||
const accountDisplayName = isPersonalAccount
|
||||
? `${account.name} (personal account)`
|
||||
: account.name;
|
||||
const disabled = !namesWithSufficientPermissions.has(account.name);
|
||||
|
||||
return {
|
||||
title: accountDisplayName,
|
||||
value: { name: account.name },
|
||||
...(disabled && {
|
||||
disabled: true,
|
||||
description: 'You do not have the required permissions to create projects on this account.',
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
projectDir: string
|
||||
): Promise<string> {
|
||||
const allAccounts = actor.accounts;
|
||||
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
|
||||
allAccounts
|
||||
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
|
||||
.map(it => it.name)
|
||||
);
|
||||
|
||||
let accountName = allAccounts[0].name;
|
||||
if (allAccounts.length > 1) {
|
||||
const choices = getAccountChoices(
|
||||
actor,
|
||||
accountNamesWhereUserHasSufficientPermissionsToCreateApp
|
||||
);
|
||||
|
||||
accountName = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices,
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
|
||||
const projectName = getActorUsername(actor) + '-app';
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
|
||||
if (existingProjectIdOnServer) {
|
||||
throw new Error(
|
||||
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Project ID configuration canceled. Re-run the command to select a different account/project.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
|
||||
throw new Error(
|
||||
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
|
||||
);
|
||||
}
|
||||
|
||||
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
|
||||
const projectLink = link(projectDashboardUrl, { text: projectFullName });
|
||||
|
||||
const account = nullthrows(allAccounts.find(a => a.name === accountName));
|
||||
|
||||
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
|
||||
let projectId: string;
|
||||
try {
|
||||
projectId = await AppMutation.createAppAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
projectName,
|
||||
});
|
||||
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
|
||||
} catch (err) {
|
||||
spinner.fail();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true });
|
||||
await createOrModifyExpoConfigAsync(
|
||||
projectDir,
|
||||
{
|
||||
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
|
||||
},
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
export function stripInvalidCharactersForBundleIdentifier(string: string): string {
|
||||
return string.replaceAll(/[^A-Za-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export async function generateConfigFilesAsync(
|
||||
projectDir: string,
|
||||
app: AppFragment
|
||||
): Promise<void> {
|
||||
await generateAppConfigAsync(projectDir, app);
|
||||
|
||||
await generateEasConfigAsync(projectDir);
|
||||
|
||||
await updatePackageJsonAsync(projectDir);
|
||||
|
||||
await copyProjectTemplatesAsync(projectDir);
|
||||
|
||||
await mergeReadmeAsync(projectDir);
|
||||
}
|
||||
|
||||
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
|
||||
// Android package name requires each component to start with a lowercase letter.
|
||||
const isUsernameValidSegment = /^[^a-z]/.test(app.ownerAccount.name);
|
||||
const userPrefix = isUsernameValidSegment ? 'user' : '';
|
||||
const isSlugValidSegment = /^[^a-z]/.test(app.slug);
|
||||
const slugPrefix = isSlugValidSegment ? 'app' : '';
|
||||
|
||||
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
|
||||
app.ownerAccount.name
|
||||
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.slug)}`;
|
||||
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
|
||||
|
||||
const { expo: baseExpoConfig } = await fs.readJson(path.join(projectDir, 'app.json'));
|
||||
const expoConfig: ExpoConfig = {
|
||||
name: app.name ?? app.slug,
|
||||
slug: app.slug,
|
||||
scheme: stripInvalidCharactersForBundleIdentifier(app.name ?? app.slug),
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: app.id,
|
||||
},
|
||||
},
|
||||
owner: app.ownerAccount.name,
|
||||
updates: {
|
||||
url: updateUrl,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier,
|
||||
},
|
||||
android: {
|
||||
package: bundleIdentifier,
|
||||
},
|
||||
};
|
||||
const mergedConfig = merge(baseExpoConfig, expoConfig);
|
||||
|
||||
const appJsonPath = path.join(projectDir, 'app.json');
|
||||
await fs.writeJson(appJsonPath, { expo: mergedConfig }, { spaces: 2 });
|
||||
Log.withTick(
|
||||
`Generated ${chalk.bold('app.json')}. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/'
|
||||
)}`
|
||||
);
|
||||
Log.log();
|
||||
}
|
||||
|
||||
export async function generateEasConfigAsync(projectDir: string): Promise<void> {
|
||||
const easBuildGitHubConfig = {
|
||||
android: {
|
||||
image: 'latest',
|
||||
},
|
||||
ios: {
|
||||
image: 'latest',
|
||||
},
|
||||
};
|
||||
|
||||
const easJson: EasJson = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
build: {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
'development-simulator': {
|
||||
extends: 'development',
|
||||
ios: {
|
||||
simulator: true,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
channel: 'main',
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
production: {
|
||||
channel: 'production',
|
||||
autoIncrement: true,
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
|
||||
Log.withTick(
|
||||
`Generated ${chalk.bold('eas.json')}. ${learnMore(
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
)}`
|
||||
);
|
||||
Log.log();
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {};
|
||||
}
|
||||
packageJson.scripts.preview = 'npx eas-cli@latest workflow:run publish-preview-update.yml';
|
||||
packageJson.scripts['development-builds'] =
|
||||
'npx eas-cli@latest workflow:run create-development-builds.yml';
|
||||
packageJson.scripts.deploy = 'npx eas-cli@latest workflow:run deploy-to-production.yml';
|
||||
|
||||
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
||||
Log.withTick('Updated package.json with scripts');
|
||||
Log.log();
|
||||
}
|
||||
|
||||
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
|
||||
const templatesSourceDir = path.join(__dirname, 'templates', '.eas', 'workflows');
|
||||
const easWorkflowsTargetDir = path.join(projectDir, '.eas', 'workflows');
|
||||
|
||||
await fs.copy(templatesSourceDir, easWorkflowsTargetDir, {
|
||||
overwrite: true,
|
||||
errorOnExist: false,
|
||||
});
|
||||
|
||||
Log.withTick('Created EAS workflow files');
|
||||
Log.log();
|
||||
}
|
||||
|
||||
export async function mergeReadmeAsync(projectDir: string): Promise<void> {
|
||||
const readmeTemplatePath = path.join(__dirname, 'templates', 'readme-additions.md');
|
||||
const projectReadmePath = path.join(projectDir, 'README.md');
|
||||
|
||||
const readmeAdditions = await fs.readFile(readmeTemplatePath, 'utf8');
|
||||
const existingReadme = await fs.readFile(projectReadmePath, 'utf8');
|
||||
|
||||
const targetSection = '## Get a fresh project';
|
||||
const sectionIndex = existingReadme.indexOf(targetSection);
|
||||
|
||||
let mergedReadme: string;
|
||||
if (sectionIndex !== -1) {
|
||||
// Insert before "## Get a fresh project" section
|
||||
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
|
||||
const afterSection = existingReadme.substring(sectionIndex);
|
||||
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
|
||||
} else {
|
||||
// Append to the end if section doesn't exist
|
||||
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim() + '\n';
|
||||
}
|
||||
|
||||
await fs.writeFile(projectReadmePath, mergedReadme);
|
||||
|
||||
Log.withTick('Updated README.md with EAS configuration details');
|
||||
Log.log();
|
||||
}
|
||||
|
||||
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
|
||||
await fs.remove(path.join(projectDir, '.git'));
|
||||
|
||||
const commands = [['init'], ['add', '.'], ['commit', '-m', 'Initial commit']];
|
||||
|
||||
for (const args of commands) {
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'git',
|
||||
args,
|
||||
});
|
||||
Log.log();
|
||||
}
|
||||
}
|
||||
|
||||
export const formatScriptCommand = (script: string, packageManager: PackageManager): string => {
|
||||
if (packageManager === 'npm') {
|
||||
return `npm run ${script}`;
|
||||
}
|
||||
return `${packageManager} ${script}`;
|
||||
};
|
||||
|
||||
export default class New extends EasCommand {
|
||||
static override aliases = ['new'];
|
||||
|
||||
static override description = "create a new project set up with Expo's services.";
|
||||
|
||||
static override flags = {};
|
||||
|
||||
static override hidden = true;
|
||||
|
||||
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args } = await this.parse(New);
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
} = await this.getContextAsync(New, { nonInteractive: false });
|
||||
|
||||
if (actor.__typename === 'Robot') {
|
||||
throw new Error(
|
||||
'This command is not available for robot users. Make sure you are not using a robot token and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
Log.warn(
|
||||
'This command is not yet implemented. It will create a new project, but it will not be fully configured.'
|
||||
);
|
||||
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
|
||||
Log.newLine();
|
||||
|
||||
const targetProjectDirectory = await promptForTargetDirectoryAsync(
|
||||
args.TARGET_PROJECT_DIRECTORY
|
||||
);
|
||||
const projectDirectory = await cloneTemplateAsync(targetProjectDirectory);
|
||||
|
||||
const packageManager = await installProjectDependenciesAsync(projectDirectory);
|
||||
|
||||
const projectId = await createProjectAsync(graphqlClient, actor, projectDirectory);
|
||||
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
await generateConfigFilesAsync(projectDirectory, app);
|
||||
|
||||
await initializeGitRepositoryAsync(projectDirectory);
|
||||
|
||||
Log.log('🎉 We finished creating your new project.');
|
||||
Log.log('Next steps:');
|
||||
Log.withInfo(`Run \`cd ${projectDirectory}\` to navigate to your project.`);
|
||||
Log.withInfo(
|
||||
`Run \`${formatScriptCommand(
|
||||
'preview',
|
||||
packageManager
|
||||
)}\` to create a preview build on EAS. ${learnMore(
|
||||
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
|
||||
)}`
|
||||
);
|
||||
Log.withInfo(
|
||||
`Run \`${formatScriptCommand(
|
||||
'start',
|
||||
packageManager
|
||||
)}\` to start developing locally. ${learnMore(
|
||||
'https://docs.expo.dev/get-started/start-developing/'
|
||||
)}`
|
||||
);
|
||||
Log.withInfo(`See the README.md for more information about your project.`);
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export default class Onboarding extends EasCommand {
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preffered git clone method`));
|
||||
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
|
||||
Log.log();
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
@@ -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: {
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
name: Create development builds
|
||||
|
||||
jobs:
|
||||
android_development_build:
|
||||
name: Build Android
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: development
|
||||
ios_device_development_build:
|
||||
name: Build iOS device
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development
|
||||
ios_simulator_development_build:
|
||||
name: Build iOS simulator
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development-simulator
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
name: Deploy to production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
fingerprint:
|
||||
name: Fingerprint
|
||||
type: fingerprint
|
||||
get_android_build:
|
||||
name: Check for existing android build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
|
||||
profile: production
|
||||
get_ios_build:
|
||||
name: Check for existing ios build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
|
||||
profile: production
|
||||
build_android:
|
||||
name: Build Android
|
||||
needs: [get_android_build]
|
||||
if: ${{ !needs.get_android_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
build_ios:
|
||||
name: Build iOS
|
||||
needs: [get_ios_build]
|
||||
if: ${{ !needs.get_ios_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
submit_android_build:
|
||||
name: Submit Android Build
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
submit_ios_build:
|
||||
name: Submit iOS Build
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
publish_android_update:
|
||||
name: Publish Android update
|
||||
needs: [get_android_build]
|
||||
if: ${{ needs.get_android_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: android
|
||||
publish_ios_update:
|
||||
name: Publish iOS update
|
||||
needs: [get_ios_build]
|
||||
if: ${{ needs.get_ios_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: ios
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
name: Publish preview update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
|
||||
jobs:
|
||||
publish_preview_update:
|
||||
name: Publish preview update
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
@@ -0,0 +1,19 @@
|
||||
## Workflows
|
||||
|
||||
This project is configured to use [EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) to automate some development and release processes. These commands are set up in [`package.json`](./package.json) and can be run using NPM scripts in your terminal.
|
||||
|
||||
### Previews
|
||||
|
||||
Run `npm run preview` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
|
||||
|
||||
### Development Builds
|
||||
|
||||
Run `npm run development-builds` to [create a development build](https://docs.expo.dev/eas/workflows/examples/create-development-builds/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/create-development-builds/#prerequisites) to ensure you have the correct emulator setup on your machine.
|
||||
|
||||
### Production Deployments
|
||||
|
||||
Run `npm run deploy` to [deploy to production](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/#prerequisites) to ensure you're set up to submit to the Apple and Google stores.
|
||||
|
||||
## Hosting
|
||||
|
||||
Expo offers hosting for websites and API functions via EAS Hosting. See the [Getting Started](https://docs.expo.dev/eas/hosting/get-started/) guide to learn more.
|
||||
@@ -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',
|
||||
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user