mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef14b5eb7 | ||
|
|
c777b25e92 | ||
|
|
15b690c857 | ||
|
|
4d04463714 | ||
|
|
1c31702653 | ||
|
|
b053e0926a | ||
|
|
6eeb15bdce | ||
|
|
279eb1595c | ||
|
|
256b02d3e9 | ||
|
|
9f4fef7626 | ||
|
|
8cc324e11e | ||
|
|
f33b4c131d | ||
|
|
e3eb3ad5ea | ||
|
|
1292465c57 | ||
|
|
00d2022680 | ||
|
|
5aaaf1b224 | ||
|
|
aaf14b8bab | ||
|
|
65413d1640 | ||
|
|
71ee9469aa | ||
|
|
7223a19fc8 | ||
|
|
3f4e826315 | ||
|
|
ea7cc6b1ea | ||
|
|
b5281d64d1 | ||
|
|
e51fe11aca | ||
|
|
16767f3feb | ||
|
|
c42b1a004c | ||
|
|
8a4bf6f068 | ||
|
|
03e1c18d34 | ||
|
|
2bf8822b1d | ||
|
|
460f98da76 | ||
|
|
3a7182bb82 | ||
|
|
f963dc711b | ||
|
|
a059852bd9 | ||
|
|
33e0b0901c | ||
|
|
8ba877ab81 | ||
|
|
9f6e387888 | ||
|
|
c10691c339 | ||
|
|
155b65c8fc | ||
|
|
cdbc95295c | ||
|
|
414ccb92c0 | ||
|
|
aabf519a9d | ||
|
|
88d9627286 | ||
|
|
59ce6d0b65 | ||
|
|
8e64a9c73e | ||
|
|
4476c87962 | ||
|
|
18ecd0844a | ||
|
|
cc3e7cdbe4 | ||
|
|
aa7fecb9fe | ||
|
|
c73a3e3a1e | ||
|
|
37ca2ebd55 | ||
|
|
dead11964e | ||
|
|
b66d31d7d7 | ||
|
|
ed8170047b | ||
|
|
0e4056ba82 | ||
|
|
6182ea5a1d | ||
|
|
c9e7b649ad | ||
|
|
7a6b796242 | ||
|
|
804eb9a326 | ||
|
|
8da14f0101 | ||
|
|
dc62d22816 | ||
|
|
258c625611 | ||
|
|
e84340774d | ||
|
|
fbc23f7a7d | ||
|
|
97255ec90d | ||
|
|
cfccc87b82 | ||
|
|
09b4f8367e | ||
|
|
45481f9758 | ||
|
|
1119008317 | ||
|
|
123a0c1b7e | ||
|
|
2f21fa62b1 | ||
|
|
8ad449c6b7 | ||
|
|
fe8ec50806 | ||
|
|
86cefeb259 | ||
|
|
034e0a1e7f | ||
|
|
a6b776915f | ||
|
|
4ae52d0e3d | ||
|
|
f62720ef5a | ||
|
|
af09aaedb3 | ||
|
|
184616e874 | ||
|
|
6bb09c4e99 | ||
|
|
81add86c61 | ||
|
|
4bb1c784d8 | ||
|
|
322082c28b | ||
|
|
5db1d21aa5 | ||
|
|
0db5585b20 | ||
|
|
10ec54345a | ||
|
|
27b4710264 | ||
|
|
4132e92da5 | ||
|
|
264340a717 | ||
|
|
1d09c2c97d |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: build-android
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Android
|
||||
type: build
|
||||
runs_on: linux-large
|
||||
params:
|
||||
platform: android
|
||||
profile: system-test
|
||||
custom_job:
|
||||
name: Custom Job
|
||||
steps:
|
||||
- name: Echo
|
||||
run: echo "This is a custom job step"
|
||||
@@ -0,0 +1,16 @@
|
||||
name: setup-gcloud
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Auth gcloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
|
||||
project_id: exponentjs
|
||||
|
||||
- name: Setup gcloud
|
||||
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
|
||||
with:
|
||||
version: 548.0.0
|
||||
project_id: exponentjs
|
||||
@@ -0,0 +1,205 @@
|
||||
name: build-and-deploy-worker
|
||||
# Assumes main branch always specifies an environment (staging/production).
|
||||
# If environment is empty: PR testing build (no upload, no deploy)
|
||||
# If environment is set: Build (unless cached on main), upload, and deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: '"staging", "production" or empty'
|
||||
type: string
|
||||
required: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: '"staging", "production" or empty'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Check if worker already exists
|
||||
id: check_worker_exists
|
||||
# Check if tarballs for this SHA already exist
|
||||
# If they do, we can skip building regardless of branch
|
||||
if: inputs.environment != ''
|
||||
run: |
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
|
||||
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
|
||||
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Start EAS Workflow to build worker tarballs
|
||||
id: eas_workflow
|
||||
# Build if: PR testing (no env), or deploying but tarballs don't exist
|
||||
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Only upload artifacts from EAS if GHA will upload them to GCS
|
||||
# This avoids unnecessary artifact uploads for PR builds
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
# Run workflow and capture output (includes run ID)
|
||||
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
|
||||
--ref ${{ github.sha }} \
|
||||
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
|
||||
--non-interactive \
|
||||
--no-wait \
|
||||
--json)
|
||||
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
|
||||
echo "Error: Could not determine workflow run ID"
|
||||
echo "Workflow output:"
|
||||
echo "$WORKFLOW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for EAS workflow and capture artifacts
|
||||
id: eas_artifacts
|
||||
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: Missing workflow run ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
while true; do
|
||||
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
|
||||
--non-interactive \
|
||||
--json)
|
||||
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
|
||||
|
||||
case "$STATUS" in
|
||||
SUCCESS)
|
||||
break
|
||||
;;
|
||||
FAILURE)
|
||||
echo "EAS workflow failed."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
CANCELED)
|
||||
echo "EAS workflow canceled."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
ACTION_REQUIRED)
|
||||
echo "EAS workflow requires action."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
NEW|IN_PROGRESS)
|
||||
sleep 7
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected workflow status: $STATUS"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Skip artifact extraction if we didn't request uploads
|
||||
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
|
||||
echo "Skipping artifact download - upload was disabled for this run"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract artifact download URLs from workflow output
|
||||
# Artifacts are in jobs[].artifacts[] for custom jobs
|
||||
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
|
||||
.jobs[]
|
||||
| .artifacts[]?
|
||||
| select(.name == "worker-ios-tarball")
|
||||
| .downloadUrl
|
||||
' | head -1)
|
||||
|
||||
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
|
||||
.jobs[]
|
||||
| .artifacts[]?
|
||||
| select(.name == "worker-android-tarball")
|
||||
| .downloadUrl
|
||||
' | head -1)
|
||||
|
||||
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
|
||||
echo "Error: Could not find iOS artifact download URL"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
|
||||
echo "Error: Could not find Android artifact download URL"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mask the download URLs
|
||||
echo "::add-mask::$DOWNLOAD_IOS_URL"
|
||||
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
|
||||
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cancel EAS workflow on GitHub cancellation
|
||||
if: cancelled()
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -n "$WORKFLOW_RUN_ID" ]; then
|
||||
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
fi
|
||||
|
||||
- name: Upload worker tarballs to GCP
|
||||
# Upload when deploying if we built something new
|
||||
# Don't upload PR builds (no environment) or when using cached tarballs
|
||||
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
run: |
|
||||
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
|
||||
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
|
||||
|
||||
- name: Promote worker to ${{ inputs.environment }}
|
||||
if: inputs.environment != ''
|
||||
run: |
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
|
||||
|
||||
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
|
||||
done
|
||||
@@ -0,0 +1,64 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
staging_only:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
description: "Version number to move the tag to"
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: "Run the workflow in dry run mode, without making any NPM changes"
|
||||
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
|
||||
jobs:
|
||||
move-tag:
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
node-version: 22
|
||||
- name: Resolve version (fails if the version does not exist)
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm pack --dry-run eas-cli@$INPUT_VERSION
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
|
||||
echo "Only moving the staging tag, skipping the production tag"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
@@ -9,6 +9,8 @@ jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
@@ -24,9 +26,11 @@ jobs:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
@@ -69,9 +73,11 @@ jobs:
|
||||
# Use macOS to get the correct platform specific dependencies
|
||||
runs-on: macos-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: brew install python-setuptools
|
||||
@@ -112,9 +118,11 @@ jobs:
|
||||
name: Build for Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
@@ -152,9 +160,11 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-mac, build-windows]
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
@@ -165,31 +175,31 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build-staging tag
|
||||
env:
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build tag
|
||||
env:
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-to-npm
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['18', '20', '22']
|
||||
node: ['20', '22', '24']
|
||||
include:
|
||||
- node: '20'
|
||||
coverage: true
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
name: system-tests
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
workflow_call: {}
|
||||
|
||||
jobs:
|
||||
system-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.STAGING_EXPO_DEV_EXPO_SERVICES_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
EXPO_STAGING: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
|
||||
|
||||
- name: Create test app
|
||||
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
|
||||
|
||||
- name: Link to EAS project
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
|
||||
- name: Add bundle identifiers to app.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
jq '.expo.ios.bundleIdentifier = "dev.expo.workersystemtests" | .expo.android.package = "dev.expo.workersystemtests"' app.json > app.json.tmp
|
||||
mv app.json.tmp app.json
|
||||
|
||||
- name: Configure EAS Build
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest build:configure --platform all
|
||||
|
||||
- name: Add system-test profile to eas.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
jq '.build["system-test"] = { "ios": { "simulator": true }, "android": { "withoutCredentials": true } }' eas.json > eas.json.tmp
|
||||
mv eas.json.tmp eas.json
|
||||
|
||||
- name: Setup EAS workflow
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
mkdir -p .eas/workflows
|
||||
cp ${{ github.workspace }}/.github/fixtures/eas-workflow-build-android.yml .eas/workflows/build-android.yml
|
||||
|
||||
- name: Run iOS build and Android workflow concurrently
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
|
||||
if [ -z "$BUILD_ID" ]; then
|
||||
echo "Error: could not determine build ID"
|
||||
echo "$BUILD_JSON" | jq .
|
||||
exit 1
|
||||
fi
|
||||
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved BUILD_ID=$BUILD_ID"
|
||||
|
||||
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: could not determine workflow run ID"
|
||||
echo "$WORKFLOW_JSON" | jq .
|
||||
exit 1
|
||||
fi
|
||||
echo "WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID"
|
||||
|
||||
is_terminal_build_status() {
|
||||
case "$1" in
|
||||
FINISHED|ERRORED|CANCELED) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_terminal_workflow_status() {
|
||||
case "$1" in
|
||||
SUCCESS|FAILURE|CANCELED|ACTION_REQUIRED) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
BUILD_STATUS=""
|
||||
WORKFLOW_STATUS=""
|
||||
TIMEOUT_SECONDS=1800
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
while true; do
|
||||
ELAPSED=$(( $(date +%s) - START_TIME ))
|
||||
if [ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ]; then
|
||||
echo "Timeout: polling exceeded 30 minutes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
|
||||
echo "iOS build status: $BUILD_STATUS"
|
||||
echo "Android workflow status: $WORKFLOW_STATUS"
|
||||
|
||||
if is_terminal_build_status "$BUILD_STATUS" && is_terminal_workflow_status "$WORKFLOW_STATUS"; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 7
|
||||
done
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
if [ "$BUILD_STATUS" != "FINISHED" ]; then
|
||||
echo "Build did not finish successfully: $BUILD_STATUS"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
if [ "$WORKFLOW_STATUS" != "SUCCESS" ]; then
|
||||
echo "Workflow did not finish successfully: $WORKFLOW_STATUS"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
|
||||
- name: Cancel EAS runs on workflow cancellation
|
||||
if: always() && (cancelled() || failure())
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${BUILD_ID:-}" ]; then
|
||||
echo "Canceling EAS build: $BUILD_ID"
|
||||
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
|
||||
echo "$BUILD_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "build:cancel produced no output"
|
||||
fi
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$BUILD_STATUS" ]; then
|
||||
echo "Post-cancel build status: $BUILD_STATUS"
|
||||
else
|
||||
echo "Post-cancel build status unavailable"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
|
||||
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
|
||||
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
|
||||
echo "$WORKFLOW_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "workflow:cancel produced no output"
|
||||
fi
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$WORKFLOW_STATUS" ]; then
|
||||
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
|
||||
else
|
||||
echo "Post-cancel workflow status unavailable"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,75 @@
|
||||
name: worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/worker/**"
|
||||
- .github/workflows/worker.yml
|
||||
- .github/workflows/build-and-deploy-worker.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/worker/**"
|
||||
- .github/workflows/worker.yml
|
||||
- .github/workflows/build-and-deploy-worker.yml
|
||||
|
||||
concurrency:
|
||||
group: worker-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
worker-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
|
||||
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: packages/worker
|
||||
run: yarn test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: packages/worker
|
||||
run: yarn test:integration
|
||||
|
||||
worker-build:
|
||||
needs:
|
||||
- worker-checks
|
||||
uses: ./.github/workflows/build-and-deploy-worker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'staging' || '' }}
|
||||
|
||||
system-tests-staging:
|
||||
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
|
||||
needs:
|
||||
- worker-build
|
||||
- worker-checks
|
||||
uses: ./.github/workflows/worker-system-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
deploy-worker-production:
|
||||
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
|
||||
needs:
|
||||
- system-tests-staging
|
||||
uses: ./.github/workflows/build-and-deploy-worker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: production
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- deploy-worker-production
|
||||
if: github.ref == 'refs/heads/main' && always() && !cancelled()
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Notify Slack
|
||||
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
with:
|
||||
channel: turtle-bots
|
||||
slack_webhook: ${{ secrets.SLACK_EAS_WORKFLOWS_WEBHOOK }}
|
||||
+109
-1
@@ -8,12 +8,120 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improvements to workflow:create. ([#3194](https://github.com/expo/eas-cli/pull/3194) by [@douglowder](https://github.com/douglowder))
|
||||
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `--source-maps` flag compatibility with older SDKs that only support it as a boolean flag. ([#3341](https://github.com/expo/eas-cli/pull/3341) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Use `--dump-sourcemaps` as fallback when `--source-maps` is not provided to `eas update`, for backwards compatibility. ([8cc324e1](https://github.com/expo/eas-cli/commit/8cc324e1) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Fix `metadata:pull` failing for apps with only a live version by falling back to live app version and info. ([#3299](https://github.com/expo/eas-cli/pull/3299) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- eas init should fix and validate project name and slug. ([#3277](https://github.com/expo/eas-cli/pull/3277) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Test with node 24. ([#3270](https://github.com/expo/eas-cli/pull/3270) by [@wschurman](https://github.com/wschurman))
|
||||
- Delete channel in background. ([#3278](https://github.com/expo/eas-cli/pull/3278) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add warning to channel:delete. ([#3335](https://github.com/expo/eas-cli/pull/3335) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `eas:new` sorts organization accounts first in account selection. ([#3256](https://github.com/expo/eas-cli/pull/3256) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas:build` bug where overages are checked before config is ready. ([#3260](https://github.com/expo/eas-cli/pull/3260) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Delete branch in background. ([#3258](https://github.com/expo/eas-cli/pull/3258) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.27.0](https://github.com/expo/eas-cli/releases/tag/v16.27.0) - 2025-11-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Kick off update patch generation if enabled - this is feature is in preview, don't use it for prod yet please. ([#3250](https://github.com/expo/eas-cli/pull/3250) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix workflow:status when run ID passed in. ([#3253](https://github.com/expo/eas-cli/pull/3253) by [@douglowder](https://github.com/douglowder))
|
||||
- Fix updating file env vars with `env:update --non-interactive`. ([#3249](https://github.com/expo/eas-cli/pull/3249) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fix yarn watch. ([#3251](https://github.com/expo/eas-cli/pull/3251) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Remove the **node_modules** installation requirement for `workflow:run` command. ([#3254](https://github.com/expo/eas-cli/pull/3254) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [16.26.0](https://github.com/expo/eas-cli/releases/tag/v16.26.0) - 2025-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- New command `workflow:status`. ([#3242](https://github.com/expo/eas-cli/pull/3242) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- `eas:new` cleanup (remove `hidden` tag, fix README error, output cleanup). ([#3243](https://github.com/expo/eas-cli/pull/3243) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.25.1](https://github.com/expo/eas-cli/releases/tag/v16.25.1) - 2025-10-28
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove preview label from EAS Hosting commands ([#3241](https://github.com/expo/eas-cli/pull/3241) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.25.0](https://github.com/expo/eas-cli/releases/tag/v16.25.0) - 2025-10-27
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `eas:new` command changes (project name prompt, output cleanup, additional project files, additional prompts). ([#3235](https://github.com/expo/eas-cli/pull/3235) by [@mackenco](https://github.com/mackenco))
|
||||
- `eas:build` command displays a warning when account is near it's build limit. ([#3236](https://github.com/expo/eas-cli/pull/3236) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- workflow:run can use --wait and --json together. ([#3239](https://github.com/expo/eas-cli/pull/3239) by [@douglowder](https://github.com/douglowder))
|
||||
- Add missing env for local builds with latest plugin. ([#3237](https://github.com/expo/eas-cli/pull/3237) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Document the default values for `track` and `releaseStatus`. ([#3229](https://github.com/expo/eas-cli/pull/3229) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.24.1](https://github.com/expo/eas-cli/releases/tag/v16.24.1) - 2025-10-19
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Revert change in ora version. ([#3231](https://github.com/expo/eas-cli/pull/3231) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.24.0](https://github.com/expo/eas-cli/releases/tag/v16.24.0) - 2025-10-19
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- workflow:run --wait shows periodic job and step status updates. ([#3227](https://github.com/expo/eas-cli/pull/3227) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- workflow:logs should show logs from build and repack jobs. ([#3226](https://github.com/expo/eas-cli/pull/3226) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.23.1](https://github.com/expo/eas-cli/releases/tag/v16.23.1) - 2025-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Multiple bugfixes and small features for the `eas:new` command: handle project name and directory generation more smoothly; support `bun` as a package manager; add `--package-manager` flag with a default to `npm`; ensure workflow files are in the correct location after build; update the generated project's README with the provided package manager; ensure `app.json`'s `scheme` is valid; silence lots of noisy output. ([#3205](https://github.com/expo/eas-cli/pull/3205) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.23.0](https://github.com/expo/eas-cli/releases/tag/v16.23.0) - 2025-10-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support custom environment variable environments ([#3220](https://github.com/expo/eas-cli/pull/3220) by [@kadikraman](https://github.com/kadikraman))
|
||||
- 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))
|
||||
|
||||
## [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
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
This is a **Lerna-based monorepo** with four packages:
|
||||
|
||||
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
|
||||
|
||||
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
|
||||
- Built on oclif (Open CLI Framework)
|
||||
- Entry point: `packages/eas-cli/bin/run`
|
||||
|
||||
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
|
||||
|
||||
- Validates `eas.json` configuration files using Joi schemas
|
||||
- Manages build, submit, and deploy profiles
|
||||
- Supports JSON5 via golden-fleece
|
||||
|
||||
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
|
||||
|
||||
- Optimizes build caching for Expo CLI
|
||||
|
||||
4. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
|
||||
- Runs on EAS build VMs/pods
|
||||
- WebSocket server for communication with Turtle Launcher
|
||||
- Wraps `@expo/build-tools` to execute actual React Native builds
|
||||
|
||||
> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
yarn install # Install all dependencies
|
||||
yarn build # Build all packages in the monorepo
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
yarn start # Watch mode with parallel builds across all packages
|
||||
yarn watch # Alias for start
|
||||
|
||||
# Development with relaxed TypeScript rules (allows unused variables)
|
||||
yarn start-allow-unused
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
yarn test # Run all tests across packages (Jest multi-project)
|
||||
yarn test --watch # Run tests in watch mode
|
||||
|
||||
# Run tests for specific package
|
||||
cd packages/eas-cli && yarn test
|
||||
cd packages/eas-json && yarn test
|
||||
cd packages/worker && yarn test
|
||||
```
|
||||
|
||||
### Type Checking & Linting
|
||||
|
||||
```bash
|
||||
yarn typecheck # TypeScript type check all packages
|
||||
yarn lint # ESLint across all packages
|
||||
```
|
||||
|
||||
### Running EAS CLI Locally
|
||||
|
||||
```bash
|
||||
# From repository root:
|
||||
yarn eas <command> # Run via root package script
|
||||
|
||||
# Or use the binary directly:
|
||||
packages/eas-cli/bin/run <command>
|
||||
|
||||
# Recommended: Create an alias for development
|
||||
alias easd="$(pwd)/packages/eas-cli/bin/run"
|
||||
easd build --help
|
||||
```
|
||||
|
||||
## Build System
|
||||
|
||||
Each package has independent TypeScript compilation:
|
||||
|
||||
- `eas-cli`: `src/` → `build/`
|
||||
- `eas-json`: `src/` → `build/`
|
||||
- `eas-build-cache-provider`: `src/` → `build/`
|
||||
- `worker`: `src/` → `dist/`
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node18)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
- **Framework**: Jest with multi-project configuration
|
||||
- **Root config**: `jest.config.ts` (combines eas-cli, eas-json, worker)
|
||||
- **Test locations**: `__tests__/` directories alongside source files
|
||||
- **Mocking**:
|
||||
- File system: `memfs`
|
||||
- HTTP: `nock`
|
||||
- Utilities: `ts-mockito`, `mockdate`
|
||||
|
||||
Run a single test file:
|
||||
|
||||
```bash
|
||||
yarn test <path-to-test-file>
|
||||
# Example:
|
||||
yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# API Configuration
|
||||
export EXPO_STAGING=1 # Use staging API (https://staging-api.expo.dev)
|
||||
export EXPO_LOCAL=1 # Use local API (http://127.0.0.1:3000)
|
||||
|
||||
# Development
|
||||
export EAS_NO_VCS=1 # Disable version control checks
|
||||
export EXPO_DEBUG=1 # Enable debug logging
|
||||
```
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
### Validation
|
||||
|
||||
All changes should be validated with TypeScript and the linter before committing:
|
||||
|
||||
```bash
|
||||
yarn typecheck # Validate TypeScript types
|
||||
yarn lint # Run ESLint
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Use the `Log` module from `@expo/logger`:
|
||||
|
||||
```typescript
|
||||
import { Log } from '@expo/logger';
|
||||
|
||||
Log.log('Info message');
|
||||
Log.warn('Warning message');
|
||||
Log.error('Error message');
|
||||
```
|
||||
|
||||
### Pull Request conventions
|
||||
|
||||
When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.github/PULL_REQUEST_TEMPLATE).
|
||||
|
||||
## Release Process
|
||||
|
||||
1. Update `CHANGELOG.md` in the appropriate package
|
||||
2. Version bump is automated based on changelog:
|
||||
- Breaking changes → MAJOR
|
||||
- New features → MINOR
|
||||
- Otherwise → PATCH
|
||||
3. GitHub Actions workflow handles release automation
|
||||
4. Notifications sent to Slack #eas-cli channel
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Node Version**: Requires Node.js >= 18.0.0 (managed via Volta)
|
||||
- **Package Manager**: Uses Yarn 1.22.21
|
||||
- **Compilation Target**: CommonJS with Node resolution
|
||||
+3
-1
@@ -4,9 +4,11 @@ import type { Config } from 'jest';
|
||||
import easCliConfig from './packages/eas-cli/jest.config';
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import easJsonConfig from './packages/eas-json/jest.config';
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import workerConfig from './packages/worker/jest.config.unit';
|
||||
|
||||
const config: Config = {
|
||||
projects: [easCliConfig, easJsonConfig],
|
||||
projects: [easCliConfig, easJsonConfig, workerConfig],
|
||||
testPathIgnorePatterns: ['.*'],
|
||||
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "16.22.0",
|
||||
"version": "16.31.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+4
-4
@@ -21,21 +21,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.11.0",
|
||||
"@types/node": "20.14.8",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-universe": "13.0.0",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
"lerna": "8.2.3",
|
||||
"lerna": "9.0.3",
|
||||
"oclif": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"node": "20.19.4",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLAUDE.md - eas-build-cache-provider
|
||||
|
||||
This file provides guidance for working with the **eas-build-cache-provider** package.
|
||||
|
||||
## Overview
|
||||
|
||||
A build cache provider plugin for Expo CLI. Published as `eas-build-cache-provider` on npm.
|
||||
|
||||
- Provides remote caching for Expo builds
|
||||
- Optimizes build times by caching intermediate build artifacts
|
||||
- Integrates with EAS infrastructure
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn build # TypeScript compilation (src → build)
|
||||
yarn watch # Watch mode
|
||||
yarn typecheck # Type check
|
||||
yarn test # Run tests
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
Install as a dev dependency in your Expo project:
|
||||
|
||||
```bash
|
||||
npm install --save-dev eas-build-cache-provider
|
||||
# or
|
||||
yarn add --dev eas-build-cache-provider
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Update your **app.json** to enable the EAS build cache provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"experiments": {
|
||||
"buildCacheProvider": "eas"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The build cache provider:
|
||||
1. Hooks into Expo CLI's build process
|
||||
2. Uploads build cache artifacts to EAS infrastructure
|
||||
3. Downloads cached artifacts on subsequent builds
|
||||
4. Improves build times by reusing cached dependencies and build outputs
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Experimental**: This is an experimental feature
|
||||
- **EAS Integration**: Requires EAS account and project setup
|
||||
- **Cache Scope**: Cache is scoped per project and platform
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"version": "16.4.2",
|
||||
"version": "16.30.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@types/getenv": "^1.0.0",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -33,7 +33,11 @@
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": "expo/eas-cli",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-build-cache-provider"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
@@ -50,9 +54,5 @@
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
# CLAUDE.md - eas-cli
|
||||
|
||||
This file provides guidance for working with the **eas-cli** package.
|
||||
|
||||
## Overview
|
||||
|
||||
The main CLI tool for Expo Application Services (EAS). Published as `eas-cli` on npm.
|
||||
|
||||
- **95+ commands** organized by domain
|
||||
- Built on **oclif** (Open CLI Framework)
|
||||
- Entry point: `bin/run`
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Running Locally
|
||||
```bash
|
||||
# From repository root:
|
||||
yarn eas <command>
|
||||
|
||||
# Or use the binary directly:
|
||||
packages/eas-cli/bin/run <command>
|
||||
|
||||
# Recommended alias for development:
|
||||
alias easd="$(pwd)/packages/eas-cli/bin/run"
|
||||
easd build --help
|
||||
```
|
||||
|
||||
### Build & Development
|
||||
```bash
|
||||
yarn build # TypeScript compilation (src → build)
|
||||
yarn watch # Watch mode
|
||||
yarn watch-allow-unused # Watch mode with relaxed TS rules
|
||||
yarn typecheck # Type check without building
|
||||
yarn typecheck-for-build # Type check for production build
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
yarn test # Run all tests
|
||||
yarn test <path-to-file> # Run specific test file
|
||||
```
|
||||
|
||||
### GraphQL Code Generation
|
||||
```bash
|
||||
yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
|
||||
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
|
||||
```
|
||||
|
||||
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
|
||||
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
|
||||
- Types for all queries, mutations, and fragments
|
||||
|
||||
### oclif Commands
|
||||
```bash
|
||||
yarn oclif readme # Auto-generate README.md from command metadata
|
||||
```
|
||||
|
||||
## Command Architecture
|
||||
|
||||
### Base Command Class
|
||||
|
||||
All commands extend `EasCommand` (in `src/commandUtils/EasCommand.ts`).
|
||||
|
||||
### Context System
|
||||
|
||||
Commands declare required context via `ContextOptions`. This is a powerful system that provides commands with pre-initialized resources based on their needs.
|
||||
|
||||
Available context types:
|
||||
|
||||
- **LoggedIn**: Requires authentication
|
||||
- Provides: `actor` (user info), `graphqlClient` (authenticated GraphQL client)
|
||||
- **MaybeLoggedIn**: Optional authentication
|
||||
- Provides: `actor` (may be undefined), `graphqlClient`
|
||||
- **ProjectConfig**: Requires project with app.json/app.config
|
||||
- Provides: `projectId`, `exp` (Expo config), project directory
|
||||
- **ProjectDir**: Project directory context
|
||||
- Provides: `projectDir`, `projectRoot`
|
||||
- **VcsClient**: Version control system integration
|
||||
- Provides: `vcsClient` (Git operations)
|
||||
- **ServerSideEnvironmentVariables**: Environment variable management
|
||||
- Provides: methods to interact with server-side env vars
|
||||
|
||||
Example command structure:
|
||||
```typescript
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
|
||||
export default class Build extends EasCommand {
|
||||
static override description = 'start a build';
|
||||
|
||||
static override ContextOptions = {
|
||||
...EasCommand.ContextOptions.LoggedIn, // Requires auth
|
||||
...EasCommand.ContextOptions.ProjectConfig, // Requires project
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
// Context is automatically initialized
|
||||
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
// Your command logic here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command Discovery
|
||||
|
||||
- **Location**: Commands are auto-discovered from `build/commands/` by oclif
|
||||
- **Manifest**: `oclif.manifest.json` is generated during `prepack`
|
||||
- **Topics**: Organized into topics (account, build, channel, update, etc.)
|
||||
|
||||
## API Architecture
|
||||
|
||||
### GraphQL API (Primary)
|
||||
|
||||
**Location**: `src/graphql/`
|
||||
|
||||
**Client**: `ExpoGraphqlClient` (urql-based wrapper)
|
||||
- Automatic retry logic with exponential backoff
|
||||
- Cache management (cache-first vs network-only)
|
||||
- Transient error handling
|
||||
- Authentication via Bearer token or session secret
|
||||
|
||||
**Structure**:
|
||||
```
|
||||
src/graphql/
|
||||
├── generated.ts # Generated TypeScript types
|
||||
├── queries/ # 24+ query modules
|
||||
├── mutations/ # 17+ mutation modules
|
||||
└── types/ # GraphQL fragments
|
||||
```
|
||||
|
||||
**Making GraphQL Requests**:
|
||||
```typescript
|
||||
// In a command with LoggedIn context:
|
||||
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
const result = await graphqlClient
|
||||
.query(MyQuery, { variables: { id: projectId } })
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
```
|
||||
|
||||
### REST API v2 (Legacy)
|
||||
|
||||
**Client**: `ApiV2Client` in `src/api.ts`
|
||||
|
||||
**Base URLs**:
|
||||
- Production: `https://api.expo.dev`
|
||||
- Staging: `https://staging-api.expo.dev` (set `EXPO_STAGING=1`)
|
||||
- Local: `http://127.0.0.1:3000` (set `EXPO_LOCAL=1`)
|
||||
|
||||
**Methods**: GET, POST, PUT, DELETE
|
||||
|
||||
**Error Handling**: `ApiV2Error` wrapper
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── commands/ # CLI command implementations (organized by domain)
|
||||
│ ├── account/ # Login, logout, account management
|
||||
│ ├── build/ # Build orchestration commands
|
||||
│ ├── branch/ # Update branch management
|
||||
│ ├── channel/ # Update channel management
|
||||
│ ├── credentials/ # iOS/Android credential management
|
||||
│ ├── deploy/ # Deployment commands
|
||||
│ ├── device/ # Apple device management
|
||||
│ ├── env/ # Environment variable management
|
||||
│ ├── fingerprint/ # Fingerprint comparison/generation
|
||||
│ ├── metadata/ # App store metadata management
|
||||
│ ├── project/ # Project initialization and info
|
||||
│ ├── secret/ # Secret management (legacy)
|
||||
│ ├── submit/ # App store submission
|
||||
│ ├── update/ # EAS Update management
|
||||
│ ├── webhook/ # Webhook management
|
||||
│ └── workflow/ # Workflow management
|
||||
├── commandUtils/ # Base command classes and shared utilities
|
||||
├── build/ # Build orchestration logic
|
||||
├── credentials/ # Credential management system
|
||||
├── project/ # Project configuration and publishing
|
||||
├── update/ # EAS Update functionality
|
||||
├── submit/ # App store submission logic
|
||||
├── user/ # Authentication and user management
|
||||
├── graphql/ # GraphQL queries, mutations, and types
|
||||
├── analytics/ # Rudder Analytics integration
|
||||
├── vcs/ # Version control abstraction (Git)
|
||||
└── utils/ # Shared utilities
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Working with eas.json
|
||||
```typescript
|
||||
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
|
||||
// Read eas.json
|
||||
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJson = await EasJsonUtils.readAsync(projectDir);
|
||||
|
||||
// Access build profiles
|
||||
const buildProfile = easJson.build?.[profileName];
|
||||
```
|
||||
|
||||
### Project Context
|
||||
```typescript
|
||||
// In a command with ProjectConfig context:
|
||||
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
// projectId: EAS project ID
|
||||
// exp: Parsed Expo config (app.json/app.config.js)
|
||||
```
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **SessionManager** handles login/logout (`src/user/SessionManager.ts`)
|
||||
2. Authentication methods:
|
||||
- Interactive login
|
||||
- SSO via browser launcher
|
||||
- Session secrets
|
||||
- Access tokens
|
||||
3. Credentials stored via `UserSettings`
|
||||
|
||||
### Feature Flags
|
||||
|
||||
- Server-side feature gating via `FeatureGating` class
|
||||
- Environment variable overrides via `FeatureGateEnvOverrides`
|
||||
- Retrieved from Actor during authentication
|
||||
|
||||
### Error Handling
|
||||
|
||||
Extend `CommandError` or use specific error classes in `src/commandUtils/errors.ts`:
|
||||
```typescript
|
||||
import { CommandError } from '../commandUtils/errors';
|
||||
|
||||
throw new CommandError('BUILD_FAILED', 'The build failed due to...');
|
||||
```
|
||||
|
||||
### Analytics
|
||||
|
||||
- **Provider**: Rudder Analytics (`@expo/rudder-sdk-node`)
|
||||
- Events tracked for builds, submissions, commands
|
||||
- User can opt-out via settings
|
||||
|
||||
## Testing
|
||||
|
||||
- **Location**: `__tests__/` directories alongside source files
|
||||
- **Mocking**:
|
||||
- File system: `memfs`
|
||||
- HTTP: `nock`
|
||||
- Utilities: `ts-mockito`, `mockdate`
|
||||
|
||||
Example test:
|
||||
```typescript
|
||||
import { vol } from 'memfs';
|
||||
import nock from 'nock';
|
||||
|
||||
describe('MyCommand', () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should do something', async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **oclif**: Commands auto-discovered from `build/commands/`
|
||||
- **README**: Auto-generated from oclif - update via `yarn oclif readme`
|
||||
- **Manifest**: Generated during `prepack` step
|
||||
- **Templates**: `src/commandUtils/new/templates` are copied to `build/` during build
|
||||
- **Node Version**: >= 18.0.0
|
||||
+293
-214
File diff suppressed because it is too large.
Load diff
Generated
+4206
-342
File diff suppressed because it is too large.
Load diff
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "16.22.0",
|
||||
"version": "16.31.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.12",
|
||||
"@expo/apple-utils": "2.1.13",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "1.0.231",
|
||||
"@expo/eas-json": "16.20.1",
|
||||
"@expo/eas-build-job": "1.0.243",
|
||||
"@expo/eas-json": "16.30.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "1.0.221",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.7.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/pkcs12": "0.1.3",
|
||||
"@expo/plist": "0.2.0",
|
||||
"@expo/plugin-help": "5.1.23",
|
||||
@@ -58,6 +58,7 @@
|
||||
"https-proxy-agent": "5.0.1",
|
||||
"ignore": "5.3.0",
|
||||
"indent-string": "4.0.0",
|
||||
"invariant": "^2.2.2",
|
||||
"jks-js": "1.1.0",
|
||||
"joi": "17.11.0",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
@@ -107,6 +108,7 @@
|
||||
"@types/express": "4.17.15",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/jsonwebtoken": "8.5.1",
|
||||
"@types/mime": "3.0.1",
|
||||
"@types/node-fetch": "2.6.12",
|
||||
@@ -129,7 +131,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -220,25 +222,27 @@
|
||||
"-v"
|
||||
]
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-cli"
|
||||
},
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"pretarball-ci": "./scripts/pretarball-ci.sh",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"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": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
}
|
||||
}
|
||||
@@ -192,9 +192,16 @@ export function getMockWorkflowRunsFragment(
|
||||
failures?: number;
|
||||
pending?: number;
|
||||
withJobs?: number;
|
||||
withBuildJobs?: number;
|
||||
}
|
||||
): WorkflowRunFragment[] {
|
||||
const { successes = 0, failures = 0, pending = 0, withJobs = 0 } = params ?? {};
|
||||
const {
|
||||
successes = 0,
|
||||
failures = 0,
|
||||
pending = 0,
|
||||
withJobs = 0,
|
||||
withBuildJobs = 0,
|
||||
} = params ?? {};
|
||||
const runs: any[] = [];
|
||||
for (let i = 0; i < successes; i++) {
|
||||
runs.push({
|
||||
@@ -247,10 +254,13 @@ export function getMockWorkflowRunsFragment(
|
||||
for (let i = 0; i < withJobs; i++) {
|
||||
runs.push(getMockWorkflowRunWithJobsFragment());
|
||||
}
|
||||
for (let i = 0; i < withBuildJobs; i++) {
|
||||
runs.push(getMockWorkflowRunWithBuildJobsFragment());
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
export function getMockWorkflowJobFragment(
|
||||
export function getMockWorkflowCustomJobFragment(
|
||||
runId?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
|
||||
return {
|
||||
@@ -258,7 +268,7 @@ export function getMockWorkflowJobFragment(
|
||||
key: 'job1',
|
||||
name: 'job1',
|
||||
status: WorkflowJobStatus.Success,
|
||||
type: WorkflowJobType.Build,
|
||||
type: WorkflowJobType.Custom,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
outputs: {},
|
||||
@@ -275,6 +285,18 @@ export function getMockWorkflowJobFragment(
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowBuildJobFragment(
|
||||
runId?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
|
||||
return {
|
||||
...getMockWorkflowCustomJobFragment(runId),
|
||||
type: WorkflowJobType.Build,
|
||||
outputs: {
|
||||
build_id: 'build1',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunWithJobsFragment(
|
||||
runID?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
|
||||
@@ -303,6 +325,38 @@ export function getMockWorkflowRunWithJobsFragment(
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [getMockWorkflowJobFragment(id)],
|
||||
jobs: [getMockWorkflowCustomJobFragment(id)],
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunWithBuildJobsFragment(
|
||||
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: [getMockWorkflowBuildJobFragment(id)],
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowJobFragment,
|
||||
getMockWorkflowCustomJobFragment,
|
||||
getMockWorkflowRunWithJobsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
|
||||
import {
|
||||
fetchRawLogsForBuildJobAsync,
|
||||
fetchRawLogsForCustomJobAsync,
|
||||
} from '../../commandUtils/workflow/fetchLogs';
|
||||
import WorkflowLogView from '../../commands/workflow/logs';
|
||||
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
@@ -20,6 +25,7 @@ jest.mock('../../build/ios/version');
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowJobQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowRunQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
@@ -35,7 +41,7 @@ jest.mock('../../commandUtils/workflow/fetchLogs');
|
||||
|
||||
describe(WorkflowLogView, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowJobFragment());
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowCustomJobFragment());
|
||||
jest
|
||||
.mocked(WorkflowRunQuery.withJobsByIdAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
|
||||
@@ -98,7 +104,7 @@ describe(WorkflowLogView, () => {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
|
||||
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
@@ -130,7 +136,64 @@ describe(WorkflowLogView, () => {
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.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 build 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({ withBuildJobs: 1 }));
|
||||
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
|
||||
id: 'build1',
|
||||
status: BuildStatus.Finished,
|
||||
priority: BuildPriority.Normal,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
isForIosSimulator: false,
|
||||
project: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
slug: 'app',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
__typename: 'Account',
|
||||
},
|
||||
},
|
||||
platform: AppPlatform.Android,
|
||||
logFiles: ['https://example.com/log1'],
|
||||
});
|
||||
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(fetchRawLogsForBuildJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
ScheduleBranchDeletionMutation,
|
||||
ScheduleBranchDeletionMutationVariables,
|
||||
} from '../graphql/generated';
|
||||
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
|
||||
|
||||
export async function scheduleBranchDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
branchId,
|
||||
}: {
|
||||
branchId: string;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleBranchDeletionMutation, ScheduleBranchDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleBranchDeletion($branchId: ID!) {
|
||||
updateBranch {
|
||||
scheduleUpdateBranchDeletion(branchId: $branchId) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ branchId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return result.updateBranch.scheduleUpdateBranchDeletion;
|
||||
}
|
||||
@@ -72,6 +72,7 @@ function createMockBuildFragment({
|
||||
updatedAt: new Date().toISOString(),
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
__typename: 'App',
|
||||
|
||||
@@ -70,6 +70,7 @@ function createMockBuildFragment({
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
logFiles: [],
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
priority: BuildPriority.Normal,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface IosBuildContext {
|
||||
}
|
||||
|
||||
export interface BuildContext<T extends Platform> {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
easJsonCliConfig: EasJson['cli'];
|
||||
buildProfile: BuildProfile<T>;
|
||||
|
||||
@@ -125,6 +125,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
);
|
||||
|
||||
const commonContext: CommonContext<T> = {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { EnvironmentVariableEnvironment, isEnvironment } from './utils/environment';
|
||||
import { DefaultEnvironment } from './utils/environment';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
@@ -48,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,
|
||||
@@ -128,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'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Job, Metadata } from '@expo/eas-build-job';
|
||||
import { Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import semver from 'semver';
|
||||
|
||||
import { getExpoApiBaseUrl } from '../api';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.171';
|
||||
const PLUGIN_PACKAGE_VERSION = version; // should match version of @expo/eas-build-job
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
@@ -60,6 +61,7 @@ export async function runLocalBuildAsync(
|
||||
...env,
|
||||
...process.env,
|
||||
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
|
||||
__API_SERVER_URL: getExpoApiBaseUrl(),
|
||||
...(options.skipCleanup || options.skipNativeBuild
|
||||
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
|
||||
: {}),
|
||||
|
||||
@@ -78,10 +78,12 @@ import { downloadAndMaybeExtractAppAsync } from '../utils/download';
|
||||
import { truthy } from '../utils/expodash/filter';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { ProfileData, getProfilesAsync } from '../utils/profiles';
|
||||
import { maybeWarnAboutUsageOveragesAsync } from '../utils/usage/checkForOverages';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
let metroConfigValidated = false;
|
||||
let sdkVersionChecked = false;
|
||||
let hasWarnedAboutUsageOverages = false;
|
||||
|
||||
export interface BuildFlags {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
@@ -409,6 +411,15 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
});
|
||||
|
||||
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
|
||||
hasWarnedAboutUsageOverages = true;
|
||||
Log.newLine();
|
||||
await maybeWarnAboutUsageOveragesAsync({ graphqlClient, accountId: buildCtx.accountId });
|
||||
if (!moreBuilds) {
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (moreBuilds) {
|
||||
Log.newLine();
|
||||
const appPlatform = toAppPlatform(buildProfile.platform);
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
type Environment = NonNullable<BuildProfile['environment']>;
|
||||
|
||||
// Default environments
|
||||
export enum EnvironmentVariableEnvironment {
|
||||
export enum DefaultEnvironment {
|
||||
Development = 'development',
|
||||
Preview = 'preview',
|
||||
Production = 'production',
|
||||
}
|
||||
|
||||
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.toLowerCase() as EnvironmentVariableEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProfileEnvironmentToEnvironment(
|
||||
environment: BuildProfile['environment']
|
||||
): EnvironmentVariableEnvironment | null {
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
return BuildProfileEnvironmentToEnvironment[environment];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
ScheduleChannelDeletionMutation,
|
||||
ScheduleChannelDeletionMutationVariables,
|
||||
} from '../graphql/generated';
|
||||
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
|
||||
|
||||
export async function scheduleChannelDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelId,
|
||||
}: {
|
||||
channelId: string;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleChannelDeletionMutation, ScheduleChannelDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleChannelDeletion($channelId: ID!) {
|
||||
updateChannel {
|
||||
scheduleUpdateChannelDeletion(channelId: $channelId) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ channelId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return result.updateChannel.scheduleUpdateChannelDeletion;
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
CommandEvent,
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
experienceToAccountName,
|
||||
fullNameToSlug,
|
||||
isValidExperienceName,
|
||||
isValidSlugStrict,
|
||||
} from '../experienceParser';
|
||||
|
||||
describe(isValidExperienceName, () => {
|
||||
it.each([
|
||||
['wat', false],
|
||||
['@wat', false],
|
||||
['@wat/', false],
|
||||
['@wat/hello', true],
|
||||
])('case %p', (candidate, shouldBeValid) => {
|
||||
expect(isValidExperienceName(candidate)).toEqual(shouldBeValid);
|
||||
});
|
||||
});
|
||||
|
||||
describe(experienceToAccountName, () => {
|
||||
it('parses account name', () => {
|
||||
expect(experienceToAccountName('@wat/huh')).toEqual('wat');
|
||||
});
|
||||
});
|
||||
|
||||
describe(fullNameToSlug, () => {
|
||||
it('parses slug', () => {
|
||||
expect(fullNameToSlug('@wat/huh')).toEqual('huh');
|
||||
});
|
||||
});
|
||||
|
||||
describe(isValidSlugStrict, () => {
|
||||
it.each([
|
||||
['wat', true],
|
||||
['wat123456-hello-what123456', true],
|
||||
['@wat', false],
|
||||
['@wat/', false],
|
||||
['@wat/hello', false],
|
||||
])('case %p', (candidate, shouldBeValid) => {
|
||||
expect(isValidSlugStrict(candidate)).toEqual(shouldBeValid);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
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> {
|
||||
|
||||
+4
-12
@@ -1,23 +1,15 @@
|
||||
import { ExpoGraphqlClient } from './createGraphqlClient';
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* TODO: Move to eas-build repo
|
||||
*/
|
||||
import invariant from 'invariant';
|
||||
|
||||
const EXPERIENCE_NAME_REGEX = /^@(.*?)\/(.*)/; // @<captureGroup1>/<captureGroup2>
|
||||
const SLUG_REGEX_STRICT = /^[a-zA-Z0-9_\\-]+$/; // from xdl-schemas slug
|
||||
|
||||
export const isValidExperienceName = (experienceName: string): boolean => {
|
||||
const matches = experienceName.match(EXPERIENCE_NAME_REGEX);
|
||||
return matches !== null && matches[1].length > 0 && matches[2].length > 0;
|
||||
};
|
||||
|
||||
export const experienceToAccountName = (experienceName: string): string => {
|
||||
const matches = experienceName.match(EXPERIENCE_NAME_REGEX);
|
||||
const accountName = matches ? matches[1] : null;
|
||||
invariant(accountName, `The experience name "${experienceName}" is malformed`);
|
||||
return accountName;
|
||||
};
|
||||
|
||||
export const fullNameToSlug = (fullName: string): string | null => {
|
||||
const match = fullName.match(EXPERIENCE_NAME_REGEX);
|
||||
return match ? match[2] : null;
|
||||
};
|
||||
|
||||
export const isValidSlugStrict = (slug: string): boolean => SLUG_REGEX_STRICT.test(slug);
|
||||
|
||||
// keep this list in sync with our project creation CLI https://github.com/expo/expo/blob/main/packages/create-expo/src/Template.ts
|
||||
export const FORBIDDEN_NAMES = [
|
||||
'react-native',
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-native-web',
|
||||
'expo',
|
||||
'expo-router',
|
||||
];
|
||||
|
||||
export function isSlugForbidden(slug: string): boolean {
|
||||
return FORBIDDEN_NAMES.includes(slug);
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
|
||||
|
||||
// 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,20 +11,17 @@ export const EasNonInteractiveAndJsonFlags = {
|
||||
};
|
||||
|
||||
export const EasEnvironmentFlagParameters = {
|
||||
description: "Environment variable's environment",
|
||||
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,
|
||||
}),
|
||||
@@ -80,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,131 @@
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import {
|
||||
cloneTemplateAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../commands';
|
||||
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('commands', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'ssh',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'https',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installProjectDependenciesAsync', () => {
|
||||
it('should install the project dependencies', async () => {
|
||||
const projectDir = '/test/project-directory';
|
||||
|
||||
jest.mocked(installDependenciesAsync).mockResolvedValue();
|
||||
jest.mocked(runCommandAsync).mockResolvedValue();
|
||||
|
||||
await installProjectDependenciesAsync(projectDir, 'npm');
|
||||
|
||||
expect(installDependenciesAsync).toHaveBeenCalledWith({
|
||||
outputLevel: 'none',
|
||||
projectDir,
|
||||
packageManager: 'npm',
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-updates'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', '@expo/metro-runtime'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeGitRepositoryAsync', () => {
|
||||
it('should initialize git repository', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await initializeGitRepositoryAsync(projectDir);
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['init'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['add', '.'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['commit', '-m', 'Initial commit'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { LogSpy } from './testUtils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import {
|
||||
findAvailableProjectNameAsync,
|
||||
generateProjectConfigAsync,
|
||||
getAccountChoices,
|
||||
promptForProjectAccountAsync,
|
||||
} from '../configs';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../utils', () => ({
|
||||
printDirectory: jest.fn((dir: string) => dir),
|
||||
}));
|
||||
|
||||
describe('configs', () => {
|
||||
let logSpy: LogSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
logSpy = new LogSpy('withInfo');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
logSpy.restore();
|
||||
});
|
||||
|
||||
// Helper function to verify prompt was called with expected message
|
||||
const expectPromptToHaveMessage = (expectedMessage: string): void => {
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expectedMessage,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const mockUserInput = (inputValue: any): void => {
|
||||
jest.mocked(promptAsync).mockResolvedValue(inputValue);
|
||||
};
|
||||
|
||||
describe('generateProjectConfigAsync', () => {
|
||||
const mockOptions = {
|
||||
graphqlClient: {} as ExpoGraphqlClient,
|
||||
projectAccount: 'test-account',
|
||||
};
|
||||
|
||||
describe('when no path is provided', () => {
|
||||
it('should prompt for project name with default value', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'expo-project' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'expo-project',
|
||||
projectDirectory: expect.stringContaining('/expo-project'),
|
||||
});
|
||||
expectPromptToHaveMessage('What would you like to name your project?');
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initial: 'expo-project',
|
||||
})
|
||||
);
|
||||
logSpy.expectLogToContain('Using project name: expo-project');
|
||||
});
|
||||
|
||||
it('should prompt and use custom name entered by user', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'user-custom-name' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'user-custom-name',
|
||||
projectDirectory: expect.stringContaining('/user-custom-name'),
|
||||
});
|
||||
expectPromptToHaveMessage('What would you like to name your project?');
|
||||
logSpy.expectLogToContain('Using project name: user-custom-name');
|
||||
});
|
||||
|
||||
it('should use shortid when prompted name exists', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true); // base name exists
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'existing-project' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^existing-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining(result.projectName),
|
||||
});
|
||||
logSpy.expectLogToContain('Using project name:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when path is provided', () => {
|
||||
it('should use absolute path and extract name from basename', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
const absolutePath = '/absolute/path/to/my-project';
|
||||
const result = await generateProjectConfigAsync(absolutePath, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'my-project',
|
||||
projectDirectory: absolutePath,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
logSpy.expectLogToContain('Using project directory:');
|
||||
});
|
||||
|
||||
it('should resolve relative path and extract name from basename', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
const relativePath = 'some/relative/my-app';
|
||||
const result = await generateProjectConfigAsync(relativePath, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'my-app',
|
||||
projectDirectory: expect.stringContaining('some/relative/my-app'),
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
logSpy.expectLogToContain('Using project directory:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForProjectAccountAsync', () => {
|
||||
it('should prompt for a project account when there are multiple accounts', async () => {
|
||||
const selectedAccount = 'jester';
|
||||
mockUserInput({ account: { name: selectedAccount } });
|
||||
|
||||
const result = await promptForProjectAccountAsync(jester);
|
||||
|
||||
expect(result).toBe(selectedAccount);
|
||||
expectPromptToHaveMessage('Which account should own this project?');
|
||||
});
|
||||
|
||||
it('should automatically select single account with permissions', async () => {
|
||||
const result = await promptForProjectAccountAsync(jester);
|
||||
expect(result).toBe(jester.accounts[0].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountChoices', () => {
|
||||
it('should return mapped account choices', () => {
|
||||
const result = getAccountChoices(jester);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'other',
|
||||
value: { name: 'other' },
|
||||
disabled: true,
|
||||
description:
|
||||
'You do not have the required permissions to create projects on this account.',
|
||||
},
|
||||
{
|
||||
title: 'jester',
|
||||
value: { name: 'jester' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAvailableProjectNameAsync', () => {
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockOptions = {
|
||||
graphqlClient: mockGraphqlClient,
|
||||
projectAccount: 'jester',
|
||||
};
|
||||
|
||||
it('should find first available name when checking both local and remote', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'test-project',
|
||||
projectDirectory: '/base/path/test-project',
|
||||
});
|
||||
expect(fs.pathExists).toHaveBeenCalledTimes(1);
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'test-project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name exists locally', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true); // Base name exists locally
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
expect(fs.pathExists).toHaveBeenCalledTimes(1);
|
||||
expect(fs.pathExists).toHaveBeenCalledWith('/base/path/test-project');
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name exists remotely', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id'); // Base name exists remotely
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(1);
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'test-project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name is taken both locally and remotely', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id');
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getEASUpdateURL } from '../../../api';
|
||||
import { AppFragment } from '../../../graphql/generated';
|
||||
import { easCliVersion } from '../../../utils/easCli';
|
||||
import {
|
||||
cleanAndPrefix,
|
||||
copyProjectTemplatesAsync,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
updatePackageJsonAsync,
|
||||
updateReadmeAsync,
|
||||
} from '../projectFiles';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('projectFiles', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
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(''));
|
||||
jest.mocked(fs.symlink).mockImplementation(() => Promise.resolve());
|
||||
});
|
||||
|
||||
describe('cleanAndPrefix', () => {
|
||||
it('should clean and prefix the string', () => {
|
||||
expect(cleanAndPrefix('test-app-123', 'app')).toBe('testapp123');
|
||||
expect(cleanAndPrefix('test@app#with$symbols', 'app')).toBe('testappwithsymbols');
|
||||
expect(cleanAndPrefix('test-app-with-dashes', 'app')).toBe('testappwithdashes');
|
||||
expect(cleanAndPrefix('TestApp', 'app')).toBe('appTestApp');
|
||||
expect(cleanAndPrefix('100 App', 'app')).toBe('app100App');
|
||||
});
|
||||
|
||||
it('should handle schemes', () => {
|
||||
expect(cleanAndPrefix('test-app-123', 'scheme')).toBe('test-app-123');
|
||||
expect(cleanAndPrefix('test@app#with$symbols', 'scheme')).toBe('testappwithsymbols');
|
||||
expect(cleanAndPrefix('test+app.with-allowed.symbols', 'scheme')).toBe(
|
||||
'test+app.with-allowed.symbols'
|
||||
);
|
||||
expect(cleanAndPrefix('100 App', 'scheme')).toBe('scheme100app');
|
||||
expect(cleanAndPrefix('TestApp', 'scheme')).toBe('testapp');
|
||||
expect(cleanAndPrefix('testApp2', 'scheme')).toBe('testapp2');
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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: `>= ${easCliVersion}`,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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: {
|
||||
draft: 'npx eas-cli@latest workflow:run create-draft.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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProjectTemplatesAsync', () => {
|
||||
it('should copy the project templates', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await copyProjectTemplatesAsync(projectDir);
|
||||
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('templates'),
|
||||
projectDir,
|
||||
expect.objectContaining({
|
||||
errorOnExist: false,
|
||||
overwrite: true,
|
||||
filter: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReadmeAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
const readmeTemplatePath = expect.stringContaining('templates/readme-additions.md');
|
||||
const projectReadmePath = `${projectDir}/README.md`;
|
||||
|
||||
const mockReadmeTemplate = `## Get started
|
||||
|
||||
To start the app, in your terminal run:
|
||||
|
||||
\`\`\`bash
|
||||
npm run start
|
||||
\`\`\`
|
||||
|
||||
## Workflows
|
||||
`;
|
||||
const mockExistingReadme = `# My App
|
||||
|
||||
## Get started
|
||||
|
||||
Follow these steps to get started with the app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the dependencies first.`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
|
||||
const content = filePath.includes('readme-additions.md')
|
||||
? mockReadmeTemplate
|
||||
: filePath === projectReadmePath
|
||||
? mockExistingReadme
|
||||
: '';
|
||||
return Promise.resolve(content);
|
||||
});
|
||||
});
|
||||
|
||||
it('should read the existing readme and merge the template content', async () => {
|
||||
await updateReadmeAsync(projectDir, 'npm');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(readmeTemplatePath, 'utf8');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(projectReadmePath, 'utf8');
|
||||
|
||||
const headings = ['# My App', '## Get started', '## Workflows'];
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace npm run with package manager specific commands', async () => {
|
||||
await updateReadmeAsync(projectDir, 'yarn');
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringContaining('yarn run start')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle the case when 'Get started' section does not exist", async () => {
|
||||
const readmeWithoutGetStarted = `# My App
|
||||
|
||||
This is my awesome app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the dependencies first.`;
|
||||
|
||||
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
|
||||
const content = filePath.includes('readme-additions.md')
|
||||
? mockReadmeTemplate
|
||||
: filePath === projectReadmePath
|
||||
? readmeWithoutGetStarted
|
||||
: '';
|
||||
return Promise.resolve(content);
|
||||
});
|
||||
|
||||
await updateReadmeAsync(projectDir, 'npm');
|
||||
|
||||
const headings = ['# My App', '## Installation', '## Get started', '## Workflows'];
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import Log from '../../../log';
|
||||
|
||||
export class LogSpy {
|
||||
private readonly logSpy: jest.SpyInstance;
|
||||
|
||||
constructor(logMethod: 'warn' | 'log' | 'withTick' | 'withInfo') {
|
||||
this.logSpy = jest.spyOn(Log, logMethod);
|
||||
}
|
||||
|
||||
getLogOutput(): string[] {
|
||||
return this.logSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
|
||||
}
|
||||
|
||||
expectLogToContain(message: string): void {
|
||||
const output = this.getLogOutput();
|
||||
// strip out ANSI codes and special characters like the tick
|
||||
const outputWithoutAnsi = output.map(line =>
|
||||
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
|
||||
);
|
||||
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.logSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from '../utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('printDirectory', () => {
|
||||
let cwdSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock process.cwd() to return a predictable value
|
||||
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/Users/test/projects');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return "." for the current directory', () => {
|
||||
const result = printDirectory('/Users/test/projects');
|
||||
expect(result).toBe('.');
|
||||
});
|
||||
|
||||
it('should return relative path with "./" prefix for subdirectory', () => {
|
||||
const result = printDirectory('/Users/test/projects/my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should return relative path with "./" prefix for nested subdirectory', () => {
|
||||
const result = printDirectory('/Users/test/projects/nested/my-app');
|
||||
expect(result).toBe('./nested/my-app');
|
||||
});
|
||||
|
||||
it('should return absolute path for directory outside cwd', () => {
|
||||
const result = printDirectory('/Users/other/projects/my-app');
|
||||
expect(result).toBe('/Users/other/projects/my-app');
|
||||
});
|
||||
|
||||
it('should return absolute path for parent directory', () => {
|
||||
const result = printDirectory('/Users/test');
|
||||
expect(result).toBe('/Users/test');
|
||||
});
|
||||
|
||||
it('should handle relative paths and convert them correctly', () => {
|
||||
// Relative path resolves to absolute first, then converts back to relative
|
||||
const result = printDirectory('my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle relative paths with "./" already', () => {
|
||||
const result = printDirectory('./my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle relative paths with "../" (goes outside cwd)', () => {
|
||||
const result = printDirectory('../other-project');
|
||||
// This goes outside cwd, so should return absolute path
|
||||
expect(result).toBe('/Users/test/other-project');
|
||||
});
|
||||
|
||||
it('should handle complex relative paths', () => {
|
||||
const result = printDirectory('./nested/deep/project');
|
||||
expect(result).toBe('./nested/deep/project');
|
||||
});
|
||||
|
||||
it('should normalize paths correctly', () => {
|
||||
const result = printDirectory('/Users/test/projects/./my-app/../my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle paths in real cwd', () => {
|
||||
// Restore mock to test with real cwd
|
||||
cwdSpy.mockRestore();
|
||||
const realCwd = process.cwd();
|
||||
|
||||
// Test with current directory
|
||||
const result1 = printDirectory(realCwd);
|
||||
expect(result1).toBe('.');
|
||||
|
||||
// Test with subdirectory
|
||||
const subDir = path.join(realCwd, 'test-dir');
|
||||
const result2 = printDirectory(subDir);
|
||||
expect(result2).toBe('./test-dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
|
||||
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
const githubRepositoryName = 'expo-template-default';
|
||||
|
||||
const spinner = ora(
|
||||
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
|
||||
).start();
|
||||
|
||||
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
targetProjectDir,
|
||||
cloneMethod,
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
|
||||
|
||||
return finalTargetProjectDirectory;
|
||||
}
|
||||
|
||||
export async function installProjectDependenciesAsync(
|
||||
projectDir: string,
|
||||
packageManager: PackageManager
|
||||
): Promise<void> {
|
||||
const spinner = ora(`${chalk.bold('Installing project dependencies')}`).start();
|
||||
await installDependenciesAsync({
|
||||
outputLevel: 'none',
|
||||
projectDir,
|
||||
packageManager,
|
||||
});
|
||||
|
||||
const dependencies = ['expo-updates', '@expo/metro-runtime'];
|
||||
for (const dependency of dependencies) {
|
||||
spinner.text = `Installing ${chalk.bold(dependency)}`;
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', dependency],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Installed project dependencies`);
|
||||
}
|
||||
|
||||
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
|
||||
const spinner = ora(`${chalk.bold('Initializing Git repository')}`).start();
|
||||
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,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Initialized Git repository`);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { Choice, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
function validateProjectPath(resolvedPath: string): void {
|
||||
const normalizedPath = path.normalize(resolvedPath);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (normalizedPath.includes('..')) {
|
||||
throw new Error(`Invalid project path: "${resolvedPath}". Path traversal is not allowed.`);
|
||||
}
|
||||
|
||||
// Ensure we're not trying to create a project in system directories
|
||||
const systemDirs = ['/bin', '/sbin', '/etc', '/usr', '/var', '/sys', '/proc', '/dev'];
|
||||
const isSystemDir = systemDirs.some(
|
||||
dir => normalizedPath === dir || normalizedPath.startsWith(dir + path.sep)
|
||||
);
|
||||
|
||||
if (isSystemDir) {
|
||||
throw new Error(
|
||||
`Invalid project path: "${resolvedPath}". Cannot create projects in system directories.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateProjectConfigAsync(
|
||||
pathArg: string | undefined,
|
||||
options: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectAccount: string;
|
||||
}
|
||||
): Promise<{
|
||||
projectName: string;
|
||||
projectDirectory: string;
|
||||
}> {
|
||||
let baseName = 'expo-project';
|
||||
let parentDirectory = process.cwd();
|
||||
|
||||
if (pathArg) {
|
||||
const resolvedPath = path.isAbsolute(pathArg) ? pathArg : path.resolve(process.cwd(), pathArg);
|
||||
validateProjectPath(resolvedPath);
|
||||
baseName = path.basename(resolvedPath);
|
||||
parentDirectory = path.dirname(resolvedPath);
|
||||
} else {
|
||||
baseName = (
|
||||
await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'What would you like to name your project?',
|
||||
initial: 'expo-project',
|
||||
})
|
||||
).name;
|
||||
}
|
||||
|
||||
// Find an available name checking both local filesystem and remote server
|
||||
const { projectName, projectDirectory } = await findAvailableProjectNameAsync(
|
||||
baseName,
|
||||
parentDirectory,
|
||||
options
|
||||
);
|
||||
|
||||
Log.withInfo(`Using project name: ${projectName}`);
|
||||
Log.withInfo(`Using project directory: ${printDirectory(projectDirectory)}`);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
|
||||
const permissionsMap = new Map<string, boolean>();
|
||||
|
||||
for (const account of actor.accounts) {
|
||||
const hasPermission =
|
||||
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
|
||||
permissionsMap.set(account.name, hasPermission);
|
||||
}
|
||||
|
||||
return permissionsMap;
|
||||
}
|
||||
|
||||
export async function promptForProjectAccountAsync(actor: Actor): Promise<string> {
|
||||
const permissionsMap = getAccountPermissionsMap(actor);
|
||||
|
||||
// If only one account, use it (if has permissions)
|
||||
if (actor.accounts.length === 1) {
|
||||
const account = actor.accounts[0];
|
||||
|
||||
if (permissionsMap.get(account.name)) {
|
||||
return account.name;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`You don't have permission to create projects on your only available account (${account.name}).`
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple accounts - prompt user to select one with permissions
|
||||
return (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices: getAccountChoices(actor, permissionsMap),
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
|
||||
export function getAccountChoices(actor: Actor, permissionsMap?: Map<string, boolean>): Choice[] {
|
||||
const permissions = permissionsMap ?? getAccountPermissionsMap(actor);
|
||||
const sortedAccounts = [...actor.accounts].sort((a, _b) => (a.ownerUserActor ? 1 : -1));
|
||||
|
||||
return sortedAccounts.map(account => {
|
||||
const isPersonalAccount = !!account.ownerUserActor && account.ownerUserActor.id === actor.id;
|
||||
const isTeamAccount = !!account.ownerUserActor && account.ownerUserActor.id !== actor.id;
|
||||
|
||||
const accountDisplayName = isPersonalAccount
|
||||
? `${account.name} (Limited - Personal Account)`
|
||||
: isTeamAccount
|
||||
? `${account.name} (Limited - Team Account)`
|
||||
: account.name;
|
||||
|
||||
const disabled = !permissions.get(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.',
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyProjectDoesNotExistAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
{ silent = false }: { silent?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
|
||||
const doesNotExist = existingProjectId === null;
|
||||
if (!doesNotExist && !silent) {
|
||||
Log.warn(`Project @${accountName}/${projectName} already exists on the server.`);
|
||||
}
|
||||
|
||||
return doesNotExist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an available project name that doesn't conflict with either:
|
||||
* Local filesystem (directory already exists)
|
||||
* Remote server (project already exists on Expo)
|
||||
*/
|
||||
export async function findAvailableProjectNameAsync(
|
||||
baseName: string,
|
||||
parentDirectory: string,
|
||||
{
|
||||
graphqlClient,
|
||||
projectAccount,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectAccount: string;
|
||||
}
|
||||
): Promise<{ projectName: string; projectDirectory: string }> {
|
||||
let projectName = baseName;
|
||||
let projectDirectory = path.join(parentDirectory, projectName);
|
||||
|
||||
const localExists = await fs.pathExists(projectDirectory);
|
||||
|
||||
const remoteAvailable = await verifyProjectDoesNotExistAsync(
|
||||
graphqlClient,
|
||||
projectAccount,
|
||||
projectName
|
||||
);
|
||||
|
||||
if (localExists || !remoteAvailable) {
|
||||
projectName = `${baseName}-${nanoid(6)}`;
|
||||
projectDirectory = path.join(parentDirectory, projectName);
|
||||
}
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import merge from 'ts-deepmerge';
|
||||
|
||||
import { getEASUpdateURL } from '../../api';
|
||||
import { AppFragment } from '../../graphql/generated';
|
||||
import { PackageManager } from '../../onboarding/installDependencies';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
// Android package names must start with a lowercase letter
|
||||
// schemes must start with a lowercase letter and can only contain lowercase letters, digits, "+", "." or "-"
|
||||
export function cleanAndPrefix(_string: string, type: 'user' | 'app' | 'scheme'): string {
|
||||
let string = _string;
|
||||
let pattern = /[^A-Za-z0-9]/g;
|
||||
if (type === 'scheme') {
|
||||
string = _string.toLowerCase();
|
||||
pattern = /[^a-z0-9+.-]/g;
|
||||
}
|
||||
|
||||
const prefix = /^[^a-z]/.test(string) ? type : '';
|
||||
const cleaned = string.replaceAll(pattern, '');
|
||||
|
||||
return prefix + cleaned;
|
||||
}
|
||||
|
||||
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
|
||||
const user = cleanAndPrefix(app.ownerAccount.name, 'user');
|
||||
const slug = cleanAndPrefix(app.slug, 'app');
|
||||
const scheme = cleanAndPrefix(app.name ?? app.slug, 'scheme');
|
||||
|
||||
const bundleIdentifier = `com.${user}.${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,
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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.draft = 'npx eas-cli@latest workflow:run create-draft.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 });
|
||||
}
|
||||
|
||||
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
|
||||
const templatesSourceDir = path.join(__dirname, 'templates');
|
||||
|
||||
// Copy everything from templates to projectDir, skipping readme-additions.md
|
||||
await fs.copy(templatesSourceDir, projectDir, {
|
||||
overwrite: true,
|
||||
errorOnExist: false,
|
||||
filter: (src: string) => {
|
||||
return !src.endsWith('readme-additions.md');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateReadmeAsync(
|
||||
projectDir: string,
|
||||
packageManager: PackageManager
|
||||
): 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 started';
|
||||
const sectionIndex = existingReadme.indexOf(targetSection);
|
||||
|
||||
let mergedReadme: string;
|
||||
if (sectionIndex !== -1) {
|
||||
// Find the next ## section after "## Get started"
|
||||
const afterTargetSection = existingReadme.substring(sectionIndex);
|
||||
const nextSectionMatch = afterTargetSection.match(/\n## /);
|
||||
let endIndex = existingReadme.length;
|
||||
if (nextSectionMatch?.index !== undefined) {
|
||||
// Replace from "## Get started" to the next "##" section
|
||||
endIndex = sectionIndex + nextSectionMatch.index;
|
||||
}
|
||||
|
||||
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
|
||||
const afterSection = existingReadme.substring(endIndex);
|
||||
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
|
||||
} else {
|
||||
// No "Get started" section found, append the template to the existing README
|
||||
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim();
|
||||
}
|
||||
|
||||
mergedReadme = mergedReadme.replaceAll('npm run', `${packageManager} run`);
|
||||
|
||||
await fs.writeFile(projectReadmePath, mergedReadme);
|
||||
}
|
||||
File renamed without changes.
+4
-1
@@ -1,4 +1,4 @@
|
||||
name: Publish preview update
|
||||
name: Create draft
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,3 +10,6 @@ jobs:
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
deploy_website:
|
||||
name: Deploy website
|
||||
type: deploy
|
||||
File renamed without changes.
@@ -0,0 +1,163 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||
|
||||
## Documentation Resources
|
||||
|
||||
When working on this project, **always consult the official Expo documentation** available at:
|
||||
|
||||
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
|
||||
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
|
||||
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
|
||||
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
|
||||
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
|
||||
|
||||
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
|
||||
|
||||
- Expo APIs and best practices
|
||||
- Expo Router navigation patterns
|
||||
- EAS Build, Submit, and Update workflows
|
||||
- Expo SDK modules and their usage
|
||||
- Development and deployment processes
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── app/ # Expo Router file-based routing
|
||||
│ ├── (tabs)/ # Tab-based navigation screens
|
||||
│ │ ├── index.tsx # Home screen
|
||||
│ │ ├── explore.tsx # Explore screen
|
||||
│ │ └── _layout.tsx # Tabs layout
|
||||
│ ├── _layout.tsx # Root layout with theme provider
|
||||
│ └── modal.tsx # Modal screen example
|
||||
├── components/ # Reusable React components
|
||||
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
|
||||
│ └── ... # Feature components (themed, haptic, parallax)
|
||||
├── constants/ # App-wide constants (theme, colors)
|
||||
├── hooks/ # Custom React hooks (color scheme, theme)
|
||||
├── assets/ # Static assets (images, fonts)
|
||||
├── scripts/ # Utility scripts (reset-project)
|
||||
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
|
||||
├── app.json # Expo configuration
|
||||
├── eas.json # EAS Build/Submit configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npx expo start # Start dev server
|
||||
npx expo start --clear # Clear cache and start dev server
|
||||
npx expo install <package> # Install packages with compatible versions
|
||||
npx expo install --check # Check which installed packages need to be updated
|
||||
npx expo install --fix # Automatically update any invalid package versions
|
||||
npm run development-builds # Create development builds (workflow)
|
||||
npm run reset-project # Reset to blank template
|
||||
```
|
||||
|
||||
### Building & Testing
|
||||
|
||||
```bash
|
||||
npx expo doctor # Check project health and dependencies
|
||||
npx expo lint # Run ESLint
|
||||
npm run draft # Publish preview update and website (workflow)
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
|
||||
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
|
||||
npm run deploy # Deploy to production (workflow)
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style & Standards
|
||||
|
||||
- **TypeScript First**: Use TypeScript for all new code with strict type checking
|
||||
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
|
||||
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
|
||||
- **React 19 Patterns**: Follow modern React patterns including:
|
||||
- Function components with hooks
|
||||
- Enable React Compiler
|
||||
- Proper dependency arrays in useEffect
|
||||
- Memoization when appropriate (useMemo, useCallback)
|
||||
- Error boundaries for better error handling
|
||||
|
||||
### Navigation & Routing
|
||||
|
||||
- Use **Expo Router** for all navigation
|
||||
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
|
||||
- Docs: https://docs.expo.dev/router/introduction/
|
||||
|
||||
### Recommended Libraries
|
||||
|
||||
- **Navigation**: `expo-router` for navigation
|
||||
- **Images**: `expo-image` for optimized image handling and caching
|
||||
- **Animations**: `react-native-reanimated` for performant animations on native thread
|
||||
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
|
||||
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
|
||||
|
||||
## Debugging & Development Tools
|
||||
|
||||
### DevTools Integration
|
||||
|
||||
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
|
||||
- **Network Inspection**: Monitor API calls and network requests in DevTools
|
||||
- **Element Inspector**: Debug component hierarchy and styles
|
||||
- **Performance Profiler**: Identify performance bottlenecks
|
||||
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
|
||||
|
||||
### Testing & Quality Assurance
|
||||
|
||||
#### Automated Testing with MCP Tools
|
||||
|
||||
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
|
||||
|
||||
- **Component Testing**: Add `testID` props to components for automation
|
||||
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
|
||||
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
|
||||
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
|
||||
|
||||
## EAS Workflows CI/CD
|
||||
|
||||
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
|
||||
|
||||
When working with EAS Workflows, **always refer to**:
|
||||
|
||||
- https://docs.expo.dev/eas/workflows/ for workflow examples
|
||||
- The `.eas/workflows/` directory for existing workflow configurations
|
||||
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
|
||||
|
||||
### Build Profiles (eas.json)
|
||||
|
||||
- **development**: Development builds with dev client
|
||||
- **development-simulator**: Development builds for iOS simulator
|
||||
- **preview**: Internal distribution preview builds
|
||||
- **production**: Production builds with auto-increment
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Expo Go Errors & Development Builds
|
||||
|
||||
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
|
||||
|
||||
## AI Agent Instructions
|
||||
|
||||
When working on this project:
|
||||
|
||||
1. **Always start by consulting the appropriate documentation**:
|
||||
|
||||
- For general Expo questions: https://docs.expo.dev/llms-full.txt
|
||||
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
|
||||
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
|
||||
|
||||
2. **Understand before implementing**: Read the relevant docs section before writing code
|
||||
|
||||
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
|
||||
@@ -0,0 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**Note**: This project uses `AGENTS.md` files for detailed guidance.
|
||||
|
||||
## Primary Reference
|
||||
|
||||
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
|
||||
+18
-1
@@ -1,10 +1,27 @@
|
||||
## Get started
|
||||
|
||||
To start the app, in your terminal run:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in:
|
||||
|
||||
- [a development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [an Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [an iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## 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.
|
||||
Run `npm run draft` 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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Formats a directory path for display.
|
||||
* If the directory is within the current working directory, returns a relative path.
|
||||
* Otherwise, returns the absolute path.
|
||||
*/
|
||||
export function printDirectory(directory: string): string {
|
||||
const cwd = process.cwd();
|
||||
const absoluteDir = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory);
|
||||
|
||||
const relativePath = path.relative(cwd, absoluteDir);
|
||||
|
||||
// If the relative path doesn't start with '..' it means it's within or at the cwd
|
||||
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
||||
return relativePath !== '' ? `./${relativePath}` : '.';
|
||||
}
|
||||
|
||||
return absoluteDir;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isSlugForbidden, isValidExperienceName, isValidSlugStrict } from './experienceParser';
|
||||
|
||||
/**
|
||||
* Perform the same validation as the server, to avoid GraphQL errors.
|
||||
*/
|
||||
export function validateFullNameAndSlug(fullName: string, slug: string): void {
|
||||
if (!isValidExperienceName(fullName)) {
|
||||
throw new Error(`Invalid project name: ${fullName}`);
|
||||
}
|
||||
|
||||
if (!isValidSlugStrict(slug)) {
|
||||
throw new Error(`Invalid slug: ${slug}`);
|
||||
}
|
||||
|
||||
if (isSlugForbidden(slug)) {
|
||||
throw new Error(
|
||||
`Cannot create an app named "${slug}" because it would conflict with a dependency of the same name.`
|
||||
);
|
||||
}
|
||||
|
||||
if (fullName.length > 255) {
|
||||
throw new Error(`Project full name (${fullName}) can not be longer than 255 characters.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to derive a valid slug name from the one passed in
|
||||
*/
|
||||
export function validSlugName(slug: string): string {
|
||||
const chars = slug.split('');
|
||||
const validChars = chars.filter(char => isValidSlugStrict(char));
|
||||
return validChars.join('');
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { WorkflowJobResult } from './types';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
// This function is in a separate module for testing purposes
|
||||
|
||||
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
|
||||
export async function fetchRawLogsForCustomJobAsync(
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
@@ -13,3 +17,25 @@ export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<s
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
|
||||
export async function fetchRawLogsForBuildJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const buildId = job.outputs?.build_id;
|
||||
if (!buildId) {
|
||||
return null;
|
||||
}
|
||||
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
|
||||
useCache: false,
|
||||
});
|
||||
const firstLogFileUrl = buildFragment.logFiles?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DefaultEnvironment } from '../../build/utils/environment';
|
||||
import { booleanLike, stringLike } from '../../credentials/ios/types';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
const inputExtraProperties = {
|
||||
description: stringLike.optional().describe('Description of the input'),
|
||||
required: booleanLike.default(false).optional().describe('Whether the input is required.'),
|
||||
};
|
||||
|
||||
// Adapted from the input definition on the server (https://github.com/expo/universe/pull/21950)
|
||||
export const WorkflowDispatchInputZ = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('string').default('string').optional(),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('boolean'),
|
||||
default: booleanLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('number'),
|
||||
default: z.number().optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('choice'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('environment'),
|
||||
default: z
|
||||
.enum(Object.values(DefaultEnvironment))
|
||||
.optional()
|
||||
.describe('Default value for the input'),
|
||||
}),
|
||||
]);
|
||||
export function parseInputs(inputFlags: string[]): Record<string, string> {
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
for (const inputFlag of inputFlags) {
|
||||
const equalIndex = inputFlag.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
|
||||
}
|
||||
|
||||
const key = inputFlag.substring(0, equalIndex);
|
||||
const value = inputFlag.substring(equalIndex + 1);
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
|
||||
}
|
||||
|
||||
inputs[key] = value;
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export function parseJsonInputs(jsonString: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('JSON input must be an object.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON input.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWorkflowInputsFromYaml(
|
||||
yamlConfig: string
|
||||
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
|
||||
try {
|
||||
const parsed = YAML.parse(yamlConfig);
|
||||
return z
|
||||
.record(z.string(), WorkflowDispatchInputZ)
|
||||
.default({})
|
||||
.parse(parsed?.on?.workflow_dispatch?.inputs);
|
||||
} catch (error) {
|
||||
Log.warn('Failed to parse workflow inputs from YAML:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
export async function maybePromptForMissingInputsAsync({
|
||||
inputSpecs,
|
||||
inputs,
|
||||
}: {
|
||||
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
|
||||
inputs: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
|
||||
|
||||
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
|
||||
|
||||
if (missingRequiredInputs.length === 0) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Some required inputs are missing. Please provide them:');
|
||||
|
||||
const nextInputs = { ...inputs };
|
||||
|
||||
for (const [key, spec] of missingRequiredInputs) {
|
||||
const value = await promptForMissingInputAsync({ key, spec });
|
||||
nextInputs[key] = value;
|
||||
}
|
||||
|
||||
return nextInputs;
|
||||
}
|
||||
|
||||
async function promptForMissingInputAsync({
|
||||
key,
|
||||
spec,
|
||||
}: {
|
||||
key: string;
|
||||
spec: z.infer<typeof WorkflowDispatchInputZ>;
|
||||
}): Promise<unknown> {
|
||||
const message = spec.description ? `${key} (${spec.description})` : key;
|
||||
|
||||
switch (spec.type) {
|
||||
case 'boolean': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'number',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: number) => {
|
||||
if (isNaN(val)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'choice': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'value',
|
||||
message,
|
||||
choices: spec.options.map(option => ({
|
||||
title: option,
|
||||
value: option,
|
||||
})),
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'string':
|
||||
case 'environment':
|
||||
default: {
|
||||
const { value } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: string) => {
|
||||
if (spec.required && (!val || val.trim() === '')) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
choiceFromWorkflowJob,
|
||||
choiceFromWorkflowRun,
|
||||
choicesFromWorkflowLogs,
|
||||
processLogsFromJobAsync,
|
||||
fetchAndProcessLogsFromJobAsync,
|
||||
processWorkflowRuns,
|
||||
} from './utils';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
@@ -268,7 +268,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
|
||||
if (!job) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
|
||||
}
|
||||
const logs = await processLogsFromJobAsync(job);
|
||||
const logs = await fetchAndProcessLogsFromJobAsync(prevState, job);
|
||||
if (!logs) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
|
||||
|
||||
/*
|
||||
* Utility types for workflow commands
|
||||
* Utility types and Zod definitions for workflow commands
|
||||
*/
|
||||
|
||||
export enum WorkflowTriggerType {
|
||||
@@ -42,5 +42,12 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
|
||||
export type WorkflowLogLine = {
|
||||
time: string;
|
||||
msg: string;
|
||||
result?: string;
|
||||
marker?: string;
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchRawLogsForJobAsync } from './fetchLogs';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
|
||||
import {
|
||||
WorkflowJobResult,
|
||||
WorkflowLogLine,
|
||||
@@ -7,11 +10,21 @@ import {
|
||||
WorkflowTriggerType,
|
||||
} from './types';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { Choice } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
|
||||
triggerType: WorkflowTriggerType;
|
||||
@@ -68,16 +81,23 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
|
||||
};
|
||||
}
|
||||
|
||||
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 choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
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}`,
|
||||
name: step,
|
||||
status: stepStatus,
|
||||
value: step,
|
||||
logLines,
|
||||
};
|
||||
})
|
||||
.filter(step => step.status !== 'skipped');
|
||||
}
|
||||
|
||||
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
|
||||
@@ -100,29 +120,254 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes
|
||||
});
|
||||
}
|
||||
|
||||
export async function processLogsFromJobAsync(
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
const rawLogs = await fetchRawLogsForJobAsync(job);
|
||||
let rawLogs: string | null;
|
||||
switch (job.type) {
|
||||
case WorkflowJobType.Build:
|
||||
case WorkflowJobType.Repack:
|
||||
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
|
||||
break;
|
||||
default:
|
||||
rawLogs = await fetchRawLogsForCustomJobAsync(job);
|
||||
break;
|
||||
}
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const logs: WorkflowLogs = new Map();
|
||||
const logKeys = new Set<string>();
|
||||
rawLogs.split('\n').forEach(line => {
|
||||
rawLogs.split('\n').forEach((line, index) => {
|
||||
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { result, marker } = parsedLine;
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
|
||||
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 });
|
||||
logs.get(stepId)?.push({ time, msg, result, marker, err });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
function descriptionForJobStatus(status: WorkflowJobStatus): string {
|
||||
switch (status) {
|
||||
case WorkflowJobStatus.New:
|
||||
return 'Waiting for worker';
|
||||
case WorkflowJobStatus.InProgress:
|
||||
return 'In progress';
|
||||
case WorkflowJobStatus.Success:
|
||||
return 'Completed successfully';
|
||||
case WorkflowJobStatus.Failure:
|
||||
return 'Failed';
|
||||
case WorkflowJobStatus.Canceled:
|
||||
return 'Canceled';
|
||||
case WorkflowJobStatus.Skipped:
|
||||
return 'Skipped';
|
||||
case WorkflowJobStatus.ActionRequired:
|
||||
return 'Waiting for action';
|
||||
case WorkflowJobStatus.PendingCancel:
|
||||
return 'Pending cancel';
|
||||
}
|
||||
}
|
||||
|
||||
export async function infoForActiveWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = 5 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
for (const job of workflowRun.jobs) {
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Job', value: job.name });
|
||||
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
|
||||
if (job.status !== WorkflowJobStatus.InProgress) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
if (steps.length > 0) {
|
||||
const currentStep = steps[steps.length - 1];
|
||||
statusValues.push({ label: ' Current step', value: currentStep.name });
|
||||
if (currentStep?.logLines?.length) {
|
||||
statusValues.push({ label: ' Current logs', value: '' });
|
||||
const currentLogs =
|
||||
currentStep.logLines
|
||||
?.map(line => line.msg)
|
||||
.filter((_, index) => {
|
||||
if (maxLogLines === -1) {
|
||||
return true;
|
||||
}
|
||||
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
|
||||
}) ?? [];
|
||||
for (const log of currentLogs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
|
||||
export async function infoForFailedWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = -1 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
|
||||
for (const job of workflowRun.jobs) {
|
||||
if (job.status !== WorkflowJobStatus.Failure) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
if (steps.length > 0) {
|
||||
const failedStep = steps.find(step => step.status === 'fail');
|
||||
if (failedStep) {
|
||||
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
|
||||
statusValues.push({ label: ' Failed step', value: failedStep.name });
|
||||
statusValues.push({
|
||||
label: ' Logs for failed step',
|
||||
value: '',
|
||||
});
|
||||
for (const log of logs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(filePath, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
export async function maybeReadStdinAsync(): Promise<string | null> {
|
||||
// Check if there's data on stdin
|
||||
if (process.stdin.isTTY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
data += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const trimmedData = data.trim();
|
||||
resolve(trimmedData || null);
|
||||
});
|
||||
|
||||
process.stdin.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function showWorkflowStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
waitForCompletion = true,
|
||||
}: { workflowRunId: string; spinnerUsesStdErr: boolean; waitForCompletion?: boolean }
|
||||
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
|
||||
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
|
||||
|
||||
const spinner = ora({
|
||||
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
|
||||
text: '',
|
||||
}).start();
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.New:
|
||||
break;
|
||||
case WorkflowRunStatus.InProgress: {
|
||||
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
|
||||
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
|
||||
break;
|
||||
}
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
|
||||
spinner.stopAndPersist();
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure: {
|
||||
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
|
||||
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
|
||||
spinner.fail(failedInfo);
|
||||
return workflowRun;
|
||||
}
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
|
||||
spinner.text = '';
|
||||
spinner.succeed('');
|
||||
return workflowRun;
|
||||
}
|
||||
if (!waitForCompletion) {
|
||||
if (spinner.isSpinning) {
|
||||
spinner.stopAndPersist();
|
||||
}
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(workflowRunExitCodes.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
}
|
||||
}
|
||||
|
||||
export const workflowRunExitCodes = {
|
||||
WORKFLOW_FAILED: 11,
|
||||
WORKFLOW_CANCELED: 12,
|
||||
WAIT_ABORTED: 13,
|
||||
};
|
||||
@@ -1,23 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { scheduleBranchDeletionAsync } from '../../branch/delete';
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateBranchMutation,
|
||||
DeleteUpdateBranchMutationVariables,
|
||||
DeleteUpdateBranchResult,
|
||||
GetBranchInfoQuery,
|
||||
GetBranchInfoQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
async function getBranchInfoAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -50,31 +46,6 @@ async function getBranchInfoAsync(
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteBranchOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ branchId }: DeleteUpdateBranchMutationVariables
|
||||
): Promise<DeleteUpdateBranchResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateBranch($branchId: ID!) {
|
||||
updateBranch {
|
||||
deleteUpdateBranch(branchId: $branchId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
branchId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.deleteUpdateBranch;
|
||||
}
|
||||
|
||||
export default class BranchDelete extends EasCommand {
|
||||
static override description = 'delete a branch';
|
||||
|
||||
@@ -146,12 +117,12 @@ export default class BranchDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteBranchOnAppAsync(graphqlClient, {
|
||||
branchId,
|
||||
});
|
||||
const receipt = await scheduleBranchDeletionAsync(graphqlClient, { branchId });
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
Log.debug('Deletion result', { successfulReceipt });
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
printJsonOnlyOutput({ id: branchId });
|
||||
} else {
|
||||
Log.withTick(
|
||||
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
|
||||
|
||||
@@ -10,7 +10,6 @@ import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEn
|
||||
import { ensureIosCredentialsForBuildResignAsync } from '../../build/ios/credentials';
|
||||
import { prepareCredentialsToResign } from '../../build/ios/prepareJob';
|
||||
import { listAndSelectBuildOnAppAsync } from '../../build/queries';
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import { printBuildResults, printLogsUrls } from '../../build/utils/printBuildInfo';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { scheduleChannelDeletionAsync } from '../../channel/delete';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateChannelMutation,
|
||||
DeleteUpdateChannelMutationVariables,
|
||||
DeleteUpdateChannelResult,
|
||||
} from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
export default class ChannelDelete extends EasCommand {
|
||||
static override description = 'Delete a channel';
|
||||
@@ -77,7 +70,7 @@ export default class ChannelDelete extends EasCommand {
|
||||
if (!nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
|
||||
`You are about to permanently delete channel: "${channelName}". Deleting this channel will also permanently remove all associated builds from your account. \nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
@@ -87,39 +80,14 @@ export default class ChannelDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
|
||||
channelId,
|
||||
});
|
||||
const receipt = await scheduleChannelDeletionAsync(graphqlClient, { channelId });
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
Log.debug('Deletion result', { successfulReceipt });
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
printJsonOnlyOutput({ id: channelId });
|
||||
} else {
|
||||
Log.withTick(`️Deleted channel "${channelName}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChannelOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId }: DeleteUpdateChannelMutationVariables
|
||||
): Promise<DeleteUpdateChannelResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateChannelMutation, DeleteUpdateChannelMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateChannel($channelId: ID!) {
|
||||
updateChannel {
|
||||
deleteUpdateChannel(channelId: $channelId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
channelId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateChannel.deleteUpdateChannel;
|
||||
}
|
||||
@@ -44,8 +44,6 @@ export default class WorkerAliasDelete extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -73,8 +73,6 @@ export default class WorkerAlias extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -45,8 +45,6 @@ export default class WorkerDelete extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -5,7 +5,6 @@ import chalk from 'chalk';
|
||||
import fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import { getHostingDeploymentsUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
@@ -45,7 +44,7 @@ interface DeployFlags {
|
||||
json: boolean;
|
||||
isProduction: boolean;
|
||||
aliasName?: string;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
deploymentIdentifier?: string;
|
||||
exportDir: string;
|
||||
dryRun: boolean;
|
||||
@@ -53,7 +52,7 @@ interface DeployFlags {
|
||||
|
||||
interface RawDeployFlags {
|
||||
'non-interactive': boolean;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
json: boolean;
|
||||
prod: boolean;
|
||||
alias?: string;
|
||||
@@ -121,8 +120,6 @@ export default class WorkerDeploy extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
+12
-15
@@ -1,7 +1,7 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
@@ -103,7 +103,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -140,7 +140,7 @@ describe(EnvCreate, () => {
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
},
|
||||
]);
|
||||
@@ -168,7 +168,7 @@ describe(EnvCreate, () => {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
});
|
||||
});
|
||||
@@ -201,7 +201,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -236,7 +236,7 @@ describe(EnvCreate, () => {
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: 'otherId',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
@@ -274,7 +274,7 @@ describe(EnvCreate, () => {
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
@@ -285,7 +285,7 @@ describe(EnvCreate, () => {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
});
|
||||
});
|
||||
@@ -320,10 +320,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [
|
||||
EnvironmentVariableEnvironment.Production,
|
||||
EnvironmentVariableEnvironment.Development,
|
||||
],
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Development],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -343,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({
|
||||
@@ -361,7 +358,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
@@ -400,7 +397,7 @@ describe(EnvCreate, () => {
|
||||
{
|
||||
name: 'TEST_VAR',
|
||||
value: 'test-value',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
@@ -37,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);
|
||||
@@ -129,7 +129,7 @@ describe(EnvDelete, () => {
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -156,7 +156,7 @@ describe(EnvDelete, () => {
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
environment: EnvironmentVariableEnvironment.Development,
|
||||
environment: DefaultEnvironment.Development,
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-7
@@ -1,6 +1,6 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ describe(EnvExec, () => {
|
||||
id: 'var1',
|
||||
name: 'EXPO_PUBLIC_API_URL',
|
||||
value: 'https://api.example.com',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -58,13 +58,13 @@ describe(EnvExec, () => {
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: EnvironmentVariableEnvironment.Development,
|
||||
environment: DefaultEnvironment.Development,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid environment when using positional argument', async () => {
|
||||
const command = new EnvExec(['invalid', 'echo test'], mockConfig);
|
||||
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({
|
||||
@@ -72,8 +72,14 @@ describe(EnvExec, () => {
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: 'custom-environment',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
@@ -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,
|
||||
@@ -135,7 +135,7 @@ describe(EnvGet, () => {
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: EnvironmentVariableEnvironment.Development,
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['TEST_VAR_1'],
|
||||
includeFileContent: true,
|
||||
}
|
||||
|
||||
+23
-29
@@ -1,7 +1,7 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
@@ -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,7 +82,7 @@ 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'));
|
||||
@@ -110,7 +104,7 @@ describe(EnvList, () => {
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: EnvironmentVariableEnvironment.Development,
|
||||
environment: DefaultEnvironment.Development,
|
||||
includeFileContent: false,
|
||||
});
|
||||
});
|
||||
@@ -133,7 +127,7 @@ describe(EnvList, () => {
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: undefined,
|
||||
environment: DefaultEnvironment.Production,
|
||||
includeFileContent: false,
|
||||
}
|
||||
);
|
||||
@@ -155,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'));
|
||||
@@ -178,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'));
|
||||
|
||||
+17
-10
@@ -2,7 +2,7 @@ import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ describe(EnvPull, () => {
|
||||
id: 'var1',
|
||||
name: 'EXPO_PUBLIC_API_URL',
|
||||
value: 'https://api.example.com',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -45,7 +45,7 @@ describe(EnvPull, () => {
|
||||
id: 'var2',
|
||||
name: 'DATABASE_URL',
|
||||
value: 'postgres://localhost:5432/mydb',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -56,7 +56,7 @@ describe(EnvPull, () => {
|
||||
id: 'var3',
|
||||
name: 'SECRET_KEY',
|
||||
value: 'super-secret-key',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -67,7 +67,7 @@ describe(EnvPull, () => {
|
||||
id: 'var4',
|
||||
name: 'CONFIG_FILE',
|
||||
value: 'base64-encoded-file-content',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -95,7 +95,7 @@ describe(EnvPull, () => {
|
||||
});
|
||||
|
||||
describe('environment validation', () => {
|
||||
it('accepts development environment (case insensitive)', async () => {
|
||||
it('accepts development environment', async () => {
|
||||
const command = new EnvPull(['development'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
@@ -117,8 +117,8 @@ describe(EnvPull, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid environment', async () => {
|
||||
const command = new EnvPull(['invalid'], mockConfig);
|
||||
it('accepts custom environment', async () => {
|
||||
const command = new EnvPull(['custom-environment'], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -127,8 +127,15 @@ describe(EnvPull, () => {
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: testProjectId,
|
||||
environment: 'custom-environment',
|
||||
includeFileContent: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+209
-20
@@ -1,7 +1,7 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
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;
|
||||
@@ -26,12 +28,14 @@ describe(EnvPush, () => {
|
||||
const testEnvPath = '.env.test';
|
||||
|
||||
const mockEnvContent = `EXPO_PUBLIC_API_URL=https://api.example.com
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
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));
|
||||
@@ -39,17 +43,10 @@ SECRET_KEY=super-secret-key`;
|
||||
|
||||
// Mock GraphQL queries and mutations
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); // No existing variables
|
||||
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
|
||||
id: 'var1',
|
||||
name: 'EXPO_PUBLIC_API_URL',
|
||||
value: 'https://api.example.com',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync)
|
||||
.mockResolvedValue(true);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
@@ -68,13 +65,13 @@ SECRET_KEY=super-secret-key`;
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
environment: EnvironmentVariableEnvironment.Development,
|
||||
filterNames: ['EXPO_PUBLIC_API_URL', 'DATABASE_URL', 'SECRET_KEY'],
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['EXPO_PUBLIC_API_URL', 'VARIABLE_NAME', 'SECRET_KEY'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid environment when using positional argument', async () => {
|
||||
const command = new EnvPush(['invalid', '--path', testEnvPath], mockConfig);
|
||||
it('accepts custom environment', async () => {
|
||||
const command = new EnvPush(['custom-environment', '--path', testEnvPath], mockConfig);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
@@ -83,8 +80,200 @@ SECRET_KEY=super-secret-key`;
|
||||
projectDir: testProjectDir,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+300
-9
@@ -1,8 +1,9 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
@@ -12,14 +13,21 @@ 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');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe(EnvUpdate, () => {
|
||||
const projectId = 'test-project-id';
|
||||
@@ -34,6 +42,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 () => {
|
||||
@@ -42,7 +85,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -74,7 +117,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'NEW_VARIABLE',
|
||||
value: 'new-value',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
});
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
@@ -88,7 +131,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -120,7 +163,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.`
|
||||
@@ -130,6 +173,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]);
|
||||
@@ -141,7 +185,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 })
|
||||
@@ -169,7 +213,7 @@ describe(EnvUpdate, () => {
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
@@ -205,8 +249,255 @@ describe(EnvUpdate, () => {
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
environment: EnvironmentVariableEnvironment.Development,
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['TEST_VAR_1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('processes file type variable in non-interactive mode with --type file and --value', async () => {
|
||||
const testFilePath = '/path/to/test-file.json';
|
||||
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
|
||||
const testFileName = 'test-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
const mockUpdatedVariable = {
|
||||
...mockVariable,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
|
||||
|
||||
// Mock fs.pathExists to return true
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
|
||||
// Mock fs.readFile to return base64 encoded content
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--type',
|
||||
'file',
|
||||
'--value',
|
||||
testFilePath,
|
||||
'--environment',
|
||||
'production',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
// Verify file was checked for existence
|
||||
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
|
||||
// Verify file was read as base64
|
||||
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
|
||||
|
||||
// Verify update was called with FileBase64 type and base64-encoded value
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: variableId,
|
||||
value: testFileBase64,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
fileName: testFileName,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when updating FILE type variable with value that is not a valid file path', async () => {
|
||||
const plainStringValue = 'some-new-value';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
|
||||
// Mock fs.pathExists to return false (file doesn't exist)
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--value',
|
||||
plainStringValue,
|
||||
'--environment',
|
||||
'production',
|
||||
// Note: --type is NOT specified
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
|
||||
// Should throw error when FILE type variable is updated with non-file value
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`Variable "TEST_VARIABLE" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when --type file is explicitly specified but file does not exist', async () => {
|
||||
const nonExistentFilePath = '/path/to/nonexistent-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
|
||||
// Mock fs.pathExists to return false (file doesn't exist)
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--type',
|
||||
'file',
|
||||
'--value',
|
||||
nonExistentFilePath,
|
||||
'--environment',
|
||||
'production',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
|
||||
// Should throw error when --type file is explicitly specified
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`File "${nonExistentFilePath}" does not exist`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing STRING type when updating value without --type flag', async () => {
|
||||
const stringValue = 'my-best-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'MY_VAR',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.String,
|
||||
value: 'old-value',
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
};
|
||||
|
||||
const mockUpdatedVariable = {
|
||||
...mockVariable,
|
||||
value: stringValue,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'MY_VAR',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--value',
|
||||
stringValue,
|
||||
// Note: --type is NOT specified
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
// Verify update was called with preserved STRING type
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: variableId,
|
||||
value: stringValue,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing FILE type when updating value without --type flag', async () => {
|
||||
const testFilePath = '/path/to/test-file.json';
|
||||
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
|
||||
const testFileName = 'test-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'MY_FILE_VAR',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
value: 'old-base64-value',
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
};
|
||||
|
||||
const mockUpdatedVariable = {
|
||||
...mockVariable,
|
||||
value: testFileBase64,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
|
||||
|
||||
// Mock fs.pathExists to return true
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
|
||||
// Mock fs.readFile to return base64 encoded content
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'MY_FILE_VAR',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--value',
|
||||
testFilePath,
|
||||
// Note: --type is NOT specified
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
// Verify file was checked for existence
|
||||
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
|
||||
// Verify file was read as base64
|
||||
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
|
||||
|
||||
// Verify update was called with preserved FILE type
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: variableId,
|
||||
value: testFileBase64,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
fileName: testFileName,
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
-24
@@ -3,8 +3,8 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../utils/prompts';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
interface RawCreateFlags {
|
||||
name?: string;
|
||||
@@ -42,7 +41,7 @@ interface RawCreateFlags {
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ interface CreateFlags {
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EnvironmentVariableScope;
|
||||
environment?: EnvironmentVariableEnvironment[];
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
}
|
||||
|
||||
@@ -64,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,
|
||||
},
|
||||
];
|
||||
@@ -101,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,
|
||||
@@ -111,14 +117,7 @@ export default class EnvCreate extends EasCommand {
|
||||
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),
|
||||
@@ -272,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'> & {
|
||||
@@ -323,18 +323,16 @@ export default class EnvCreate extends EasCommand {
|
||||
|
||||
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
|
||||
|
||||
if (environment && !isEnvironment(environment.toLowerCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
let newEnvironments = environments
|
||||
? environments
|
||||
: environment
|
||||
? [environment.toLowerCase() 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');
|
||||
|
||||
+5
-12
@@ -2,7 +2,6 @@ import { Flags } from '@oclif/core';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
@@ -15,18 +14,18 @@ import { EnvironmentVariableMutation } from '../../graphql/mutations/Environment
|
||||
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;
|
||||
}
|
||||
@@ -38,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',
|
||||
}),
|
||||
@@ -50,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,
|
||||
},
|
||||
];
|
||||
@@ -166,12 +165,6 @@ export default class EnvDelete extends EasCommand {
|
||||
: EnvironmentVariableScope.Project;
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toLowerCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return { ...flags, 'variable-environment': environment, scope };
|
||||
}
|
||||
|
||||
|
||||
+9
-13
@@ -1,24 +1,22 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
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?.toLowerCase();
|
||||
|
||||
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 =
|
||||
|
||||
+9
-14
@@ -1,7 +1,6 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +107,7 @@ export default class EnvGet extends EasCommand {
|
||||
let variable;
|
||||
|
||||
if (variables.length > 1) {
|
||||
const variableInEnvironment = variables.find(v => v.environments?.includes(environment!));
|
||||
const variableInEnvironment = variables.find(v => v.environments?.includes(environment));
|
||||
if (!variableInEnvironment) {
|
||||
throw new Error(
|
||||
`Variable with name "${name}" not found in environment "${environment.toLocaleLowerCase()}"`
|
||||
@@ -156,12 +157,6 @@ export default class EnvGet extends EasCommand {
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project;
|
||||
if (environment) {
|
||||
environment = environment.toLowerCase();
|
||||
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(
|
||||
|
||||
+11
-12
@@ -1,7 +1,6 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
@@ -20,7 +19,6 @@ import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import {
|
||||
formatVariable,
|
||||
formatVariableValue,
|
||||
isEnvironment,
|
||||
performForEnvironmentsAsync,
|
||||
} from '../../utils/variableUtils';
|
||||
|
||||
@@ -36,7 +34,7 @@ async function getVariablesForScopeAsync(
|
||||
scope: EnvironmentVariableScope;
|
||||
includingSensitive: boolean;
|
||||
includeFileContent: boolean;
|
||||
environment?: EnvironmentVariableEnvironment;
|
||||
environment?: string;
|
||||
projectId: string;
|
||||
}
|
||||
): Promise<EnvironmentVariableWithFileContent[]> {
|
||||
@@ -71,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;
|
||||
@@ -80,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;
|
||||
@@ -112,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,
|
||||
},
|
||||
];
|
||||
@@ -137,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 => {
|
||||
@@ -180,14 +183,10 @@ export default class EnvList extends EasCommand {
|
||||
flags: RawListFlags,
|
||||
{ environment }: { environment?: string }
|
||||
): ListFlags {
|
||||
if (environment && !isEnvironment(environment.toLowerCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments = flags.environment
|
||||
? flags.environment
|
||||
: environment
|
||||
? [environment.toLowerCase() as EnvironmentVariableEnvironment]
|
||||
? [environment]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
+9
-10
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -51,14 +50,6 @@ export default class EnvPull extends EasCommand {
|
||||
|
||||
let environment = flagEnvironment?.toLowerCase() ?? argEnvironment?.toLowerCase();
|
||||
|
||||
if (!environment) {
|
||||
environment = await promptVariableEnvironmentAsync({ nonInteractive });
|
||||
}
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -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(
|
||||
|
||||
+56
-50
@@ -3,7 +3,6 @@ import dotenv from 'dotenv';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
@@ -17,7 +16,6 @@ import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVari
|
||||
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 =
|
||||
@@ -34,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,
|
||||
},
|
||||
];
|
||||
@@ -48,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,
|
||||
@@ -61,6 +63,9 @@ export default class EnvPush extends EasCommand {
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive: false,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,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) {
|
||||
@@ -165,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}`
|
||||
);
|
||||
@@ -177,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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,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.toLowerCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments =
|
||||
flags.environment ??
|
||||
(environment ? [environment.toLowerCase() as EnvironmentVariableEnvironment] : undefined);
|
||||
): { environment?: string[]; path: string; force: boolean } {
|
||||
const environments = flags.environment ?? (environment ? [environment] : undefined);
|
||||
|
||||
return {
|
||||
...flags,
|
||||
@@ -215,13 +226,8 @@ export default class EnvPush extends EasCommand {
|
||||
|
||||
private async parseEnvFileAsync(
|
||||
envPath: string,
|
||||
environments: EnvironmentVariableEnvironment[]
|
||||
): Promise<
|
||||
Record<
|
||||
string,
|
||||
CreateEnvironmentVariableInput & { environments: EnvironmentVariableEnvironment[] }
|
||||
>
|
||||
> {
|
||||
environments: string[]
|
||||
): Promise<Record<string, CreateEnvironmentVariableInput & { environments: string[] }>> {
|
||||
if (!(await fs.exists(envPath))) {
|
||||
throw new Error(`File ${envPath} does not exist.`);
|
||||
}
|
||||
|
||||
+46
-36
@@ -4,8 +4,8 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
@@ -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.toLowerCase();
|
||||
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;
|
||||
@@ -300,23 +299,14 @@ export default class EnvUpdate extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
let environmentFilePath: string | undefined;
|
||||
|
||||
if ((newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64 && value) {
|
||||
environmentFilePath = path.resolve(value);
|
||||
if (!(await fs.pathExists(environmentFilePath))) {
|
||||
throw new Error(`File "${value}" does not exist`);
|
||||
}
|
||||
fileName = path.basename(environmentFilePath);
|
||||
}
|
||||
|
||||
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
|
||||
|
||||
if (!environments || environments.length === 0) {
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
selectedEnvironments: selectedVariable.environments ?? [],
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -340,6 +330,26 @@ export default class EnvUpdate extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// If value is provided but type is not explicitly set, preserve the existing type
|
||||
if (value && !newType) {
|
||||
newType = selectedVariable.type;
|
||||
}
|
||||
|
||||
if (newType === EnvironmentSecretType.FileBase64 && value) {
|
||||
const environmentFilePath = path.resolve(value);
|
||||
if (!(await fs.pathExists(environmentFilePath))) {
|
||||
if (type === 'file') {
|
||||
throw new Error(`File "${value}" does not exist`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Variable "${selectedVariable.name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
}
|
||||
}
|
||||
fileName = path.basename(environmentFilePath);
|
||||
value = await fs.readFile(environmentFilePath, 'base64');
|
||||
}
|
||||
|
||||
if (visibility) {
|
||||
newVisibility = parseVisibility(visibility);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import chalk from 'chalk';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds';
|
||||
import { GetServerSideEnvironmentVariablesFn } from '../../commandUtils/context/ServerSideEnvironmentVariablesContextField';
|
||||
@@ -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.',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Flags } from '@oclif/core';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEnvVarsAsync';
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EASEnvironmentFlag,
|
||||
@@ -40,7 +39,7 @@ export default class FingerprintGenerate extends EasCommand {
|
||||
options: ['android', 'ios'],
|
||||
}),
|
||||
...EASEnvironmentFlag,
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>({
|
||||
environment: Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
exclusive: ['build-profile'],
|
||||
}),
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
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 { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
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';
|
||||
import New, { createProjectAsync, generateConfigsAsync } from '../new';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('fs-extra', () => ({
|
||||
...jest.requireActual('fs-extra'),
|
||||
pathExists: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('../../../prompts', () => ({
|
||||
promptAsync: jest.fn(),
|
||||
}));
|
||||
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(),
|
||||
getActorUsername: jest.fn().mockReturnValue('jester'),
|
||||
}));
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
@@ -53,662 +33,89 @@ jest.mock('../../../build/utils/url');
|
||||
jest.mock('../../../utils/easCli', () => ({
|
||||
easCliVersion: '5.0.0',
|
||||
}));
|
||||
// Remove Log module mocking entirely - use the actual module
|
||||
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync', () => ({
|
||||
findProjectIdByAccountNameAndSlugNullableAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../../graphql/mutations/AppMutation', () => ({
|
||||
AppMutation: {
|
||||
createAppAsync: jest.fn().mockResolvedValue('test-project-id'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../project/expoConfig', () => ({
|
||||
getPrivateExpoConfigAsync: jest.fn().mockResolvedValue({ extra: {} }),
|
||||
createOrModifyExpoConfigAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../../prompts', () => ({
|
||||
...jest.requireActual('../../../prompts'),
|
||||
promptAsync: jest.fn().mockImplementation(async (options: any) => {
|
||||
if (options.name === 'account') {
|
||||
return { account: { name: 'jester' } };
|
||||
}
|
||||
if (options.name === 'name') {
|
||||
return { name: options.initial || 'expo-project' };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(New.name, () => {
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
describe('generateConfigsAsync', () => {
|
||||
it('should orchestrate account selection and project configuration', async () => {
|
||||
const result = await generateConfigsAsync({ path: undefined }, jester, mockGraphqlClient);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
expect(result).toEqual({
|
||||
projectAccount: 'jester',
|
||||
projectDirectory: expect.stringContaining('/expo-project'),
|
||||
projectName: 'expo-project',
|
||||
});
|
||||
});
|
||||
|
||||
// Capture console.log calls
|
||||
consoleLogSpy = jest.spyOn(console, 'log');
|
||||
it('should pass through path argument to config generation', async () => {
|
||||
const customPath = 'my-custom-project';
|
||||
|
||||
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(''));
|
||||
const result = await generateConfigsAsync({ path: customPath }, jester, mockGraphqlClient);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectAccount: 'jester',
|
||||
projectDirectory: expect.stringContaining('/my-custom-project'),
|
||||
projectName: 'my-custom-project',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
describe('createProjectAsync', () => {
|
||||
it('should create a project', async () => {
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('test-project-id');
|
||||
jest
|
||||
.mocked(getPrivateExpoConfigAsync)
|
||||
.mockResolvedValue({ name: 'name', slug: 'slug' } as ExpoConfig);
|
||||
jest.mocked(createOrModifyExpoConfigAsync);
|
||||
|
||||
// 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);
|
||||
const result = await createProjectAsync({
|
||||
graphqlClient: mockGraphqlClient,
|
||||
actor: jester,
|
||||
projectDirectory: 'test-project',
|
||||
projectAccount: 'jester',
|
||||
projectName: 'test',
|
||||
});
|
||||
|
||||
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);
|
||||
expect(result).toBe('test-project-id');
|
||||
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
accountId: jester.accounts[0].id,
|
||||
projectName: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
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: [],
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
|
||||
'test-project',
|
||||
{
|
||||
extra: { eas: { projectId: 'test-project-id' } },
|
||||
},
|
||||
...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');
|
||||
});
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { validSlugName, validateFullNameAndSlug } from '../../commandUtils/projectNameValidation';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
@@ -276,8 +277,9 @@ export default class ProjectInit extends EasCommand {
|
||||
throw new Error('No account selected for project. Canceling.');
|
||||
}
|
||||
|
||||
const projectName = exp.slug;
|
||||
const projectName = validSlugName(exp.slug); // This filters out invalid characters
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
validateFullNameAndSlug(projectFullName, projectName);
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
|
||||
@@ -1,178 +1,76 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import { Flags } from '@oclif/core';
|
||||
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 {
|
||||
cloneTemplateAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../../commandUtils/new/commands';
|
||||
import {
|
||||
generateProjectConfigAsync,
|
||||
promptForProjectAccountAsync,
|
||||
} from '../../commandUtils/new/configs';
|
||||
import {
|
||||
copyProjectTemplatesAsync,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
updatePackageJsonAsync,
|
||||
updateReadmeAsync,
|
||||
} from '../../commandUtils/new/projectFiles';
|
||||
import { printDirectory } from '../../commandUtils/new/utils';
|
||||
import { AppFragment } 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 { PACKAGE_MANAGERS, PackageManager } from '../../onboarding/installDependencies';
|
||||
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';
|
||||
import { Actor } from '../../user/User';
|
||||
|
||||
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(
|
||||
export async function generateConfigsAsync(
|
||||
args: { path?: string },
|
||||
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: ExpoGraphqlClient
|
||||
): Promise<{
|
||||
projectName: string;
|
||||
projectDirectory: string;
|
||||
projectAccount: string;
|
||||
}> {
|
||||
const projectAccount = await promptForProjectAccountAsync(actor);
|
||||
const { projectName, projectDirectory } = await generateProjectConfigAsync(args.path, {
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
projectAccount,
|
||||
});
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
projectAccount,
|
||||
projectDirectory,
|
||||
projectName,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
export async function createProjectAsync({
|
||||
graphqlClient,
|
||||
actor,
|
||||
projectDirectory,
|
||||
projectAccount,
|
||||
projectName,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
actor: Actor;
|
||||
projectDirectory: string;
|
||||
projectAccount: string;
|
||||
projectName: string;
|
||||
}): Promise<string> {
|
||||
const projectFullName = `@${projectAccount}/${projectName}`;
|
||||
const projectDashboardUrl = getProjectDashboardUrl(projectAccount, projectName);
|
||||
const projectLink = link(projectDashboardUrl, { text: projectFullName });
|
||||
|
||||
const account = nullthrows(allAccounts.find(a => a.name === accountName));
|
||||
const account = nullthrows(actor.accounts.find(a => a.name === projectAccount));
|
||||
|
||||
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
|
||||
let projectId: string;
|
||||
@@ -187,27 +85,25 @@ export async function createProjectAsync(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true });
|
||||
const exp = await getPrivateExpoConfigAsync(projectDirectory, { skipPlugins: true });
|
||||
await createOrModifyExpoConfigAsync(
|
||||
projectDir,
|
||||
projectDirectory,
|
||||
{
|
||||
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
|
||||
},
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
|
||||
Log.withInfo(`Project successfully linked (ID: ${chalk.bold(projectId)})`);
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
export function stripInvalidCharactersForBundleIdentifier(string: string): string {
|
||||
return string.replaceAll(/[^A-Za-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export async function generateConfigFilesAsync(
|
||||
export async function generateProjectFilesAsync(
|
||||
projectDir: string,
|
||||
app: AppFragment
|
||||
app: AppFragment,
|
||||
packageManager: PackageManager
|
||||
): Promise<void> {
|
||||
const spinner = ora(`Generating project files`).start();
|
||||
await generateAppConfigAsync(projectDir, app);
|
||||
|
||||
await generateEasConfigAsync(projectDir);
|
||||
@@ -216,206 +112,49 @@ export async function generateConfigFilesAsync(
|
||||
|
||||
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(
|
||||
await updateReadmeAsync(projectDir, packageManager);
|
||||
spinner.succeed(`Generated project files`);
|
||||
Log.withInfo(
|
||||
`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(
|
||||
Log.withInfo(
|
||||
`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 description =
|
||||
'Create a new project configured with Expo Application Services (EAS)';
|
||||
|
||||
static override flags = {};
|
||||
static override args = [
|
||||
{
|
||||
name: 'path',
|
||||
description: 'Path to create the project (defaults to current directory)',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
static override hidden = true;
|
||||
|
||||
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
|
||||
static override flags = {
|
||||
'package-manager': Flags.enum<PackageManager>({
|
||||
char: 'p',
|
||||
description: 'Package manager to use for installing dependencies',
|
||||
options: [...PACKAGE_MANAGERS],
|
||||
default: 'npm',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args } = await this.parse(New);
|
||||
const { args, flags } = await this.parse(New);
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
@@ -427,46 +166,48 @@ export default class New extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
projectName,
|
||||
projectDirectory: targetProjectDirectory,
|
||||
projectAccount,
|
||||
} = await generateConfigsAsync(args, actor, graphqlClient);
|
||||
|
||||
const projectDirectory = await cloneTemplateAsync(targetProjectDirectory);
|
||||
|
||||
const packageManager = await installProjectDependenciesAsync(projectDirectory);
|
||||
const packageManager = flags['package-manager'];
|
||||
await installProjectDependenciesAsync(projectDirectory, packageManager);
|
||||
|
||||
const projectId = await createProjectAsync(graphqlClient, actor, projectDirectory);
|
||||
const projectId = await createProjectAsync({
|
||||
projectDirectory,
|
||||
projectAccount,
|
||||
projectName,
|
||||
actor,
|
||||
graphqlClient,
|
||||
});
|
||||
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
await generateConfigFilesAsync(projectDirectory, app);
|
||||
await generateProjectFilesAsync(projectDirectory, app, packageManager);
|
||||
|
||||
await initializeGitRepositoryAsync(projectDirectory);
|
||||
|
||||
Log.log('🎉 We finished creating your new project.');
|
||||
Log.newLine();
|
||||
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(
|
||||
`Run ${chalk.bold(`cd ${printDirectory(projectDirectory)}`)} to navigate to your project.`
|
||||
);
|
||||
Log.withInfo(
|
||||
`Run ${chalk.bold(`${packageManager} run draft`)} to create a preview on EAS. ${learnMore(
|
||||
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
|
||||
)}`
|
||||
);
|
||||
Log.withInfo(
|
||||
`Run \`${formatScriptCommand(
|
||||
'start',
|
||||
packageManager
|
||||
)}\` to start developing locally. ${learnMore(
|
||||
`Run ${chalk.bold(`${packageManager} run start`)} 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();
|
||||
Log.withInfo(`See the ${chalk.bold('README.md')} for more information about your project.`);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import UpdatePublish from '..';
|
||||
import UpdatePublish, { preprocessSourceMapsArg } from '..';
|
||||
import { ensureBranchExistsAsync } from '../../../branch/queries';
|
||||
import {
|
||||
DynamicPrivateProjectConfigContextField,
|
||||
@@ -328,3 +328,34 @@ function mockTestExport({
|
||||
|
||||
return { inputDir: exportDir, platforms, runtimeVersion };
|
||||
}
|
||||
|
||||
describe(preprocessSourceMapsArg, () => {
|
||||
it('passes through when --source-maps has a value', () => {
|
||||
const argv = ['--source-maps', 'inline', '--message', 'test'];
|
||||
expect(preprocessSourceMapsArg(argv)).toEqual(argv);
|
||||
});
|
||||
|
||||
it('inserts "true" when --source-maps has no value', () => {
|
||||
// At end of args
|
||||
expect(preprocessSourceMapsArg(['--branch', 'main', '--source-maps'])).toEqual([
|
||||
'--branch',
|
||||
'main',
|
||||
'--source-maps',
|
||||
'true',
|
||||
]);
|
||||
// Followed by another flag
|
||||
expect(preprocessSourceMapsArg(['--source-maps', '--message', 'test'])).toEqual([
|
||||
'--source-maps',
|
||||
'true',
|
||||
'--message',
|
||||
'test',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden flags', () => {
|
||||
it('has --no-bytecode and --source-maps as hidden flags', () => {
|
||||
expect(UpdatePublish.flags['no-bytecode'].hidden).toBe(true);
|
||||
expect(UpdatePublish.flags['source-maps'].hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export default class UpdateConfigure extends EasCommand {
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(UpdateConfigure, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
withServerSideEnvironment: flags['environment'],
|
||||
withServerSideEnvironment: flags['environment'] ?? null,
|
||||
});
|
||||
|
||||
Log.log(
|
||||
|
||||
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
|
||||
return await PublishMutation.setRolloutPercentageAsync(
|
||||
graphqlClient,
|
||||
u.updateId,
|
||||
rolloutPercentageToSet!
|
||||
rolloutPercentageToSet
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
// import { getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { ensureBranchExistsAsync } from '../../branch/queries';
|
||||
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import { getUpdateGroupUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
UpdateRolloutInfoGroup,
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
// import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
@@ -32,12 +29,10 @@ import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprin
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
RawAsset,
|
||||
UpdatePublishPlatform,
|
||||
buildBundlesAsync,
|
||||
buildUnsortedUpdateInfoGroupAsync,
|
||||
collectAssetsAsync,
|
||||
filterCollectedAssetsByRequestedPlatforms,
|
||||
// findCompatibleBuildsAsync,
|
||||
generateEasMetadataAsync,
|
||||
getBranchNameForCommandAsync,
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
|
||||
@@ -52,7 +47,12 @@ import {
|
||||
} from '../../project/publish';
|
||||
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
|
||||
import {
|
||||
UpdatePublishPlatform,
|
||||
getUpdateJsonInfosForUpdates,
|
||||
isBundleDiffingEnabled,
|
||||
prewarmDiffingAsync,
|
||||
} from '../../update/utils';
|
||||
import {
|
||||
checkManifestBodyAgainstUpdateInfoGroup,
|
||||
getCodeSigningInfoAsync,
|
||||
@@ -65,6 +65,28 @@ import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
/**
|
||||
* Preprocess argv to handle --source-maps with optional value.
|
||||
* If --source-maps is followed by another flag (starts with -) or end of args,
|
||||
* insert 'true' as the default value.
|
||||
*/
|
||||
export function preprocessSourceMapsArg(argv: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
result.push(arg);
|
||||
|
||||
if (arg === '--source-maps') {
|
||||
const nextArg = argv[i + 1];
|
||||
// If no next arg or next arg is a flag, insert 'true' as the value
|
||||
if (nextArg === undefined || nextArg.startsWith('-')) {
|
||||
result.push('true');
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
type RawUpdateFlags = {
|
||||
auto: boolean;
|
||||
branch?: string;
|
||||
@@ -74,12 +96,14 @@ type RawUpdateFlags = {
|
||||
'input-dir': string;
|
||||
'skip-bundler': boolean;
|
||||
'clear-cache': boolean;
|
||||
'no-bytecode': boolean;
|
||||
'source-maps'?: string;
|
||||
'private-key-path'?: string;
|
||||
'emit-metadata': boolean;
|
||||
'rollout-percentage'?: number;
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
environment: EnvironmentVariableEnvironment | null;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
type UpdateFlags = {
|
||||
@@ -91,12 +115,14 @@ type UpdateFlags = {
|
||||
inputDir: string;
|
||||
skipBundler: boolean;
|
||||
clearCache: boolean;
|
||||
noBytecode: boolean;
|
||||
sourceMaps?: string;
|
||||
privateKeyPath?: string;
|
||||
emitMetadata: boolean;
|
||||
rolloutPercentage?: number;
|
||||
json: boolean;
|
||||
nonInteractive: boolean;
|
||||
environment: EnvironmentVariableEnvironment | null;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
export default class UpdatePublish extends EasCommand {
|
||||
@@ -129,6 +155,16 @@ export default class UpdatePublish extends EasCommand {
|
||||
description: `Clear the bundler cache before publishing`,
|
||||
default: false,
|
||||
}),
|
||||
'no-bytecode': Flags.boolean({
|
||||
description: `Skip generating Hermes bytecode (output plain JavaScript instead)`,
|
||||
default: false,
|
||||
hidden: true,
|
||||
}),
|
||||
'source-maps': Flags.string({
|
||||
description: `Emit source maps. Options: true (default), inline, false`,
|
||||
default: 'true',
|
||||
hidden: true,
|
||||
}),
|
||||
'emit-metadata': Flags.boolean({
|
||||
description: `Emit "eas-update-metadata.json" in the bundle folder with detailed information about the generated updates`,
|
||||
default: false,
|
||||
@@ -166,7 +202,9 @@ export default class UpdatePublish extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags: rawFlags } = await this.parse(UpdatePublish);
|
||||
// Preprocess argv to handle --source-maps with optional value
|
||||
const preprocessedArgv = preprocessSourceMapsArg(this.argv);
|
||||
const { flags: rawFlags } = await this.parse(UpdatePublish, preprocessedArgv);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
|
||||
const {
|
||||
auto: autoFlag,
|
||||
@@ -176,6 +214,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
inputDir,
|
||||
skipBundler,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
privateKeyPath,
|
||||
json: jsonFlag,
|
||||
nonInteractive,
|
||||
@@ -193,7 +233,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
getServerSideEnvironmentVariablesAsync,
|
||||
} = await this.getContextAsync(UpdatePublish, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: environment,
|
||||
withServerSideEnvironment: environment ?? null,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
@@ -261,6 +301,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
exp,
|
||||
platformFlag: requestedPlatform,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
extraEnv: maybeServerEnv,
|
||||
});
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
@@ -572,6 +614,10 @@ export default class UpdatePublish extends EasCommand {
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (isBundleDiffingEnabled(exp)) {
|
||||
await prewarmDiffingAsync(graphqlClient, projectId, newUpdates);
|
||||
}
|
||||
|
||||
if (!skipBundler && emitMetadata) {
|
||||
Log.log('Generating eas-update-metadata.json');
|
||||
await generateEasMetadataAsync(distRoot, getUpdateJsonInfosForUpdates(newUpdates));
|
||||
@@ -590,12 +636,6 @@ export default class UpdatePublish extends EasCommand {
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
// const runtimeToCompatibleBuilds = await Promise.all(
|
||||
// runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(obj =>
|
||||
// findCompatibleBuildsAsync(graphqlClient, projectId, obj)
|
||||
// )
|
||||
// );
|
||||
|
||||
for (const runtime of uniqBy(
|
||||
runtimeToPlatformsAndFingerprintInfoMapping,
|
||||
version => version.runtimeVersion
|
||||
@@ -667,58 +707,9 @@ export default class UpdatePublish extends EasCommand {
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
}
|
||||
|
||||
// NOTE(brentvatne): temporarily disable logging this until we can revisit the formatting
|
||||
// and the logic for it - it's a bit too aggressive right now, and warns even if you're
|
||||
// not using EAS Build
|
||||
//
|
||||
// const fingerprintsWithoutCompatibleBuilds = runtimeToCompatibleBuilds.find(
|
||||
// ({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion
|
||||
// )?.fingerprintInfoGroupWithCompatibleBuilds;
|
||||
// if (fingerprintsWithoutCompatibleBuilds) {
|
||||
// const missingBuilds = Object.entries(fingerprintsWithoutCompatibleBuilds).filter(
|
||||
// ([_platform, fingerprintInfo]) => !fingerprintInfo.build
|
||||
// );
|
||||
// if (missingBuilds.length > 0) {
|
||||
// const project = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
// Log.warn('No compatible builds found for the following fingerprints:');
|
||||
// for (const [platform, fingerprintInfo] of missingBuilds) {
|
||||
// const fingerprintUrl = new URL(
|
||||
// `/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprintInfo.fingerprintHash}`,
|
||||
// getExpoWebsiteBaseUrl()
|
||||
// );
|
||||
// Log.warn(
|
||||
// formatFields(
|
||||
// [
|
||||
// {
|
||||
// label: `${this.prettyPlatform(platform)} fingerprint`,
|
||||
// value: fingerprintInfo.fingerprintHash,
|
||||
// },
|
||||
// { label: 'URL', value: fingerprintUrl.toString() },
|
||||
// ],
|
||||
// {
|
||||
// labelFormat: label => ` ${chalk.dim(label)}:`,
|
||||
// }
|
||||
// )
|
||||
// );
|
||||
// Log.addNewLineIfNone();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// private prettyPlatform(updatePlatform: string): string {
|
||||
// switch (updatePlatform) {
|
||||
// case 'android':
|
||||
// return 'Android';
|
||||
// case 'ios':
|
||||
// return 'iOS';
|
||||
// default:
|
||||
// return updatePlatform;
|
||||
// }
|
||||
// }
|
||||
|
||||
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
|
||||
const nonInteractive = flags['non-interactive'] ?? false;
|
||||
|
||||
@@ -748,6 +739,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
inputDir: flags['input-dir'],
|
||||
skipBundler,
|
||||
clearCache: flags['clear-cache'] ? true : !!flags['environment'],
|
||||
noBytecode: flags['no-bytecode'] ?? false,
|
||||
sourceMaps: flags['source-maps'],
|
||||
platform: flags.platform,
|
||||
privateKeyPath: flags['private-key-path'],
|
||||
rolloutPercentage: flags['rollout-percentage'],
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getLimitFlagWithCustomValues,
|
||||
getPaginatedQueryOptions,
|
||||
} from '../../commandUtils/pagination';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import {
|
||||
listAndRenderUpdateGroupsOnAppAsync,
|
||||
listAndRenderUpdateGroupsOnBranchAsync,
|
||||
@@ -27,6 +29,14 @@ export default class UpdateList extends EasCommand {
|
||||
exclusive: ['branch'],
|
||||
default: false,
|
||||
}),
|
||||
platform: Flags.enum<RequestedPlatform>({
|
||||
options: Object.values(RequestedPlatform),
|
||||
char: 'p',
|
||||
description: 'Filter updates by platform',
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Filter updates by runtime version',
|
||||
}),
|
||||
...EasPaginatedQueryFlags,
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: 25, limit: 50 }),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
@@ -39,7 +49,13 @@ export default class UpdateList extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(UpdateList);
|
||||
const { branch: branchFlag, all, json: jsonFlag, 'non-interactive': nonInteractive } = flags;
|
||||
const {
|
||||
branch: branchFlag,
|
||||
all,
|
||||
json: jsonFlag,
|
||||
'non-interactive': nonInteractive,
|
||||
platform: requestedPlatform,
|
||||
} = flags;
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -52,9 +68,16 @@ export default class UpdateList extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
// Build filter object
|
||||
const filter = {
|
||||
platform: toAppPlatform(requestedPlatform),
|
||||
runtimeVersions: flags['runtime-version'] ? [flags['runtime-version']] : undefined,
|
||||
};
|
||||
|
||||
if (all) {
|
||||
await listAndRenderUpdateGroupsOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
} else {
|
||||
@@ -62,6 +85,7 @@ export default class UpdateList extends EasCommand {
|
||||
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName: branchFlag,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
} else {
|
||||
@@ -87,9 +111,20 @@ export default class UpdateList extends EasCommand {
|
||||
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName: selectedBranch.name,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
|
||||
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
|
||||
return undefined;
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return AppPlatform.Android;
|
||||
} else {
|
||||
return AppPlatform.Ios;
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
|
||||
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
|
||||
import { getUpdateMessageForCommandAsync } from '../../project/publish';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
republishAsync,
|
||||
} from '../../update/republish';
|
||||
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
|
||||
import { UpdatePublishPlatform } from '../../update/utils';
|
||||
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
@@ -9,7 +9,6 @@ import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
UpdatePublishPlatform,
|
||||
defaultPublishPlatforms,
|
||||
getBranchNameForCommandAsync,
|
||||
getUpdateMessageForCommandAsync,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { selectRuntimeOnBranchAsync } from '../../update/queries';
|
||||
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
|
||||
import { UpdatePublishPlatform } from '../../update/utils';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { parseInputs, parseJsonInputs, parseWorkflowInputsFromYaml } from '../run';
|
||||
import {
|
||||
parseInputs,
|
||||
parseJsonInputs,
|
||||
parseWorkflowInputsFromYaml,
|
||||
} from '../../../commandUtils/workflow/inputs';
|
||||
|
||||
describe('parseInputs', () => {
|
||||
it('should parse single key=value pair', () => {
|
||||
@@ -216,7 +220,6 @@ jobs:
|
||||
it('should handle invalid YAML gracefully', () => {
|
||||
const yamlConfig = 'invalid: yaml: content:';
|
||||
|
||||
const { parseWorkflowInputsFromYaml } = require('../run');
|
||||
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
|
||||
|
||||
expect(inputs).toEqual({});
|
||||
|
||||
@@ -44,41 +44,40 @@ import spawnAsync from '@expo/spawn-async';
|
||||
import { Flags } from '@oclif/core';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
import { boolish } from 'getenv';
|
||||
import * as path from 'node:path';
|
||||
import slash from 'slash';
|
||||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
maybePromptForMissingInputsAsync,
|
||||
parseInputs,
|
||||
parseJsonInputs,
|
||||
parseWorkflowInputsFromYaml,
|
||||
} from '../../commandUtils/workflow/inputs';
|
||||
import {
|
||||
fileExistsAsync,
|
||||
maybeReadStdinAsync,
|
||||
showWorkflowStatusAsync,
|
||||
workflowRunExitCodes,
|
||||
} from '../../commandUtils/workflow/utils';
|
||||
import {
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowRevision,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
const EXIT_CODES = {
|
||||
WORKFLOW_FAILED: 11,
|
||||
WORKFLOW_CANCELED: 12,
|
||||
WAIT_ABORTED: 13,
|
||||
};
|
||||
|
||||
export default class WorkflowRun extends EasCommand {
|
||||
static override description =
|
||||
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
|
||||
@@ -91,7 +90,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
default: false,
|
||||
allowNo: true,
|
||||
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
|
||||
summary: 'Wait for workflow run to complete',
|
||||
summary: 'Wait for workflow run to complete. Defaults to false.',
|
||||
}),
|
||||
input: Flags.string({
|
||||
char: 'F',
|
||||
@@ -136,7 +135,9 @@ export default class WorkflowRun extends EasCommand {
|
||||
const {
|
||||
projectId,
|
||||
exp: { slug: projectName },
|
||||
} = await getDynamicPrivateProjectConfigAsync();
|
||||
} = await getDynamicPrivateProjectConfigAsync({
|
||||
skipPlugins: true,
|
||||
});
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let yamlConfig: string;
|
||||
@@ -358,9 +359,11 @@ export default class WorkflowRun extends EasCommand {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
const { status } = await waitForWorkflowRunToEndAsync(graphqlClient, {
|
||||
const spinnerUsesStdErr = boolish('CI', false) || flags.json;
|
||||
|
||||
const { status } = await showWorkflowStatusAsync(graphqlClient, {
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
@@ -375,316 +378,9 @@ export default class WorkflowRun extends EasCommand {
|
||||
}
|
||||
|
||||
if (status === WorkflowRunStatus.Failure) {
|
||||
process.exit(EXIT_CODES.WORKFLOW_FAILED);
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_FAILED);
|
||||
} else if (status === WorkflowRunStatus.Canceled) {
|
||||
process.exit(EXIT_CODES.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkflowRunToEndAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ workflowRunId }: { workflowRunId: string }
|
||||
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
|
||||
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
|
||||
|
||||
const spinner = ora('Currently waiting for workflow run to start.').start();
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.byIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.InProgress:
|
||||
spinner.start('Workflow run is in progress.');
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.warn('Workflow run is waiting for action.');
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.warn('Workflow run has been canceled.');
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure:
|
||||
spinner.fail('Workflow run has failed.');
|
||||
return workflowRun;
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.succeed('Workflow run completed successfully.');
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(EXIT_CODES.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(filePath, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
export function parseInputs(inputFlags: string[]): Record<string, string> {
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
for (const inputFlag of inputFlags) {
|
||||
const equalIndex = inputFlag.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
|
||||
}
|
||||
|
||||
const key = inputFlag.substring(0, equalIndex);
|
||||
const value = inputFlag.substring(equalIndex + 1);
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
|
||||
}
|
||||
|
||||
inputs[key] = value;
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export async function maybeReadStdinAsync(): Promise<string | null> {
|
||||
// Check if there's data on stdin
|
||||
if (process.stdin.isTTY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
data += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const trimmedData = data.trim();
|
||||
resolve(trimmedData || null);
|
||||
});
|
||||
|
||||
process.stdin.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseJsonInputs(jsonString: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('JSON input must be an object.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON input.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
// `z.coerce.boolean()` does `Boolean(val)` under the hood,
|
||||
// which is not what we want. See:
|
||||
// https://github.com/colinhacks/zod/issues/2985#issuecomment-2230692578
|
||||
const booleanLike = z.union([
|
||||
z.boolean(),
|
||||
z.codec(z.number(), z.boolean(), {
|
||||
decode: n => !!n,
|
||||
encode: b => (b ? 1 : 0),
|
||||
}),
|
||||
z.stringbool({ truthy: ['true', 'True'], falsy: ['false', 'False'] }),
|
||||
]);
|
||||
|
||||
const stringLike = z.codec(
|
||||
z.union([
|
||||
// We're going to coerce numbers and strings into strings.
|
||||
z.number(),
|
||||
z.string(),
|
||||
// We do not allow other primitives, like:
|
||||
// - bigints, symbols - because YAML does not support them,
|
||||
// - booleans - because YAML accepts `True` and `true` as boolean input
|
||||
// and parses both as JS `true` -- if we stringified that,
|
||||
// we would lose the capital "T" which may not be what the user expects,
|
||||
// - nulls - user should do `"null"` or not pass the property at all.
|
||||
]),
|
||||
z.string(),
|
||||
{
|
||||
decode: value => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
throw new Error(`Cannot convert ${typeof value} to string: ${value}`);
|
||||
},
|
||||
encode: value => value,
|
||||
}
|
||||
);
|
||||
|
||||
export const WorkflowDispatchInputZ = z
|
||||
.object({
|
||||
description: stringLike.optional().describe('Description of the input'),
|
||||
required: booleanLike.default(false).describe('Whether the input is required.'),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal('string').default('string'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('boolean'),
|
||||
default: booleanLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('number'),
|
||||
default: z.number().optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('choice'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('environment'),
|
||||
default: z.string().optional().describe('Default value for the input'),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
export function parseWorkflowInputsFromYaml(
|
||||
yamlConfig: string
|
||||
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
|
||||
try {
|
||||
const parsed = YAML.parse(yamlConfig);
|
||||
return z
|
||||
.record(z.string(), WorkflowDispatchInputZ)
|
||||
.default({})
|
||||
.parse(parsed?.on?.workflow_dispatch?.inputs);
|
||||
} catch (error) {
|
||||
Log.warn('Failed to parse workflow inputs from YAML:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePromptForMissingInputsAsync({
|
||||
inputSpecs,
|
||||
inputs,
|
||||
}: {
|
||||
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
|
||||
inputs: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
|
||||
|
||||
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
|
||||
|
||||
if (missingRequiredInputs.length === 0) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Some required inputs are missing. Please provide them:');
|
||||
|
||||
const nextInputs = { ...inputs };
|
||||
|
||||
for (const [key, spec] of missingRequiredInputs) {
|
||||
const value = await promptForMissingInputAsync({ key, spec });
|
||||
nextInputs[key] = value;
|
||||
}
|
||||
|
||||
return nextInputs;
|
||||
}
|
||||
|
||||
async function promptForMissingInputAsync({
|
||||
key,
|
||||
spec,
|
||||
}: {
|
||||
key: string;
|
||||
spec: z.infer<typeof WorkflowDispatchInputZ>;
|
||||
}): Promise<unknown> {
|
||||
const message = spec.description ? `${key} (${spec.description})` : key;
|
||||
|
||||
switch (spec.type) {
|
||||
case 'boolean': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'number',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: number) => {
|
||||
if (isNaN(val)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'choice': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'value',
|
||||
message,
|
||||
choices: spec.options.map(option => ({
|
||||
title: option,
|
||||
value: option,
|
||||
})),
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'string':
|
||||
case 'environment': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: string) => {
|
||||
if (spec.required && (!val || val.trim() === '')) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* EAS Workflow Status Command
|
||||
*
|
||||
* This command shows the status of an existing workflow run.
|
||||
*
|
||||
* If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.
|
||||
*
|
||||
* If the selected run is still in progress, the command will show the progress of the run, with an option
|
||||
* to show periodic status updates while waiting for completion (similar to `eas workflow:run --wait`).
|
||||
*
|
||||
*/
|
||||
|
||||
import { Flags } from '@oclif/core';
|
||||
import { boolish } from 'getenv';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
choiceFromWorkflowRun,
|
||||
processWorkflowRuns,
|
||||
showWorkflowStatusAsync,
|
||||
workflowRunExitCodes,
|
||||
} from '../../commandUtils/workflow/utils';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class WorkflowStatus extends EasCommand {
|
||||
static override description =
|
||||
'show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'WORKFLOW_RUN_ID',
|
||||
description: 'A workflow run ID.',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
wait: Flags.boolean({
|
||||
default: false,
|
||||
allowNo: true,
|
||||
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
|
||||
summary: 'Wait for workflow run to complete. Defaults to false.',
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.Vcs,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(WorkflowStatus);
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowStatus, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const {
|
||||
projectId,
|
||||
exp: { slug: projectName },
|
||||
} = await getDynamicPrivateProjectConfigAsync();
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let workflowRunId = args.WORKFLOW_RUN_ID;
|
||||
|
||||
if (!workflowRunId && flags['non-interactive']) {
|
||||
throw new Error('Workflow run ID is required in non-interactive mode');
|
||||
}
|
||||
if (!workflowRunId) {
|
||||
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
undefined,
|
||||
50
|
||||
);
|
||||
const runs = processWorkflowRuns(queryResult);
|
||||
if (runs.length === 0) {
|
||||
Log.warn('No workflow runs to show');
|
||||
return;
|
||||
}
|
||||
const answers = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedRun',
|
||||
message: 'Select a workflow run:',
|
||||
choices: runs.map(run => choiceFromWorkflowRun(run)),
|
||||
});
|
||||
workflowRunId = answers.selectedRun;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
const spinnerUsesStdErr = boolish('CI', false) || flags.json;
|
||||
|
||||
await showWorkflowStatusAsync(graphqlClient, {
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
waitForCompletion: flags.wait,
|
||||
});
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
const status = workflowRun.status;
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
...workflowRun,
|
||||
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
|
||||
});
|
||||
}
|
||||
|
||||
if (status === WorkflowRunStatus.Failure) {
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_FAILED);
|
||||
} else if (status === WorkflowRunStatus.Canceled) {
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class SetUpTargetBuildCredentials {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
|
||||
const { app, entitlements } = this.options;
|
||||
const { app, entitlements, target } = this.options;
|
||||
|
||||
await ctx.bestEffortAppStoreAuthenticateAsync();
|
||||
|
||||
@@ -35,7 +35,11 @@ export class SetUpTargetBuildCredentials {
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
projectName: app.projectName,
|
||||
},
|
||||
{ entitlements, usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications }
|
||||
{
|
||||
entitlements,
|
||||
usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications,
|
||||
parentBundleIdentifier: target.parentBundleIdentifier,
|
||||
}
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -11,9 +11,22 @@ import { assertContractMessagesAsync } from './contractMessages';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
|
||||
/**
|
||||
* The entitlement key that indicates a target is an App Clip.
|
||||
* When present, the target requires special bundle ID registration with a parent app relationship.
|
||||
*/
|
||||
const APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT =
|
||||
'com.apple.developer.parent-application-identifiers';
|
||||
|
||||
export interface IosCapabilitiesOptions {
|
||||
entitlements: JSONObject;
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
/**
|
||||
* The bundle identifier of the parent app. Required for App Clips.
|
||||
* App Clips are detected by the presence of the
|
||||
* `com.apple.developer.parent-application-identifiers` entitlement.
|
||||
*/
|
||||
parentBundleIdentifier?: string;
|
||||
}
|
||||
|
||||
export interface AppLookupParams {
|
||||
@@ -43,7 +56,13 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
options?: IosCapabilitiesOptions
|
||||
): Promise<void> {
|
||||
const context = getRequestContext(authCtx);
|
||||
const spinner = ora(`Linking bundle identifier ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
// Detect App Clips by the presence of the parent-application-identifiers entitlement
|
||||
const isAppClip = Boolean(
|
||||
options?.entitlements?.[APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT]
|
||||
);
|
||||
const typeLabel = isAppClip ? 'App Clip bundle identifier' : 'bundle identifier';
|
||||
const spinner = ora(`Linking ${typeLabel} ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
let bundleId: BundleId | null;
|
||||
try {
|
||||
@@ -51,23 +70,37 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
bundleId = await BundleId.findAsync(context, { identifier: bundleIdentifier });
|
||||
|
||||
if (!bundleId) {
|
||||
spinner.text = `Registering bundle identifier ${chalk.dim(bundleIdentifier)}`;
|
||||
// If it doesn't exist, create it
|
||||
bundleId = await BundleId.createAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
});
|
||||
spinner.text = `Registering ${typeLabel} ${chalk.dim(bundleIdentifier)}`;
|
||||
|
||||
if (isAppClip && options?.parentBundleIdentifier) {
|
||||
// App Clips require the parent bundle ID's opaque ID
|
||||
bundleId = await createAppClipBundleIdAsync(context, {
|
||||
name,
|
||||
bundleIdentifier,
|
||||
parentBundleIdentifier: options.parentBundleIdentifier,
|
||||
});
|
||||
} else {
|
||||
// If it doesn't exist, create it
|
||||
bundleId = await BundleId.createAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
spinner.succeed(`Bundle identifier registered ${chalk.dim(bundleIdentifier)}`);
|
||||
spinner.succeed(
|
||||
`${isAppClip ? 'App Clip bundle' : 'Bundle'} identifier registered ${chalk.dim(
|
||||
bundleIdentifier
|
||||
)}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.message.match(/An App ID with Identifier '(.*)' is not available/)) {
|
||||
spinner.fail(
|
||||
`The bundle identifier ${chalk.bold(bundleIdentifier)} is not available to team "${
|
||||
`The ${typeLabel} ${chalk.bold(bundleIdentifier)} is not available to team "${
|
||||
authCtx.team.name
|
||||
}" (${authCtx.team.id}), change it in your app config and try again.`
|
||||
);
|
||||
} else {
|
||||
spinner.fail(`Failed to register bundle identifier ${chalk.dim(bundleIdentifier)}`);
|
||||
spinner.fail(`Failed to register ${typeLabel} ${chalk.dim(bundleIdentifier)}`);
|
||||
|
||||
// Assert contract errors for easier resolution when the user has an expired developer account.
|
||||
if (
|
||||
@@ -93,6 +126,42 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an App Clip bundle identifier.
|
||||
* App Clips require the parent bundle ID's opaque ID for registration.
|
||||
*/
|
||||
async function createAppClipBundleIdAsync(
|
||||
context: RequestContext,
|
||||
{
|
||||
name,
|
||||
bundleIdentifier,
|
||||
parentBundleIdentifier,
|
||||
}: {
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
parentBundleIdentifier: string;
|
||||
}
|
||||
): Promise<BundleId> {
|
||||
// First, find the parent bundle ID to get its opaque ID
|
||||
const parentBundleId = await BundleId.findAsync(context, {
|
||||
identifier: parentBundleIdentifier,
|
||||
});
|
||||
|
||||
if (!parentBundleId) {
|
||||
throw new Error(
|
||||
`Cannot create App Clip bundle identifier "${bundleIdentifier}" because the parent bundle identifier "${parentBundleIdentifier}" is not registered. ` +
|
||||
`Please ensure the parent app's bundle identifier is registered first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Create the App Clip bundle ID with the parent relationship
|
||||
return await BundleId.createAppClipAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
parentBundleIdId: parentBundleId.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncCapabilitiesAsync(
|
||||
bundleId: BundleId,
|
||||
{ entitlements, ...rest }: IosCapabilitiesOptions
|
||||
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user