mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bba882b1 |
No files matched your search
@@ -1,17 +0,0 @@
|
||||
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"
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -1,205 +0,0 @@
|
||||
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
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
NODE_AUTH_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 }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
@@ -60,5 +60,5 @@ jobs:
|
||||
echo "Only moving the staging tag, skipping the production tag"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
@@ -9,8 +9,6 @@ jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
@@ -26,11 +24,9 @@ jobs:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
@@ -73,11 +69,9 @@ jobs:
|
||||
# Use macOS to get the correct platform specific dependencies
|
||||
runs-on: macos-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- run: brew install python-setuptools
|
||||
@@ -118,11 +112,9 @@ jobs:
|
||||
name: Build for Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
@@ -160,11 +152,9 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-mac, build-windows]
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
@@ -175,31 +165,31 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
- name: Add latest-eas-build-staging tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
|
||||
- name: Add latest-eas-build tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_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@v4
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22', '24']
|
||||
node: ['20', '22']
|
||||
include:
|
||||
- node: '20'
|
||||
coverage: true
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
@@ -1,75 +0,0 @@
|
||||
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 }}
|
||||
@@ -8,24 +8,10 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Hidden beta version of new `eas account:usage` command for viewing account usage and billing estimates. ([#3334](https://github.com/expo/eas-cli/pull/3334) by [@douglowder](https://github.com/douglowder), [@EvanBacon](https://github.com/EvanBacon))
|
||||
- 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
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# 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
|
||||
+1
-3
@@ -4,11 +4,9 @@ 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, workerConfig],
|
||||
projects: [easCliConfig, easJsonConfig],
|
||||
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.32.0",
|
||||
"version": "16.28.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+4
-4
@@ -21,21 +21,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "20.11.0",
|
||||
"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": "9.0.3",
|
||||
"lerna": "8.2.3",
|
||||
"oclif": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.19.4",
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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.30.0",
|
||||
"version": "16.4.2",
|
||||
"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.5.4"
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -33,11 +33,7 @@
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-build-cache-provider"
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
@@ -54,5 +50,9 @@
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
# 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
|
||||
+94
-120
@@ -57,7 +57,6 @@ eas --help COMMAND
|
||||
<!-- commands -->
|
||||
* [`eas account:login`](#eas-accountlogin)
|
||||
* [`eas account:logout`](#eas-accountlogout)
|
||||
* [`eas account:usage [ACCOUNT_NAME]`](#eas-accountusage-account_name)
|
||||
* [`eas account:view`](#eas-accountview)
|
||||
* [`eas analytics [STATUS]`](#eas-analytics-status)
|
||||
* [`eas autocomplete [SHELL]`](#eas-autocomplete-shell)
|
||||
@@ -177,7 +176,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -194,29 +193,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
view account usage and billing for the current cycle
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas account:usage [ACCOUNT_NAME] [--json] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
ACCOUNT_NAME Account name to view usage for. If not provided, the account will be selected interactively (or defaults
|
||||
to the only account if there is just one)
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -233,7 +210,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -247,7 +224,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -299,7 +276,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -320,7 +297,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -340,7 +317,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -360,7 +337,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -383,7 +360,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -424,7 +401,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -443,7 +420,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +437,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -479,7 +456,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -499,7 +476,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -520,7 +497,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -555,7 +532,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -607,7 +584,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -636,7 +613,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -662,7 +639,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -716,7 +693,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -735,7 +712,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -754,7 +731,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -771,7 +748,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -792,7 +769,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -813,7 +790,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -835,7 +812,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -855,7 +832,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -877,7 +854,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -899,7 +876,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -932,7 +909,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -955,7 +932,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -976,7 +953,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -993,7 +970,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1011,7 +988,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1039,7 +1016,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1064,7 +1041,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1085,7 +1062,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1106,7 +1083,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1143,7 +1120,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1163,7 +1140,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1184,7 +1161,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1205,7 +1182,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1219,7 +1196,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1233,7 +1210,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1263,7 +1240,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1288,7 +1265,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1310,7 +1287,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1335,7 +1312,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1361,7 +1338,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1383,7 +1360,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1405,7 +1382,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1437,7 +1414,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1481,7 +1458,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1511,7 +1488,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1619,7 +1596,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1636,7 +1613,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1653,7 +1630,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1704,7 +1681,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1718,7 +1695,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1741,7 +1718,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1764,7 +1741,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1782,7 +1759,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1817,7 +1794,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1857,7 +1834,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1877,7 +1854,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1898,7 +1875,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1922,7 +1899,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1930,24 +1907,21 @@ view the recent updates
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:list [--branch <value> | --all] [-p android|ios|all] [--runtime-version <value>] [--offset
|
||||
<value>] [--limit <value>] [--json --non-interactive]
|
||||
$ eas update:list [--branch <value> | --all] [--offset <value>] [--limit <value>] [--json --non-interactive]
|
||||
|
||||
FLAGS
|
||||
-p, --platform=(android|ios|all) Filter updates by platform
|
||||
--all List updates on all branches
|
||||
--branch=<value> List updates only on this branch
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
|
||||
--runtime-version=<value> Filter updates by runtime version
|
||||
--all List updates on all branches
|
||||
--branch=<value> List updates only on this branch
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
|
||||
|
||||
DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1983,7 +1957,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2010,7 +1984,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2038,7 +2012,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2059,7 +2033,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2079,7 +2053,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2100,7 +2074,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2121,7 +2095,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2141,7 +2115,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2159,7 +2133,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2181,7 +2155,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2198,7 +2172,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2318,7 +2292,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2338,7 +2312,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2362,7 +2336,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run [FILE]`
|
||||
|
||||
@@ -2402,7 +2376,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2427,7 +2401,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2455,7 +2429,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2475,7 +2449,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2497,5 +2471,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+568
-6441
File diff suppressed because it is too large.
Load diff
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "16.32.0",
|
||||
"version": "16.28.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.13",
|
||||
"@expo/apple-utils": "2.1.12",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "1.0.243",
|
||||
"@expo/eas-json": "16.30.0",
|
||||
"@expo/eas-json": "16.25.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.9.10",
|
||||
"@expo/package-manager": "1.7.0",
|
||||
"@expo/pkcs12": "0.1.3",
|
||||
"@expo/plist": "0.2.0",
|
||||
"@expo/plugin-help": "5.1.23",
|
||||
@@ -58,7 +58,6 @@
|
||||
"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",
|
||||
@@ -108,7 +107,6 @@
|
||||
"@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",
|
||||
@@ -131,7 +129,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -222,11 +220,7 @@
|
||||
"-v"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-cli"
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
@@ -244,5 +238,9 @@
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"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"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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,308 +0,0 @@
|
||||
import { EasService, EasServiceMetric, UsageMetricType } from '../../graphql/generated';
|
||||
import { AccountFullUsageData } from '../../graphql/queries/AccountQuery';
|
||||
import {
|
||||
calculateBillingPeriodDays,
|
||||
calculateDaysElapsed,
|
||||
calculateDaysRemaining,
|
||||
extractUsageData,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '../usageUtils';
|
||||
|
||||
function createMockFullUsageData(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
subscriptionName: string;
|
||||
buildValue: number;
|
||||
buildLimit: number;
|
||||
mauValue: number;
|
||||
mauLimit: number;
|
||||
bandwidthValue: number;
|
||||
bandwidthLimit: number;
|
||||
buildOverageCost: number;
|
||||
updateOverageCost: number;
|
||||
}> = {}
|
||||
): AccountFullUsageData {
|
||||
const {
|
||||
name = 'test-account',
|
||||
subscriptionName = 'Starter',
|
||||
buildValue = 10,
|
||||
buildLimit = 50,
|
||||
mauValue = 500,
|
||||
mauLimit = 3000,
|
||||
bandwidthValue = 2.5,
|
||||
bandwidthLimit = 10,
|
||||
buildOverageCost = 0,
|
||||
updateOverageCost = 0,
|
||||
} = overrides;
|
||||
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return {
|
||||
__typename: 'Account',
|
||||
id: 'account-id',
|
||||
name,
|
||||
subscription: {
|
||||
__typename: 'SubscriptionDetails',
|
||||
id: 'sub-id',
|
||||
name: subscriptionName,
|
||||
status: 'active',
|
||||
nextInvoice: endOfMonth.toISOString(),
|
||||
nextInvoiceAmountDueCents: 1900,
|
||||
recurringCents: 1900,
|
||||
price: 1900,
|
||||
addons: [],
|
||||
},
|
||||
billingPeriod: {
|
||||
__typename: 'BillingPeriod',
|
||||
id: 'billing-period-id',
|
||||
start: startOfMonth.toISOString(),
|
||||
end: endOfMonth.toISOString(),
|
||||
anchor: startOfMonth.toISOString(),
|
||||
},
|
||||
usageMetrics: {
|
||||
__typename: 'AccountUsageMetrics',
|
||||
MEDIUM_ANDROID_BUILDS: [],
|
||||
LARGE_ANDROID_BUILDS: [],
|
||||
MEDIUM_IOS_BUILDS: [],
|
||||
LARGE_IOS_BUILDS: [],
|
||||
EAS_BUILD: {
|
||||
__typename: 'UsageMetricTotal',
|
||||
id: 'build-metric-id',
|
||||
billingPeriod: {
|
||||
__typename: 'BillingPeriod',
|
||||
id: 'billing-period-id',
|
||||
start: startOfMonth.toISOString(),
|
||||
end: endOfMonth.toISOString(),
|
||||
anchor: startOfMonth.toISOString(),
|
||||
},
|
||||
planMetrics: [
|
||||
{
|
||||
__typename: 'EstimatedUsage',
|
||||
id: 'build-plan-metric-id',
|
||||
service: EasService.Builds,
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: buildValue,
|
||||
limit: buildLimit,
|
||||
platformBreakdown: {
|
||||
__typename: 'EstimatedUsagePlatformBreakdown',
|
||||
ios: {
|
||||
__typename: 'EstimatedUsagePlatformDetail',
|
||||
value: Math.floor(buildValue * 0.6),
|
||||
limit: Math.floor(buildLimit * 0.6),
|
||||
},
|
||||
android: {
|
||||
__typename: 'EstimatedUsagePlatformDetail',
|
||||
value: Math.floor(buildValue * 0.4),
|
||||
limit: Math.floor(buildLimit * 0.4),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
overageMetrics:
|
||||
buildOverageCost > 0
|
||||
? [
|
||||
{
|
||||
__typename: 'EstimatedOverageAndCost',
|
||||
id: 'build-overage-id',
|
||||
service: EasService.Builds,
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: buildValue - buildLimit,
|
||||
limit: buildLimit,
|
||||
totalCost: buildOverageCost,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
totalCost: buildOverageCost,
|
||||
},
|
||||
EAS_UPDATE: {
|
||||
__typename: 'UsageMetricTotal',
|
||||
id: 'update-metric-id',
|
||||
billingPeriod: {
|
||||
__typename: 'BillingPeriod',
|
||||
id: 'billing-period-id',
|
||||
start: startOfMonth.toISOString(),
|
||||
end: endOfMonth.toISOString(),
|
||||
anchor: startOfMonth.toISOString(),
|
||||
},
|
||||
planMetrics: [
|
||||
{
|
||||
__typename: 'EstimatedUsage',
|
||||
id: 'mau-plan-metric-id',
|
||||
service: EasService.Updates,
|
||||
serviceMetric: EasServiceMetric.UniqueUpdaters,
|
||||
metricType: UsageMetricType.Update,
|
||||
value: mauValue,
|
||||
limit: mauLimit,
|
||||
},
|
||||
{
|
||||
__typename: 'EstimatedUsage',
|
||||
id: 'bandwidth-plan-metric-id',
|
||||
service: EasService.Updates,
|
||||
serviceMetric: EasServiceMetric.BandwidthUsage,
|
||||
metricType: UsageMetricType.Bandwidth,
|
||||
value: bandwidthValue,
|
||||
limit: bandwidthLimit,
|
||||
},
|
||||
],
|
||||
overageMetrics:
|
||||
updateOverageCost > 0
|
||||
? [
|
||||
{
|
||||
__typename: 'EstimatedOverageAndCost',
|
||||
id: 'mau-overage-id',
|
||||
service: EasService.Updates,
|
||||
serviceMetric: EasServiceMetric.UniqueUpdaters,
|
||||
metricType: UsageMetricType.Update,
|
||||
value: mauValue - mauLimit,
|
||||
limit: mauLimit,
|
||||
totalCost: updateOverageCost,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
totalCost: updateOverageCost,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats date correctly', () => {
|
||||
const result = formatDate('2024-03-15T00:00:00.000Z');
|
||||
expect(result).toMatch(/Mar 15, 2024|Mar 14, 2024/); // Allow for timezone differences
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
it('formats cents to dollars', () => {
|
||||
expect(formatCurrency(1900)).toBe('$19.00');
|
||||
expect(formatCurrency(0)).toBe('$0.00');
|
||||
expect(formatCurrency(150)).toBe('$1.50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('formats numbers with no decimals by default', () => {
|
||||
expect(formatNumber(1000)).toBe('1,000');
|
||||
expect(formatNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('formats numbers with specified decimals', () => {
|
||||
expect(formatNumber(1234.5678, 2)).toBe('1,234.57');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDaysRemaining', () => {
|
||||
it('calculates days remaining correctly', () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 10);
|
||||
const result = calculateDaysRemaining(futureDate.toISOString());
|
||||
expect(result).toBeGreaterThanOrEqual(9);
|
||||
expect(result).toBeLessThanOrEqual(11);
|
||||
});
|
||||
|
||||
it('returns 0 for past dates', () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 10);
|
||||
const result = calculateDaysRemaining(pastDate.toISOString());
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDaysElapsed', () => {
|
||||
it('calculates days elapsed correctly', () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 10);
|
||||
const result = calculateDaysElapsed(pastDate.toISOString());
|
||||
expect(result).toBeGreaterThanOrEqual(10);
|
||||
expect(result).toBeLessThanOrEqual(11);
|
||||
});
|
||||
|
||||
it('returns at least 1 for recent dates', () => {
|
||||
const today = new Date();
|
||||
const result = calculateDaysElapsed(today.toISOString());
|
||||
expect(result).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBillingPeriodDays', () => {
|
||||
it('calculates billing period days correctly', () => {
|
||||
const start = '2024-03-01T00:00:00.000Z';
|
||||
const end = '2024-03-31T00:00:00.000Z';
|
||||
const result = calculateBillingPeriodDays(start, end);
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUsageData', () => {
|
||||
it('correctly extracts usage data from API response', () => {
|
||||
const mockData = createMockFullUsageData({
|
||||
buildValue: 25,
|
||||
buildLimit: 50,
|
||||
mauValue: 1000,
|
||||
mauLimit: 3000,
|
||||
});
|
||||
|
||||
const result = extractUsageData(mockData);
|
||||
|
||||
expect(result.accountName).toBe('test-account');
|
||||
expect(result.subscriptionPlan).toBe('Starter');
|
||||
expect(result.builds.total.planValue).toBe(25);
|
||||
expect(result.builds.total.limit).toBe(50);
|
||||
expect(result.updates.mau.planValue).toBe(1000);
|
||||
expect(result.updates.mau.limit).toBe(3000);
|
||||
});
|
||||
|
||||
it('handles overage costs', () => {
|
||||
const mockData = createMockFullUsageData({
|
||||
buildValue: 60,
|
||||
buildLimit: 50,
|
||||
buildOverageCost: 2000, // $20 in cents
|
||||
});
|
||||
|
||||
const result = extractUsageData(mockData);
|
||||
|
||||
expect(result.builds.overageCostCents).toBe(2000);
|
||||
expect(result.totalOverageCostCents).toBe(2000);
|
||||
});
|
||||
|
||||
it('handles Free plan', () => {
|
||||
const mockData = createMockFullUsageData({
|
||||
subscriptionName: 'Free',
|
||||
});
|
||||
|
||||
const result = extractUsageData(mockData);
|
||||
|
||||
expect(result.subscriptionPlan).toBe('Free');
|
||||
});
|
||||
|
||||
it('calculates percent used correctly', () => {
|
||||
const mockData = createMockFullUsageData({
|
||||
buildValue: 25,
|
||||
buildLimit: 50,
|
||||
});
|
||||
|
||||
const result = extractUsageData(mockData);
|
||||
|
||||
expect(result.builds.total.percentUsed).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing period calculations', () => {
|
||||
it('correctly identifies billing period dates', () => {
|
||||
const mockData = createMockFullUsageData();
|
||||
const result = extractUsageData(mockData);
|
||||
|
||||
const startDate = new Date(result.billingPeriod.start);
|
||||
const endDate = new Date(result.billingPeriod.end);
|
||||
|
||||
expect(startDate).toBeInstanceOf(Date);
|
||||
expect(endDate).toBeInstanceOf(Date);
|
||||
expect(endDate.getTime()).toBeGreaterThan(startDate.getTime());
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,28 @@
|
||||
import { Client, cacheExchange, createClient as createUrqlClient, fetchExchange } from '@urql/core';
|
||||
import {
|
||||
AnyVariables,
|
||||
Client,
|
||||
OperationContext,
|
||||
OperationResult,
|
||||
OperationResultSource,
|
||||
TypedDocumentNode,
|
||||
cacheExchange,
|
||||
createClient as createUrqlClient,
|
||||
fetchExchange,
|
||||
} from '@urql/core';
|
||||
import { retryExchange } from '@urql/exchange-retry';
|
||||
import { DocumentNode } from 'graphql';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { getExpoApiBaseUrl } from '../../../api';
|
||||
import { httpsProxyAgent } from '../../../fetch';
|
||||
|
||||
// Extend the urql Client to allow for future customizations or type additions ahead of upstream type support.
|
||||
export interface ExpoGraphqlClient extends Client {}
|
||||
export interface ExpoGraphqlClient extends Client {
|
||||
query<Data = any, Variables extends AnyVariables = AnyVariables>(
|
||||
query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
|
||||
variables: Variables,
|
||||
context: Partial<OperationContext> & { additionalTypenames: string[] }
|
||||
): OperationResultSource<OperationResult<Data, Variables>>;
|
||||
}
|
||||
|
||||
export function createGraphqlClient(authInfo: {
|
||||
accessToken: string | null;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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,33 +0,0 @@
|
||||
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,295 +0,0 @@
|
||||
import {
|
||||
AppPlatform,
|
||||
EasBuildBillingResourceClass,
|
||||
EasServiceMetric,
|
||||
UsageMetricType,
|
||||
} from '../graphql/generated';
|
||||
import { AccountFullUsageData } from '../graphql/queries/AccountQuery';
|
||||
import { calculatePercentUsed } from '../utils/usage/checkForOverages';
|
||||
|
||||
export interface UsageMetricDisplay {
|
||||
name: string;
|
||||
planValue: number;
|
||||
limit: number;
|
||||
percentUsed: number;
|
||||
overageValue: number;
|
||||
overageCost: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface BuildOverageByWorkerSize {
|
||||
platform: AppPlatform;
|
||||
resourceClass: EasBuildBillingResourceClass;
|
||||
count: number;
|
||||
costCents: number;
|
||||
}
|
||||
|
||||
export interface BuildCountByPlatformAndSize {
|
||||
platform: 'ios' | 'android';
|
||||
resourceClass: 'medium' | 'large';
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UsageDisplayData {
|
||||
accountName: string;
|
||||
subscriptionPlan: string;
|
||||
billingPeriod: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
builds: {
|
||||
total: UsageMetricDisplay;
|
||||
ios?: UsageMetricDisplay;
|
||||
android?: UsageMetricDisplay;
|
||||
countsByPlatformAndSize: BuildCountByPlatformAndSize[];
|
||||
overagesByWorkerSize: BuildOverageByWorkerSize[];
|
||||
overageCostCents: number;
|
||||
};
|
||||
updates: {
|
||||
mau: UsageMetricDisplay;
|
||||
bandwidth: UsageMetricDisplay;
|
||||
overageCostCents: number;
|
||||
};
|
||||
totalOverageCostCents: number;
|
||||
estimatedBillCents: number;
|
||||
recurringCents: number | null;
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCurrency(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, decimals: number = 0): string {
|
||||
if (decimals === 0) {
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateDaysRemaining(endDate: string): number {
|
||||
const end = new Date(endDate);
|
||||
const now = new Date();
|
||||
const diffTime = end.getTime() - now.getTime();
|
||||
return Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
export function calculateDaysElapsed(startDate: string): number {
|
||||
const start = new Date(startDate);
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - start.getTime();
|
||||
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
export function calculateBillingPeriodDays(startDate: string, endDate: string): number {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const diffTime = end.getTime() - start.getTime();
|
||||
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
|
||||
const { name, subscription, billingPeriod, usageMetrics } = data;
|
||||
const {
|
||||
EAS_BUILD,
|
||||
EAS_UPDATE,
|
||||
MEDIUM_ANDROID_BUILDS,
|
||||
LARGE_ANDROID_BUILDS,
|
||||
MEDIUM_IOS_BUILDS,
|
||||
LARGE_IOS_BUILDS,
|
||||
} = usageMetrics;
|
||||
|
||||
// Find build metrics
|
||||
const buildMetric = EAS_BUILD.planMetrics.find(
|
||||
m => m.serviceMetric === EasServiceMetric.Builds && m.metricType === UsageMetricType.Build
|
||||
);
|
||||
|
||||
// Extract build counts by platform and worker size
|
||||
const countsByPlatformAndSize: BuildCountByPlatformAndSize[] = [];
|
||||
const mediumAndroidCount = MEDIUM_ANDROID_BUILDS?.[0]?.value ?? 0;
|
||||
const largeAndroidCount = LARGE_ANDROID_BUILDS?.[0]?.value ?? 0;
|
||||
const mediumIosCount = MEDIUM_IOS_BUILDS?.[0]?.value ?? 0;
|
||||
const largeIosCount = LARGE_IOS_BUILDS?.[0]?.value ?? 0;
|
||||
|
||||
if (mediumAndroidCount > 0) {
|
||||
countsByPlatformAndSize.push({
|
||||
platform: 'android',
|
||||
resourceClass: 'medium',
|
||||
count: mediumAndroidCount,
|
||||
});
|
||||
}
|
||||
if (largeAndroidCount > 0) {
|
||||
countsByPlatformAndSize.push({
|
||||
platform: 'android',
|
||||
resourceClass: 'large',
|
||||
count: largeAndroidCount,
|
||||
});
|
||||
}
|
||||
if (mediumIosCount > 0) {
|
||||
countsByPlatformAndSize.push({
|
||||
platform: 'ios',
|
||||
resourceClass: 'medium',
|
||||
count: mediumIosCount,
|
||||
});
|
||||
}
|
||||
if (largeIosCount > 0) {
|
||||
countsByPlatformAndSize.push({ platform: 'ios', resourceClass: 'large', count: largeIosCount });
|
||||
}
|
||||
|
||||
// Extract build overages by worker size from overage metrics with metadata
|
||||
const overagesByWorkerSize: BuildOverageByWorkerSize[] = [];
|
||||
for (const overage of EAS_BUILD.overageMetrics) {
|
||||
if (
|
||||
overage.serviceMetric === EasServiceMetric.Builds &&
|
||||
overage.metadata?.billingResourceClass &&
|
||||
overage.metadata?.platform
|
||||
) {
|
||||
overagesByWorkerSize.push({
|
||||
platform: overage.metadata.platform,
|
||||
resourceClass: overage.metadata.billingResourceClass,
|
||||
count: overage.value,
|
||||
costCents: overage.totalCost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find update metrics
|
||||
const mauMetric = EAS_UPDATE.planMetrics.find(
|
||||
m =>
|
||||
m.serviceMetric === EasServiceMetric.UniqueUpdaters && m.metricType === UsageMetricType.Update
|
||||
);
|
||||
const bandwidthMetric = EAS_UPDATE.planMetrics.find(
|
||||
m =>
|
||||
m.serviceMetric === EasServiceMetric.BandwidthUsage &&
|
||||
m.metricType === UsageMetricType.Bandwidth
|
||||
);
|
||||
const mauOverage = EAS_UPDATE.overageMetrics.find(
|
||||
m =>
|
||||
m.serviceMetric === EasServiceMetric.UniqueUpdaters && m.metricType === UsageMetricType.Update
|
||||
);
|
||||
const bandwidthOverage = EAS_UPDATE.overageMetrics.find(
|
||||
m =>
|
||||
m.serviceMetric === EasServiceMetric.BandwidthUsage &&
|
||||
m.metricType === UsageMetricType.Bandwidth
|
||||
);
|
||||
|
||||
// Build metrics - plan values from planMetrics, overage from overageMetrics
|
||||
const buildPlanValue = buildMetric?.value ?? 0;
|
||||
const buildLimit = buildMetric?.limit ?? 0;
|
||||
const iosBuildPlanValue = buildMetric?.platformBreakdown?.ios.value ?? 0;
|
||||
const iosBuildLimit = buildMetric?.platformBreakdown?.ios.limit ?? 0;
|
||||
const androidBuildPlanValue = buildMetric?.platformBreakdown?.android.value ?? 0;
|
||||
const androidBuildLimit = buildMetric?.platformBreakdown?.android.limit ?? 0;
|
||||
|
||||
// Update metrics - plan values from planMetrics, overage from overageMetrics
|
||||
const mauPlanValue = mauMetric?.value ?? 0;
|
||||
const mauOverageValue = mauOverage?.value ?? 0;
|
||||
const mauLimit = mauMetric?.limit ?? 0;
|
||||
|
||||
const bandwidthPlanValue = bandwidthMetric?.value ?? 0;
|
||||
const bandwidthOverageValue = bandwidthOverage?.value ?? 0;
|
||||
const bandwidthLimit = bandwidthMetric?.limit ?? 0;
|
||||
|
||||
const buildOverageCostCents = EAS_BUILD.totalCost;
|
||||
const updateOverageCostCents = EAS_UPDATE.totalCost;
|
||||
const totalOverageCostCents = buildOverageCostCents + updateOverageCostCents;
|
||||
|
||||
// Calculate total overage count for display
|
||||
const totalOverageBuilds = overagesByWorkerSize.reduce((sum, o) => sum + o.count, 0);
|
||||
|
||||
return {
|
||||
accountName: name,
|
||||
subscriptionPlan: subscription?.name ?? 'Free',
|
||||
billingPeriod: {
|
||||
start: billingPeriod.start,
|
||||
end: billingPeriod.end,
|
||||
},
|
||||
builds: {
|
||||
total: {
|
||||
name: 'Builds',
|
||||
planValue: buildPlanValue,
|
||||
limit: buildLimit,
|
||||
percentUsed: calculatePercentUsed(buildPlanValue, buildLimit),
|
||||
overageValue: totalOverageBuilds,
|
||||
overageCost: buildOverageCostCents,
|
||||
unit: 'builds',
|
||||
},
|
||||
ios: buildMetric?.platformBreakdown
|
||||
? {
|
||||
name: 'iOS Builds',
|
||||
planValue: iosBuildPlanValue,
|
||||
limit: iosBuildLimit,
|
||||
percentUsed: calculatePercentUsed(iosBuildPlanValue, iosBuildLimit),
|
||||
overageValue: 0,
|
||||
overageCost: 0,
|
||||
unit: 'builds',
|
||||
}
|
||||
: undefined,
|
||||
android: buildMetric?.platformBreakdown
|
||||
? {
|
||||
name: 'Android Builds',
|
||||
planValue: androidBuildPlanValue,
|
||||
limit: androidBuildLimit,
|
||||
percentUsed: calculatePercentUsed(androidBuildPlanValue, androidBuildLimit),
|
||||
overageValue: 0,
|
||||
overageCost: 0,
|
||||
unit: 'builds',
|
||||
}
|
||||
: undefined,
|
||||
countsByPlatformAndSize,
|
||||
overagesByWorkerSize,
|
||||
overageCostCents: buildOverageCostCents,
|
||||
},
|
||||
updates: {
|
||||
mau: {
|
||||
name: 'Unique Updaters',
|
||||
planValue: mauPlanValue,
|
||||
limit: mauLimit,
|
||||
percentUsed: calculatePercentUsed(mauPlanValue, mauLimit),
|
||||
overageValue: mauOverageValue,
|
||||
overageCost: mauOverage?.totalCost ?? 0,
|
||||
unit: 'users',
|
||||
},
|
||||
bandwidth: {
|
||||
name: 'Bandwidth',
|
||||
planValue: bandwidthPlanValue,
|
||||
limit: bandwidthLimit,
|
||||
percentUsed: calculatePercentUsed(bandwidthPlanValue, bandwidthLimit),
|
||||
overageValue: bandwidthOverageValue,
|
||||
overageCost: bandwidthOverage?.totalCost ?? 0,
|
||||
unit: 'bytes',
|
||||
},
|
||||
overageCostCents: updateOverageCostCents,
|
||||
},
|
||||
totalOverageCostCents,
|
||||
estimatedBillCents: (subscription?.recurringCents ?? 0) + totalOverageCostCents,
|
||||
recurringCents: subscription?.recurringCents ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateBillingPeriodInfo(data: UsageDisplayData): {
|
||||
daysRemaining: number;
|
||||
daysElapsed: number;
|
||||
totalDays: number;
|
||||
} {
|
||||
const daysElapsed = calculateDaysElapsed(data.billingPeriod.start);
|
||||
const totalDays = calculateBillingPeriodDays(data.billingPeriod.start, data.billingPeriod.end);
|
||||
const daysRemaining = calculateDaysRemaining(data.billingPeriod.end);
|
||||
|
||||
return {
|
||||
daysRemaining,
|
||||
daysElapsed,
|
||||
totalDays,
|
||||
};
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AccountFullUsageQuery as AccountFullUsageQueryType,
|
||||
EasService,
|
||||
EasServiceMetric,
|
||||
UsageMetricType,
|
||||
} from '../../../graphql/generated';
|
||||
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
|
||||
import { calculatePercentUsed, createProgressBar } from '../../../utils/usage/checkForOverages';
|
||||
|
||||
jest.mock('../../../graphql/queries/AccountQuery');
|
||||
|
||||
function createMockFullUsageData(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
subscriptionName: string;
|
||||
buildValue: number;
|
||||
buildLimit: number;
|
||||
mauValue: number;
|
||||
mauLimit: number;
|
||||
bandwidthValue: number;
|
||||
bandwidthLimit: number;
|
||||
buildOverageCost: number;
|
||||
updateOverageCost: number;
|
||||
}> = {}
|
||||
): AccountFullUsageQueryType['account']['byId'] {
|
||||
const {
|
||||
name = 'test-account',
|
||||
subscriptionName = 'Starter',
|
||||
buildValue = 10,
|
||||
buildLimit = 50,
|
||||
mauValue = 500,
|
||||
mauLimit = 3000,
|
||||
bandwidthValue = 2.5,
|
||||
bandwidthLimit = 10,
|
||||
buildOverageCost = 0,
|
||||
updateOverageCost = 0,
|
||||
} = overrides;
|
||||
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return {
|
||||
__typename: 'Account',
|
||||
id: 'account-id',
|
||||
name,
|
||||
subscription: {
|
||||
__typename: 'SubscriptionDetails',
|
||||
id: 'sub-id',
|
||||
name: subscriptionName,
|
||||
status: 'active',
|
||||
nextInvoice: endOfMonth.toISOString(),
|
||||
nextInvoiceAmountDueCents: 1900,
|
||||
recurringCents: 1900,
|
||||
price: 1900,
|
||||
addons: [],
|
||||
},
|
||||
billingPeriod: {
|
||||
__typename: 'BillingPeriod',
|
||||
id: 'billing-period-id',
|
||||
start: startOfMonth.toISOString(),
|
||||
end: endOfMonth.toISOString(),
|
||||
anchor: startOfMonth.toISOString(),
|
||||
},
|
||||
usageMetrics: {
|
||||
__typename: 'AccountUsageMetrics',
|
||||
MEDIUM_ANDROID_BUILDS: [],
|
||||
LARGE_ANDROID_BUILDS: [],
|
||||
MEDIUM_IOS_BUILDS: [],
|
||||
LARGE_IOS_BUILDS: [],
|
||||
EAS_BUILD: {
|
||||
__typename: 'UsageMetricTotal',
|
||||
id: 'build-metric-id',
|
||||
billingPeriod: {
|
||||
__typename: 'BillingPeriod',
|
||||
id: 'billing-period-id',
|
||||
start: startOfMonth.toISOString(),
|
||||
end: endOfMonth.toISOString(),
|
||||
anchor: startOfMonth.toISOString(),
|
||||
},
|
||||
planMetrics: [
|
||||
{
|
||||
__typename: 'EstimatedUsage',
|
||||
id: 'build-plan-metric-id',
|
||||
service: EasService.Builds,
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: buildValue,
|
||||
limit: buildLimit,
|
||||
platformBreakdown: {
|
||||
__typename: 'EstimatedUsagePlatformBreakdown',
|
||||
ios: {
|
||||
__typename: 'EstimatedUsagePlatformDetail',
|
||||
value: Math.floor(buildValue * 0.6),
|
||||
limit: Math.floor(buildLimit * 0.6),
|
||||
},
|
||||
android: {
|
||||
__typename: 'EstimatedUsagePlatformDetail',
|
||||
value: Math.floor(buildValue * 0.4),
|
||||
limit: Math.floor(buildLimit * 0.4),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
overageMetrics:
|
||||
buildOverageCost > 0
|
||||
? [
|
||||
{
|
||||
__typename: 'EstimatedOverageAndCost',
|
||||
id: 'build-overage-id',
|
||||
service: EasService.Builds,
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: buildValue - buildLimit,
|
||||
limit: buildLimit,
|
||||
totalCost: buildOverageCost,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
totalCost: buildOverageCost,
|
||||
},
|
||||
EAS_UPDATE: {
|
||||
__typename: 'UsageMetricTotal',
|
||||
id: 'update-metric-id',
|
||||
billingPeriod: {
|
||||
__typename: 'BillingPeriod',
|
||||
id: 'billing-period-id',
|
||||
start: startOfMonth.toISOString(),
|
||||
end: endOfMonth.toISOString(),
|
||||
anchor: startOfMonth.toISOString(),
|
||||
},
|
||||
planMetrics: [
|
||||
{
|
||||
__typename: 'EstimatedUsage',
|
||||
id: 'mau-plan-metric-id',
|
||||
service: EasService.Updates,
|
||||
serviceMetric: EasServiceMetric.UniqueUpdaters,
|
||||
metricType: UsageMetricType.Update,
|
||||
value: mauValue,
|
||||
limit: mauLimit,
|
||||
},
|
||||
{
|
||||
__typename: 'EstimatedUsage',
|
||||
id: 'bandwidth-plan-metric-id',
|
||||
service: EasService.Updates,
|
||||
serviceMetric: EasServiceMetric.BandwidthUsage,
|
||||
metricType: UsageMetricType.Bandwidth,
|
||||
value: bandwidthValue,
|
||||
limit: bandwidthLimit,
|
||||
},
|
||||
],
|
||||
overageMetrics:
|
||||
updateOverageCost > 0
|
||||
? [
|
||||
{
|
||||
__typename: 'EstimatedOverageAndCost',
|
||||
id: 'mau-overage-id',
|
||||
service: EasService.Updates,
|
||||
serviceMetric: EasServiceMetric.UniqueUpdaters,
|
||||
metricType: UsageMetricType.Update,
|
||||
value: mauValue - mauLimit,
|
||||
limit: mauLimit,
|
||||
totalCost: updateOverageCost,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
totalCost: updateOverageCost,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccountQuery', () => {
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockGetFullUsageAsync = jest.mocked(AccountQuery.getFullUsageAsync);
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetFullUsageAsync.mockClear();
|
||||
});
|
||||
|
||||
it('fetches usage data for an account', async () => {
|
||||
const mockData = createMockFullUsageData();
|
||||
mockGetFullUsageAsync.mockResolvedValue(mockData as any);
|
||||
|
||||
const currentDate = new Date();
|
||||
const startDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
|
||||
const endDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0);
|
||||
|
||||
const result = await AccountQuery.getFullUsageAsync(
|
||||
mockGraphqlClient,
|
||||
'account-id',
|
||||
currentDate,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
expect(mockGetFullUsageAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'account-id',
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
expect.any(Date)
|
||||
);
|
||||
expect(result.name).toBe('test-account');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePercentUsed', () => {
|
||||
it('calculates correct percentage', () => {
|
||||
expect(calculatePercentUsed(50, 100)).toBe(50);
|
||||
expect(calculatePercentUsed(85, 100)).toBe(85);
|
||||
expect(calculatePercentUsed(100, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('caps at 100%', () => {
|
||||
expect(calculatePercentUsed(150, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 when limit is 0', () => {
|
||||
expect(calculatePercentUsed(50, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProgressBar', () => {
|
||||
it('creates correct progress bar for 50%', () => {
|
||||
const bar = createProgressBar(50, 20);
|
||||
expect(bar).toBe('██████████░░░░░░░░░░');
|
||||
});
|
||||
|
||||
it('creates correct progress bar for 0%', () => {
|
||||
const bar = createProgressBar(0, 20);
|
||||
expect(bar).toBe('░░░░░░░░░░░░░░░░░░░░');
|
||||
});
|
||||
|
||||
it('creates correct progress bar for 100%', () => {
|
||||
const bar = createProgressBar(100, 20);
|
||||
expect(bar).toBe('████████████████████');
|
||||
});
|
||||
});
|
||||
@@ -1,409 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
UsageDisplayData,
|
||||
UsageMetricDisplay,
|
||||
calculateBillingPeriodInfo,
|
||||
extractUsageData,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '../../commandUtils/usageUtils';
|
||||
import { AppPlatform, EasBuildBillingResourceClass } from '../../graphql/generated';
|
||||
import { AccountFullUsageData, AccountQuery } from '../../graphql/queries/AccountQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { createProgressBar } from '../../utils/usage/checkForOverages';
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const GiB = 1024 * MiB;
|
||||
const TiB = 1024 * GiB;
|
||||
|
||||
function formatBytes(bytes: number): { value: number; unit: string } {
|
||||
if (bytes >= TiB) {
|
||||
return { value: bytes / TiB, unit: 'TiB' };
|
||||
} else if (bytes >= GiB) {
|
||||
return { value: bytes / GiB, unit: 'GiB' };
|
||||
} else {
|
||||
return { value: bytes / MiB, unit: 'MiB' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytesDisplay(bytes: number): string {
|
||||
const { value, unit } = formatBytes(bytes);
|
||||
return `${formatNumber(value, value < 10 ? 2 : value < 100 ? 1 : 0)} ${unit}`;
|
||||
}
|
||||
|
||||
function displayBandwidthMetric(metric: UsageMetricDisplay, indent: string = ' '): void {
|
||||
const progressBar = createProgressBar(metric.percentUsed, 20);
|
||||
const percentStr = `${metric.percentUsed}%`;
|
||||
const planUsageStr = `${formatBytesDisplay(metric.planValue)}/${formatBytesDisplay(
|
||||
metric.limit
|
||||
)}`;
|
||||
|
||||
let color = chalk.green;
|
||||
if (metric.percentUsed >= 100) {
|
||||
color = chalk.red;
|
||||
} else if (metric.percentUsed >= 85) {
|
||||
color = chalk.yellow;
|
||||
}
|
||||
|
||||
Log.log(`${indent}${metric.name} (plan): ${color(planUsageStr)}`);
|
||||
Log.log(`${indent}${progressBar} ${color(percentStr)}`);
|
||||
|
||||
if (metric.overageValue > 0) {
|
||||
Log.log(
|
||||
`${indent}${metric.name} (overage): ${chalk.red(
|
||||
formatBytesDisplay(metric.overageValue)
|
||||
)} (${formatCurrency(metric.overageCost)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function displayMetric(metric: UsageMetricDisplay, indent: string = ' '): void {
|
||||
const progressBar = createProgressBar(metric.percentUsed, 20);
|
||||
const percentStr = `${metric.percentUsed}%`;
|
||||
const planUsageStr = `${formatNumber(metric.planValue)}/${formatNumber(metric.limit)} ${
|
||||
metric.unit ?? ''
|
||||
}`;
|
||||
|
||||
let color = chalk.green;
|
||||
if (metric.percentUsed >= 100) {
|
||||
color = chalk.red;
|
||||
} else if (metric.percentUsed >= 85) {
|
||||
color = chalk.yellow;
|
||||
}
|
||||
|
||||
Log.log(`${indent}${metric.name} (plan): ${color(planUsageStr)}`);
|
||||
Log.log(`${indent}${progressBar} ${color(percentStr)}`);
|
||||
|
||||
if (metric.overageValue > 0) {
|
||||
Log.log(
|
||||
`${indent}${metric.name} (overage): ${chalk.red(
|
||||
`${formatNumber(metric.overageValue)} ${metric.unit ?? ''}`
|
||||
)} (${formatCurrency(metric.overageCost)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function billingUrl(accountName: string): string {
|
||||
return `https://expo.dev/accounts/${accountName}/settings/billing`;
|
||||
}
|
||||
|
||||
function displayUsage(data: UsageDisplayData, usageData: AccountFullUsageData): void {
|
||||
const subscription = usageData.subscription;
|
||||
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold(`Account: ${data.accountName}`));
|
||||
Log.log(`Plan: ${data.subscriptionPlan}`);
|
||||
if (subscription?.concurrencies) {
|
||||
Log.log(
|
||||
`Concurrencies: ${subscription.concurrencies.total} total (iOS: ${subscription.concurrencies.ios}, Android: ${subscription.concurrencies.android})`
|
||||
);
|
||||
}
|
||||
Log.log(
|
||||
`Billing Period: ${formatDate(data.billingPeriod.start)} - ${formatDate(
|
||||
data.billingPeriod.end
|
||||
)}`
|
||||
);
|
||||
|
||||
const periodInfo = calculateBillingPeriodInfo(data);
|
||||
Log.log(
|
||||
`Days: ${periodInfo.daysElapsed} elapsed / ${periodInfo.totalDays} total (${periodInfo.daysRemaining} remaining)`
|
||||
);
|
||||
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold.underline('EAS Build'));
|
||||
displayMetric(data.builds.total);
|
||||
if (data.builds.ios) {
|
||||
displayMetric(data.builds.ios, ' ');
|
||||
}
|
||||
if (data.builds.android) {
|
||||
displayMetric(data.builds.android, ' ');
|
||||
}
|
||||
|
||||
// Show build counts by platform and worker size
|
||||
if (data.builds.countsByPlatformAndSize.length > 0) {
|
||||
Log.log(' Breakdown by platform/worker:');
|
||||
for (const item of data.builds.countsByPlatformAndSize) {
|
||||
const platformName = item.platform === 'ios' ? 'iOS' : 'Android';
|
||||
Log.log(` ${platformName} ${item.resourceClass}: ${formatNumber(item.count)} builds`);
|
||||
}
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold.underline('EAS Update'));
|
||||
displayMetric(data.updates.mau);
|
||||
displayBandwidthMetric(data.updates.bandwidth);
|
||||
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold.underline('Billing'));
|
||||
|
||||
const upcomingInvoice = subscription?.upcomingInvoice;
|
||||
|
||||
// Show subscription addons
|
||||
if (subscription?.addons && subscription.addons.length > 0) {
|
||||
Log.log(' Addons:');
|
||||
for (const addon of subscription.addons) {
|
||||
Log.log(` ${addon.name}: ${addon.quantity}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show upcoming invoice line items
|
||||
if (upcomingInvoice?.lineItems && upcomingInvoice.lineItems.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log(' Upcoming invoice:');
|
||||
for (const lineItem of upcomingInvoice.lineItems) {
|
||||
const amount = lineItem.amount;
|
||||
const amountStr = amount < 0 ? chalk.green(formatCurrency(amount)) : formatCurrency(amount);
|
||||
Log.log(` ${lineItem.description}: ${amountStr}`);
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(` Invoice total: ${chalk.bold(formatCurrency(upcomingInvoice.total))}`);
|
||||
} else {
|
||||
// Fallback to the calculated estimate if no upcoming invoice
|
||||
if (data.recurringCents !== null && data.recurringCents > 0) {
|
||||
Log.log(` Base subscription: ${formatCurrency(data.recurringCents)}`);
|
||||
}
|
||||
|
||||
if (data.totalOverageCostCents > 0) {
|
||||
Log.log(` Current overages: ${chalk.yellow(formatCurrency(data.totalOverageCostCents))}`);
|
||||
if (data.builds.overageCostCents > 0) {
|
||||
Log.log(` Build overages: ${formatCurrency(data.builds.overageCostCents)}`);
|
||||
// Show breakdown by worker size
|
||||
for (const overage of data.builds.overagesByWorkerSize) {
|
||||
const platformName = overage.platform === AppPlatform.Ios ? 'iOS' : 'Android';
|
||||
const sizeName =
|
||||
overage.resourceClass === EasBuildBillingResourceClass.Large ? 'large' : 'medium';
|
||||
Log.log(
|
||||
` ${platformName} ${sizeName}: ${formatNumber(
|
||||
overage.count
|
||||
)} builds (${formatCurrency(overage.costCents)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.updates.overageCostCents > 0) {
|
||||
Log.log(` Update overages: ${formatCurrency(data.updates.overageCostCents)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Log.log(` Estimated bill: ${chalk.bold(formatCurrency(data.estimatedBillCents))}`);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(chalk.dim(`View detailed billing: ${link(billingUrl(data.accountName))}`));
|
||||
}
|
||||
|
||||
export default class AccountUsage extends EasCommand {
|
||||
static override description = 'view account usage and billing for the current cycle';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'ACCOUNT_NAME',
|
||||
description:
|
||||
'Account name to view usage for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EasJsonOnlyFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { ACCOUNT_NAME: accountName },
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(AccountUsage);
|
||||
|
||||
// Enable JSON output if either --json or --non-interactive is passed
|
||||
const json = jsonFlag || nonInteractive;
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} = await this.getContextAsync(AccountUsage, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// Find the target account
|
||||
const defaultAccount = actor.accounts[0];
|
||||
let targetAccount: { id: string; name: string };
|
||||
const availableAccounts = actor.accounts.map(a => a.name).join(', ');
|
||||
|
||||
if (accountName) {
|
||||
// First check if it's one of the user's accounts
|
||||
const found = actor.accounts.find(a => a.name === accountName);
|
||||
if (found) {
|
||||
targetAccount = found;
|
||||
} else {
|
||||
// Try to look up the account by name (user may have access via organization)
|
||||
try {
|
||||
const account = await AccountQuery.getByNameAsync(graphqlClient, accountName);
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
`Account "${accountName}" not found. Available accounts: ${availableAccounts}`
|
||||
);
|
||||
}
|
||||
targetAccount = account;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (nonInteractive) {
|
||||
throw new Error(
|
||||
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
|
||||
);
|
||||
} else if (actor.accounts.length === 1) {
|
||||
// Only one account, use it directly
|
||||
targetAccount = defaultAccount;
|
||||
} else {
|
||||
// Prompt user to select an account
|
||||
targetAccount = await selectAsync(
|
||||
'Select account to view usage for:',
|
||||
actor.accounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
})),
|
||||
{ initial: defaultAccount }
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora(`Fetching usage data for account ${targetAccount.name}`).start();
|
||||
|
||||
try {
|
||||
const currentDate = new Date();
|
||||
const { start, end } = await AccountQuery.getBillingPeriodAsync(
|
||||
graphqlClient,
|
||||
targetAccount.id,
|
||||
currentDate
|
||||
);
|
||||
const usageData = await AccountQuery.getFullUsageAsync(
|
||||
graphqlClient,
|
||||
targetAccount.id,
|
||||
currentDate,
|
||||
start,
|
||||
end
|
||||
);
|
||||
Log.debug(JSON.stringify(usageData, null, 2));
|
||||
|
||||
spinner.succeed(`Usage data loaded for ${targetAccount.name}`);
|
||||
|
||||
const displayData = extractUsageData(usageData);
|
||||
const periodInfo = calculateBillingPeriodInfo(displayData);
|
||||
|
||||
if (json) {
|
||||
const subscription = usageData.subscription;
|
||||
printJsonOnlyOutput({
|
||||
account: {
|
||||
name: displayData.accountName,
|
||||
plan: displayData.subscriptionPlan,
|
||||
concurrencies: subscription?.concurrencies ?? null,
|
||||
billingPeriod: {
|
||||
start: displayData.billingPeriod.start,
|
||||
end: displayData.billingPeriod.end,
|
||||
daysElapsed: periodInfo.daysElapsed,
|
||||
daysRemaining: periodInfo.daysRemaining,
|
||||
totalDays: periodInfo.totalDays,
|
||||
},
|
||||
},
|
||||
builds: {
|
||||
plan: {
|
||||
used: displayData.builds.total.planValue,
|
||||
limit: displayData.builds.total.limit,
|
||||
percentUsed: displayData.builds.total.percentUsed,
|
||||
},
|
||||
overage: {
|
||||
count: displayData.builds.total.overageValue,
|
||||
costCents: displayData.builds.total.overageCost,
|
||||
byWorkerSize: displayData.builds.overagesByWorkerSize.map(o => ({
|
||||
platform: o.platform.toLowerCase(),
|
||||
resourceClass: o.resourceClass.toLowerCase(),
|
||||
count: o.count,
|
||||
costCents: o.costCents,
|
||||
})),
|
||||
},
|
||||
byPlatformAndSize: displayData.builds.countsByPlatformAndSize.map(item => ({
|
||||
platform: item.platform,
|
||||
resourceClass: item.resourceClass,
|
||||
count: item.count,
|
||||
})),
|
||||
ios: displayData.builds.ios
|
||||
? {
|
||||
plan: {
|
||||
used: displayData.builds.ios.planValue,
|
||||
limit: displayData.builds.ios.limit,
|
||||
percentUsed: displayData.builds.ios.percentUsed,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
android: displayData.builds.android
|
||||
? {
|
||||
plan: {
|
||||
used: displayData.builds.android.planValue,
|
||||
limit: displayData.builds.android.limit,
|
||||
percentUsed: displayData.builds.android.percentUsed,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
updates: {
|
||||
uniqueUpdaters: {
|
||||
plan: {
|
||||
used: displayData.updates.mau.planValue,
|
||||
limit: displayData.updates.mau.limit,
|
||||
percentUsed: displayData.updates.mau.percentUsed,
|
||||
},
|
||||
overage: {
|
||||
count: displayData.updates.mau.overageValue,
|
||||
costCents: displayData.updates.mau.overageCost,
|
||||
},
|
||||
},
|
||||
bandwidth: {
|
||||
plan: {
|
||||
usedBytes: displayData.updates.bandwidth.planValue,
|
||||
usedFormatted: formatBytesDisplay(displayData.updates.bandwidth.planValue),
|
||||
limitBytes: displayData.updates.bandwidth.limit,
|
||||
limitFormatted: formatBytesDisplay(displayData.updates.bandwidth.limit),
|
||||
percentUsed: displayData.updates.bandwidth.percentUsed,
|
||||
},
|
||||
overage: {
|
||||
usedBytes: displayData.updates.bandwidth.overageValue,
|
||||
usedFormatted: formatBytesDisplay(displayData.updates.bandwidth.overageValue),
|
||||
costCents: displayData.updates.bandwidth.overageCost,
|
||||
},
|
||||
},
|
||||
overageCostCents: displayData.updates.overageCostCents,
|
||||
},
|
||||
billing: {
|
||||
addons: subscription?.addons ?? [],
|
||||
upcomingInvoice: subscription?.upcomingInvoice ?? null,
|
||||
estimated: {
|
||||
recurringCents: displayData.recurringCents,
|
||||
overageCents: displayData.totalOverageCostCents,
|
||||
totalCents: displayData.estimatedBillCents,
|
||||
},
|
||||
},
|
||||
billingUrl: billingUrl(displayData.accountName),
|
||||
});
|
||||
} else {
|
||||
displayUsage(displayData, usageData);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch usage data');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { scheduleChannelDeletionAsync } from '../../channel/delete';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
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';
|
||||
@@ -70,7 +77,7 @@ export default class ChannelDelete extends EasCommand {
|
||||
if (!nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`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.`
|
||||
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
@@ -80,14 +87,39 @@ export default class ChannelDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const receipt = await scheduleChannelDeletionAsync(graphqlClient, { channelId });
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
Log.debug('Deletion result', { successfulReceipt });
|
||||
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: channelId });
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
} 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;
|
||||
}
|
||||
+1
-1
@@ -107,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()}"`
|
||||
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -277,9 +276,8 @@ export default class ProjectInit extends EasCommand {
|
||||
throw new Error('No account selected for project. Canceling.');
|
||||
}
|
||||
|
||||
const projectName = validSlugName(exp.slug); // This filters out invalid characters
|
||||
const projectName = exp.slug;
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
validateFullNameAndSlug(projectFullName, projectName);
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import fs from 'fs-extra';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { AscApiClient, AscApiClientPostApi } from '../../submit/ios/AscApiClient';
|
||||
|
||||
export default class SubmitUploadToAsc extends EasCommand {
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
path: Flags.string({
|
||||
description: 'Path to the IPA file',
|
||||
required: true,
|
||||
}),
|
||||
key: Flags.string({
|
||||
description: 'Path to the ASC API Key JSON file',
|
||||
required: true,
|
||||
}),
|
||||
'app-id': Flags.string({
|
||||
description: 'App Store Connect App ID (e.g. 1491144534)',
|
||||
required: true,
|
||||
}),
|
||||
'bundle-version': Flags.string({
|
||||
description: 'CFBundleVersion (Build Version, e.g. 13)',
|
||||
required: true,
|
||||
}),
|
||||
'bundle-short-version': Flags.string({
|
||||
description: 'CFBundleShortVersionString (Marketing Version, e.g. 1.0.0)',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SubmitUploadToAsc);
|
||||
|
||||
const ipaPath = path.resolve(flags.path);
|
||||
if (!(await fs.pathExists(ipaPath))) {
|
||||
throw new Error(`IPA file not found: ${ipaPath}`);
|
||||
}
|
||||
const fileSize = (await fs.stat(ipaPath)).size;
|
||||
const fileName = path.basename(ipaPath);
|
||||
|
||||
const keyPath = path.resolve(flags.key);
|
||||
if (!(await fs.pathExists(keyPath))) {
|
||||
throw new Error(`Key file not found: ${keyPath}`);
|
||||
}
|
||||
|
||||
const keyJson = await fs.readJson(keyPath);
|
||||
const apiKey = z
|
||||
.object({
|
||||
issuer_id: z.string(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(keyJson);
|
||||
|
||||
Log.log('Generating JWT...');
|
||||
const token = jwt.sign({}, apiKey.key, {
|
||||
algorithm: 'ES256',
|
||||
issuer: apiKey.issuer_id,
|
||||
expiresIn: '20m',
|
||||
audience: 'appstoreconnect-v1',
|
||||
header: {
|
||||
kid: apiKey.key_id,
|
||||
},
|
||||
});
|
||||
|
||||
const client = new AscApiClient({ token });
|
||||
|
||||
Log.log('Reading App information...');
|
||||
const appResponse = await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] as const },
|
||||
{ id: flags['app-id'] }
|
||||
);
|
||||
Log.log(
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
|
||||
);
|
||||
|
||||
Log.log('Creating Build Upload...');
|
||||
const buildUploadResponse = await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: flags['bundle-short-version'],
|
||||
cfBundleVersion: flags['bundle-version'],
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: flags['app-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildUploadId = buildUploadResponse.data.id;
|
||||
|
||||
Log.log(`Build Upload initialized (ID: ${buildUploadId}). Preparing IPA upload...`);
|
||||
const buildFileResponse = await client.postAsync('/v1/buildUploadFiles', {
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
attributes: {
|
||||
assetType: 'ASSET',
|
||||
fileName,
|
||||
fileSize,
|
||||
uti: 'com.apple.ipa',
|
||||
},
|
||||
relationships: {
|
||||
buildUpload: {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: buildUploadId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Log.log(`IPA Upload initialized (ID: ${buildFileResponse.data.id}). Uploading IPA...`);
|
||||
await uploadChunksAsync({
|
||||
uploadOperations: buildFileResponse.data.attributes.uploadOperations,
|
||||
ipaPath,
|
||||
});
|
||||
|
||||
Log.log('Committing upload...');
|
||||
await client.patchAsync(
|
||||
`/v1/buildUploadFiles/:id`,
|
||||
{
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: buildFileResponse.data.id,
|
||||
attributes: {
|
||||
uploaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: buildFileResponse.data.id,
|
||||
}
|
||||
);
|
||||
|
||||
Log.log('Checking upload file status...');
|
||||
const waitingForFileStartedAt = performance.now();
|
||||
while (performance.now() - waitingForFileStartedAt < 60 * 1000 /* 60 seconds */) {
|
||||
const buildFileStatusResponse = await client.getAsync(
|
||||
`/v1/buildUploadFiles/:id`,
|
||||
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
|
||||
{ id: buildFileResponse.data.id }
|
||||
);
|
||||
|
||||
if (buildFileStatusResponse.data.attributes.assetDeliveryState.state === 'AWAITING_UPLOAD') {
|
||||
Log.log(
|
||||
`Waiting for file upload to finish processing... (state = ${buildFileStatusResponse.data.attributes.assetDeliveryState.state})`
|
||||
);
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { errors = [], warnings = [] } =
|
||||
buildFileStatusResponse.data.attributes.assetDeliveryState;
|
||||
if (warnings.length > 0) {
|
||||
Log.warn(
|
||||
`Warnings:\n- ${warnings.map(w => `${w.description} (${w.code})`).join('\n- ')}\n`
|
||||
);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
Log.error(`Errors:\n- ${errors.map(e => `${e.description} (${e.code})`).join('\n- ')}\n`);
|
||||
}
|
||||
|
||||
if (buildFileStatusResponse.data.attributes.assetDeliveryState.state === 'FAILED') {
|
||||
throw new Error(`File upload (ID: ${buildFileResponse.data.id}) failed.`);
|
||||
} else if (buildFileStatusResponse.data.attributes.assetDeliveryState.state === 'COMPLETE') {
|
||||
Log.log(`File upload (ID: ${buildFileResponse.data.id}) complete!`);
|
||||
} else if (
|
||||
buildFileStatusResponse.data.attributes.assetDeliveryState.state === 'UPLOAD_COMPLETE'
|
||||
) {
|
||||
Log.log(`File upload (ID: ${buildFileResponse.data.id}) finished!`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Log.log('Checking build upload status...');
|
||||
const waitingForBuildStartedAt = performance.now();
|
||||
while (performance.now() - waitingForBuildStartedAt < 60 * 1000 /* 60 seconds */) {
|
||||
const buildUploadStatusResponse = await client.getAsync(
|
||||
`/v1/buildUploads/:id`,
|
||||
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
|
||||
{ id: buildUploadId }
|
||||
);
|
||||
|
||||
if (
|
||||
buildUploadStatusResponse.data.attributes.state.state === 'AWAITING_UPLOAD' ||
|
||||
buildUploadStatusResponse.data.attributes.state.state === 'PROCESSING'
|
||||
) {
|
||||
Log.log(
|
||||
`Waiting for build upload to finish... (status = ${buildUploadStatusResponse.data.attributes.state.state})`
|
||||
);
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.log('\n');
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
warnings = [],
|
||||
infos = [],
|
||||
} = buildUploadStatusResponse.data.attributes.state;
|
||||
if (infos.length > 0) {
|
||||
Log.log(`Infos:\n- ${infos.map(i => `${i.description} (${i.code})`).join('\n- ')}\n`);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
Log.warn(
|
||||
`Warnings:\n- ${warnings.map(w => `${w.description} (${w.code})`).join('\n- ')}\n`
|
||||
);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
Log.error(`Errors:\n- ${errors.map(e => `${e.description} (${e.code})`).join('\n- ')}\n`);
|
||||
}
|
||||
|
||||
if (buildUploadStatusResponse.data.attributes.state.state === 'FAILED') {
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (buildUploadStatusResponse.data.attributes.state.state === 'COMPLETE') {
|
||||
Log.log(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadChunksAsync({
|
||||
uploadOperations,
|
||||
ipaPath,
|
||||
}: {
|
||||
uploadOperations: AscApiClientPostApi['/v1/buildUploadFiles']['response']['data']['attributes']['uploadOperations'];
|
||||
ipaPath: string;
|
||||
}): Promise<void> {
|
||||
const fd = await fs.open(ipaPath, 'r');
|
||||
try {
|
||||
const promises = uploadOperations.map(async (op, index) => {
|
||||
Log.log(
|
||||
` Uploading chunk ${index + 1}/${uploadOperations.length} (offset: ${op.offset}, length: ${
|
||||
op.length
|
||||
})...`
|
||||
);
|
||||
|
||||
const buffer = new Uint8Array(op.length);
|
||||
await fs.read(fd, buffer, 0, op.length, op.offset);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
};
|
||||
|
||||
for (const { name, value } of op.requestHeaders) {
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
const response = await fetch(op.url, {
|
||||
method: op.method,
|
||||
headers,
|
||||
body: buffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Failed to upload chunk ${index + 1}: ${response.status} ${response.statusText}\n${text}`
|
||||
);
|
||||
}
|
||||
Log.log(` Chunk ${index + 1}/${uploadOperations.length} uploaded.`);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
} finally {
|
||||
await fs.close(fd);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import UpdatePublish, { preprocessSourceMapsArg } from '..';
|
||||
import UpdatePublish from '..';
|
||||
import { ensureBranchExistsAsync } from '../../../branch/queries';
|
||||
import {
|
||||
DynamicPrivateProjectConfigContextField,
|
||||
@@ -328,34 +328,3 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
|
||||
return await PublishMutation.setRolloutPercentageAsync(
|
||||
graphqlClient,
|
||||
u.updateId,
|
||||
rolloutPercentageToSet
|
||||
rolloutPercentageToSet!
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,6 +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 { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import { getUpdateGroupUrl } from '../../build/utils/url';
|
||||
@@ -21,6 +22,7 @@ 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';
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
buildUnsortedUpdateInfoGroupAsync,
|
||||
collectAssetsAsync,
|
||||
filterCollectedAssetsByRequestedPlatforms,
|
||||
// findCompatibleBuildsAsync,
|
||||
generateEasMetadataAsync,
|
||||
getBranchNameForCommandAsync,
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
|
||||
@@ -65,28 +68,6 @@ 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;
|
||||
@@ -96,8 +77,6 @@ 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;
|
||||
@@ -115,8 +94,6 @@ type UpdateFlags = {
|
||||
inputDir: string;
|
||||
skipBundler: boolean;
|
||||
clearCache: boolean;
|
||||
noBytecode: boolean;
|
||||
sourceMaps?: string;
|
||||
privateKeyPath?: string;
|
||||
emitMetadata: boolean;
|
||||
rolloutPercentage?: number;
|
||||
@@ -155,16 +132,6 @@ 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,
|
||||
@@ -202,9 +169,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
// Preprocess argv to handle --source-maps with optional value
|
||||
const preprocessedArgv = preprocessSourceMapsArg(this.argv);
|
||||
const { flags: rawFlags } = await this.parse(UpdatePublish, preprocessedArgv);
|
||||
const { flags: rawFlags } = await this.parse(UpdatePublish);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
|
||||
const {
|
||||
auto: autoFlag,
|
||||
@@ -214,8 +179,6 @@ export default class UpdatePublish extends EasCommand {
|
||||
inputDir,
|
||||
skipBundler,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
privateKeyPath,
|
||||
json: jsonFlag,
|
||||
nonInteractive,
|
||||
@@ -301,8 +264,6 @@ export default class UpdatePublish extends EasCommand {
|
||||
exp,
|
||||
platformFlag: requestedPlatform,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
extraEnv: maybeServerEnv,
|
||||
});
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
@@ -636,6 +597,12 @@ 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
|
||||
@@ -707,9 +674,58 @@ 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;
|
||||
|
||||
@@ -739,8 +755,6 @@ 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,8 +8,6 @@ import {
|
||||
getLimitFlagWithCustomValues,
|
||||
getPaginatedQueryOptions,
|
||||
} from '../../commandUtils/pagination';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import {
|
||||
listAndRenderUpdateGroupsOnAppAsync,
|
||||
listAndRenderUpdateGroupsOnBranchAsync,
|
||||
@@ -29,14 +27,6 @@ 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,
|
||||
@@ -49,13 +39,7 @@ 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,
|
||||
platform: requestedPlatform,
|
||||
} = flags;
|
||||
const { branch: branchFlag, all, json: jsonFlag, 'non-interactive': nonInteractive } = flags;
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -68,16 +52,9 @@ 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 {
|
||||
@@ -85,7 +62,6 @@ export default class UpdateList extends EasCommand {
|
||||
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName: branchFlag,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
} else {
|
||||
@@ -111,20 +87,9 @@ 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;
|
||||
}
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export class SetUpTargetBuildCredentials {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
|
||||
const { app, entitlements, target } = this.options;
|
||||
const { app, entitlements } = this.options;
|
||||
|
||||
await ctx.bestEffortAppStoreAuthenticateAsync();
|
||||
|
||||
@@ -35,11 +35,7 @@ export class SetUpTargetBuildCredentials {
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
projectName: app.projectName,
|
||||
},
|
||||
{
|
||||
entitlements,
|
||||
usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications,
|
||||
parentBundleIdentifier: target.parentBundleIdentifier,
|
||||
}
|
||||
{ entitlements, usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications }
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -11,22 +11,9 @@ 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 {
|
||||
@@ -56,13 +43,7 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
options?: IosCapabilitiesOptions
|
||||
): Promise<void> {
|
||||
const context = getRequestContext(authCtx);
|
||||
|
||||
// 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();
|
||||
const spinner = ora(`Linking bundle identifier ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
let bundleId: BundleId | null;
|
||||
try {
|
||||
@@ -70,37 +51,23 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
bundleId = await BundleId.findAsync(context, { identifier: bundleIdentifier });
|
||||
|
||||
if (!bundleId) {
|
||||
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.text = `Registering bundle identifier ${chalk.dim(bundleIdentifier)}`;
|
||||
// If it doesn't exist, create it
|
||||
bundleId = await BundleId.createAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
});
|
||||
}
|
||||
spinner.succeed(
|
||||
`${isAppClip ? 'App Clip bundle' : 'Bundle'} identifier registered ${chalk.dim(
|
||||
bundleIdentifier
|
||||
)}`
|
||||
);
|
||||
spinner.succeed(`Bundle identifier registered ${chalk.dim(bundleIdentifier)}`);
|
||||
} catch (err: any) {
|
||||
if (err.message.match(/An App ID with Identifier '(.*)' is not available/)) {
|
||||
spinner.fail(
|
||||
`The ${typeLabel} ${chalk.bold(bundleIdentifier)} is not available to team "${
|
||||
`The bundle identifier ${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 ${typeLabel} ${chalk.dim(bundleIdentifier)}`);
|
||||
spinner.fail(`Failed to register bundle identifier ${chalk.dim(bundleIdentifier)}`);
|
||||
|
||||
// Assert contract errors for easier resolution when the user has an expired developer account.
|
||||
if (
|
||||
@@ -126,42 +93,6 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ManageIos } from './ManageIos';
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { selectPlatformWithExitOptionAsync } from '../../platform';
|
||||
import { selectPlatformAsync } from '../../platform';
|
||||
import { Actor } from '../../user/User';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
import { CredentialsContextProjectInfo } from '../context';
|
||||
@@ -20,7 +20,7 @@ export class SelectPlatform {
|
||||
) {}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const platform = await selectPlatformWithExitOptionAsync(this.flagPlatform);
|
||||
const platform = await selectPlatformAsync(this.flagPlatform);
|
||||
|
||||
if (platform === 'ios') {
|
||||
await new ManageIos(this, process.cwd()).runAsync();
|
||||
|
||||
+110
-951
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,8 @@ import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutation,
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutationVariables,
|
||||
GetWorkflowRevisionsFromGitRefMutation,
|
||||
GetWorkflowRevisionsFromGitRefMutationVariables,
|
||||
ValidateWorkflowYamlConfigMutation,
|
||||
ValidateWorkflowYamlConfigMutationVariables,
|
||||
WorkflowRevision,
|
||||
@@ -19,7 +21,53 @@ export namespace WorkflowRevisionMutation {
|
||||
fieldErrors: z.record(z.string(), z.array(z.string())),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function getWorkflowRevisionsFromGitRefAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
gitRef,
|
||||
}: {
|
||||
appId: string;
|
||||
gitRef: string;
|
||||
}
|
||||
): Promise<WorkflowRevision[] | undefined> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
GetWorkflowRevisionsFromGitRefMutation,
|
||||
GetWorkflowRevisionsFromGitRefMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation GetWorkflowRevisionsFromGitRef($appId: ID!, $gitRef: String!) {
|
||||
workflowRevision {
|
||||
getWorkflowRevisionsFromGitRef(appId: $appId, gitRef: $gitRef) {
|
||||
id
|
||||
yamlConfig
|
||||
blobSha
|
||||
commitSha
|
||||
createdAt
|
||||
workflow {
|
||||
id
|
||||
app {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
gitRef,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return (data.workflowRevision?.getWorkflowRevisionsFromGitRef as WorkflowRevision[]) ?? [];
|
||||
}
|
||||
export async function getOrCreateWorkflowRevisionFromGitRefAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountFullUsageQuery as AccountFullUsageQueryType,
|
||||
AccountFullUsageQueryVariables,
|
||||
AccountUsageForOverageWarningQuery,
|
||||
AccountUsageForOverageWarningQueryVariables,
|
||||
} from '../generated';
|
||||
import {
|
||||
AccountUsageMetricFragmentNode,
|
||||
BillingPeriodFragmentNode,
|
||||
SubscriptionDetailsFragmentNode,
|
||||
UsageMetricTotalFragmentNode,
|
||||
} from '../types/Account';
|
||||
|
||||
export type AccountFullUsageData = NonNullable<AccountFullUsageQueryType['account']['byId']>;
|
||||
|
||||
export const AccountQuery = {
|
||||
async getByNameAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountName: string
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<{ account: { byName: { id: string; name: string } | null } }>(
|
||||
gql`
|
||||
query AccountByName($accountName: String!) {
|
||||
account {
|
||||
byName(accountName: $accountName) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountName },
|
||||
{ additionalTypenames: ['Account'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byName;
|
||||
},
|
||||
|
||||
async getFullUsageAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AccountFullUsageData> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountFullUsageQueryType, AccountFullUsageQueryVariables>(
|
||||
gql`
|
||||
query AccountFullUsage(
|
||||
$accountId: String!
|
||||
$currentDate: DateTime!
|
||||
$startDate: DateTime!
|
||||
$endDate: DateTime!
|
||||
) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
name
|
||||
subscription {
|
||||
id
|
||||
...SubscriptionDetailsFragment
|
||||
}
|
||||
billingPeriod(date: $currentDate) {
|
||||
id
|
||||
...BillingPeriodFragment
|
||||
}
|
||||
usageMetrics {
|
||||
MEDIUM_ANDROID_BUILDS: metricsForServiceMetric(
|
||||
serviceMetric: BUILDS
|
||||
granularity: TOTAL
|
||||
timespan: { start: $startDate, end: $endDate }
|
||||
filterParams: {
|
||||
platform: "android"
|
||||
billingResourceClass: ["medium"]
|
||||
status: ["finished", "errored"]
|
||||
}
|
||||
) {
|
||||
id
|
||||
...AccountUsageMetricFragment
|
||||
}
|
||||
LARGE_ANDROID_BUILDS: metricsForServiceMetric(
|
||||
serviceMetric: BUILDS
|
||||
granularity: TOTAL
|
||||
timespan: { start: $startDate, end: $endDate }
|
||||
filterParams: {
|
||||
platform: "android"
|
||||
billingResourceClass: ["large"]
|
||||
status: ["finished", "errored"]
|
||||
}
|
||||
) {
|
||||
id
|
||||
...AccountUsageMetricFragment
|
||||
}
|
||||
MEDIUM_IOS_BUILDS: metricsForServiceMetric(
|
||||
serviceMetric: BUILDS
|
||||
granularity: TOTAL
|
||||
timespan: { start: $startDate, end: $endDate }
|
||||
filterParams: {
|
||||
platform: "ios"
|
||||
billingResourceClass: ["medium"]
|
||||
status: ["finished", "errored"]
|
||||
}
|
||||
) {
|
||||
id
|
||||
...AccountUsageMetricFragment
|
||||
}
|
||||
LARGE_IOS_BUILDS: metricsForServiceMetric(
|
||||
serviceMetric: BUILDS
|
||||
granularity: TOTAL
|
||||
timespan: { start: $startDate, end: $endDate }
|
||||
filterParams: {
|
||||
platform: "ios"
|
||||
billingResourceClass: ["large"]
|
||||
status: ["finished", "errored"]
|
||||
}
|
||||
) {
|
||||
id
|
||||
...AccountUsageMetricFragment
|
||||
}
|
||||
EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS) {
|
||||
id
|
||||
...UsageMetricTotalFragment
|
||||
}
|
||||
EAS_UPDATE: byBillingPeriod(date: $currentDate, service: UPDATES) {
|
||||
id
|
||||
...UsageMetricTotalFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(SubscriptionDetailsFragmentNode)}
|
||||
${print(BillingPeriodFragmentNode)}
|
||||
${print(AccountUsageMetricFragmentNode)}
|
||||
${print(UsageMetricTotalFragmentNode)}
|
||||
`,
|
||||
{
|
||||
accountId,
|
||||
currentDate: currentDate.toISOString(),
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
{
|
||||
additionalTypenames: [
|
||||
'Account',
|
||||
'AccountUsageMetrics',
|
||||
'UsageMetricTotal',
|
||||
'BillingPeriod',
|
||||
'EstimatedUsage',
|
||||
'EstimatedOverageAndCost',
|
||||
],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
if (!data.account.byId) {
|
||||
throw new Error(`Account with ID ${accountId} not found`);
|
||||
}
|
||||
|
||||
return data.account.byId;
|
||||
},
|
||||
|
||||
async getUsageForOverageWarningAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date
|
||||
): Promise<AccountUsageForOverageWarningQuery['account']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountUsageForOverageWarningQuery, AccountUsageForOverageWarningQueryVariables>(
|
||||
gql`
|
||||
query AccountUsageForOverageWarning($accountId: String!, $currentDate: DateTime!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
name
|
||||
subscription {
|
||||
id
|
||||
name
|
||||
}
|
||||
usageMetrics {
|
||||
EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS) {
|
||||
id
|
||||
planMetrics {
|
||||
id
|
||||
serviceMetric
|
||||
value
|
||||
limit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountId, currentDate: currentDate.toISOString() },
|
||||
{
|
||||
additionalTypenames: ['Account', 'AccountUsageMetrics', 'UsageMetricTotal'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId;
|
||||
},
|
||||
|
||||
async getBillingPeriodAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date
|
||||
): Promise<{
|
||||
start: Date;
|
||||
end: Date;
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<{
|
||||
account: {
|
||||
byId: {
|
||||
id: string;
|
||||
name: string;
|
||||
billingPeriod: { id: string; start: string; end: string; anchor: string };
|
||||
} | null;
|
||||
};
|
||||
}>(
|
||||
gql`
|
||||
query AccountBillingPeriod($accountId: String!, $currentDate: DateTime!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
name
|
||||
billingPeriod(date: $currentDate) {
|
||||
id
|
||||
...BillingPeriodFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(BillingPeriodFragmentNode)}
|
||||
`,
|
||||
{ accountId, currentDate: currentDate.toISOString() },
|
||||
{
|
||||
additionalTypenames: ['Account', 'BillingPeriod'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const { start, end } = data.account.byId?.billingPeriod ?? {};
|
||||
if (!start || !end) {
|
||||
throw new Error('Billing period data not found');
|
||||
}
|
||||
return {
|
||||
start: new Date(start),
|
||||
end: new Date(end),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountUsageForOverageWarningQuery,
|
||||
AccountUsageForOverageWarningQueryVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const AccountUsageQuery = {
|
||||
async getUsageForOverageWarningAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date
|
||||
): Promise<AccountUsageForOverageWarningQuery['account']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountUsageForOverageWarningQuery, AccountUsageForOverageWarningQueryVariables>(
|
||||
gql`
|
||||
query AccountUsageForOverageWarning($accountId: String!, $currentDate: DateTime!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
name
|
||||
subscription {
|
||||
id
|
||||
name
|
||||
}
|
||||
usageMetrics {
|
||||
EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS) {
|
||||
id
|
||||
planMetrics {
|
||||
id
|
||||
serviceMetric
|
||||
value
|
||||
limit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountId, currentDate: currentDate.toISOString() },
|
||||
{
|
||||
additionalTypenames: ['Account', 'AccountUsageMetrics', 'UsageMetricTotal'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable graphql/required-fields */
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const AccountFragmentNode = gql`
|
||||
@@ -18,153 +16,3 @@ export const AccountFragmentNode = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const InvoiceLineItemFragmentNode = gql`
|
||||
fragment InvoiceLineItemFragment on InvoiceLineItem {
|
||||
id
|
||||
description
|
||||
amount
|
||||
period {
|
||||
start
|
||||
end
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const InvoiceFragmentNode = gql`
|
||||
${print(InvoiceLineItemFragmentNode)}
|
||||
|
||||
fragment InvoiceFragment on Invoice {
|
||||
id
|
||||
total
|
||||
lineItems {
|
||||
...InvoiceLineItemFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ConcurrenciesFragmentNode = gql`
|
||||
fragment ConcurrenciesFragment on Concurrencies {
|
||||
total
|
||||
android
|
||||
ios
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddonDetailsFragmentNode = gql`
|
||||
fragment AddonDetailsFragment on AddonDetails {
|
||||
id
|
||||
name
|
||||
quantity
|
||||
}
|
||||
`;
|
||||
|
||||
export const SubscriptionDetailsFragmentNode = gql`
|
||||
${print(ConcurrenciesFragmentNode)}
|
||||
${print(AddonDetailsFragmentNode)}
|
||||
${print(InvoiceFragmentNode)}
|
||||
|
||||
fragment SubscriptionDetailsFragment on SubscriptionDetails {
|
||||
id
|
||||
name
|
||||
status
|
||||
nextInvoice
|
||||
nextInvoiceAmountDueCents
|
||||
recurringCents
|
||||
price
|
||||
concurrencies {
|
||||
...ConcurrenciesFragment
|
||||
}
|
||||
addons {
|
||||
...AddonDetailsFragment
|
||||
}
|
||||
upcomingInvoice {
|
||||
...InvoiceFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const BillingPeriodFragmentNode = gql`
|
||||
fragment BillingPeriodFragment on BillingPeriod {
|
||||
id
|
||||
start
|
||||
end
|
||||
anchor
|
||||
}
|
||||
`;
|
||||
|
||||
export const EstimatedUsagePlatformBreakdownFragmentNode = gql`
|
||||
fragment EstimatedUsagePlatformBreakdownFragment on EstimatedUsagePlatformBreakdown {
|
||||
ios {
|
||||
value
|
||||
limit
|
||||
}
|
||||
android {
|
||||
value
|
||||
limit
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const EstimatedUsageFragmentNode = gql`
|
||||
${print(EstimatedUsagePlatformBreakdownFragmentNode)}
|
||||
|
||||
fragment EstimatedUsageFragment on EstimatedUsage {
|
||||
id
|
||||
service
|
||||
serviceMetric
|
||||
metricType
|
||||
value
|
||||
limit
|
||||
platformBreakdown {
|
||||
...EstimatedUsagePlatformBreakdownFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const EstimatedOverageAndCostFragmentNode = gql`
|
||||
fragment EstimatedOverageAndCostFragment on EstimatedOverageAndCost {
|
||||
id
|
||||
service
|
||||
serviceMetric
|
||||
metricType
|
||||
value
|
||||
limit
|
||||
totalCost
|
||||
metadata {
|
||||
... on AccountUsageEASBuildMetadata {
|
||||
billingResourceClass
|
||||
platform
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UsageMetricTotalFragmentNode = gql`
|
||||
${print(BillingPeriodFragmentNode)}
|
||||
${print(EstimatedUsageFragmentNode)}
|
||||
${print(EstimatedOverageAndCostFragmentNode)}
|
||||
|
||||
fragment UsageMetricTotalFragment on UsageMetricTotal {
|
||||
id
|
||||
billingPeriod {
|
||||
...BillingPeriodFragment
|
||||
}
|
||||
planMetrics {
|
||||
...EstimatedUsageFragment
|
||||
}
|
||||
overageMetrics {
|
||||
...EstimatedOverageAndCostFragment
|
||||
}
|
||||
totalCost
|
||||
}
|
||||
`;
|
||||
|
||||
export const AccountUsageMetricFragmentNode = gql`
|
||||
fragment AccountUsageMetricFragment on AccountUsageMetric {
|
||||
id
|
||||
serviceMetric
|
||||
metricType
|
||||
value
|
||||
}
|
||||
`;
|
||||
@@ -12,6 +12,12 @@ jest.mock('../../config/writer');
|
||||
|
||||
describe(AgeRatingTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AgeRatingTask().prepareAsync({ context: {} as any });
|
||||
|
||||
await expect(promise).rejects.toThrow('not prepared');
|
||||
});
|
||||
|
||||
it('loads age rating from app version', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
|
||||
|
||||
@@ -35,16 +35,14 @@ describe(AppInfoTask, () => {
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('skips when app info is not loaded', async () => {
|
||||
it('aborts when app info is not loaded', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new AppInfoTask().downloadAsync({
|
||||
const promise = new AppInfoTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { info: undefined, infoLocales: [] } as any,
|
||||
});
|
||||
|
||||
// Should not call any setter methods when info is not available
|
||||
expect(writer.setCategories).not.toBeCalled();
|
||||
await expect(promise).rejects.toThrow('info not initialized');
|
||||
});
|
||||
|
||||
it('sets categories when info is loaded', async () => {
|
||||
|
||||
@@ -11,6 +11,12 @@ jest.mock('../../config/writer');
|
||||
|
||||
describe(AppReviewDetailTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AppReviewDetailTask().prepareAsync({ context: {} as any });
|
||||
|
||||
await expect(promise).rejects.toThrow('version not init');
|
||||
});
|
||||
|
||||
it('loads review details from app version', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersion.type}/stub-id/appStoreReviewDetail`)
|
||||
|
||||
@@ -254,16 +254,13 @@ describe(AppVersionTask, () => {
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('skips when version is not loaded', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new AppVersionTask().downloadAsync({
|
||||
config: writer,
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AppVersionTask().downloadAsync({
|
||||
config: new AppleConfigWriter(),
|
||||
context: { version: undefined } as any,
|
||||
});
|
||||
|
||||
// Should not call any setter methods when version is not available
|
||||
expect(writer.setVersion).not.toBeCalled();
|
||||
await expect(promise).rejects.toThrow('not initialized');
|
||||
});
|
||||
|
||||
it('sets version when loaded', async () => {
|
||||
|
||||
@@ -15,7 +15,8 @@ export class AgeRatingTask extends AppleTask {
|
||||
public name = (): string => 'age rating declarations';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
|
||||
assert(context.version, `App version information is not prepared, can't update age rating`);
|
||||
context.ageRating = (await context.version.getAgeRatingDeclarationAsync()) ?? undefined;
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
|
||||
@@ -18,32 +18,19 @@ export class AppInfoTask extends AppleTask {
|
||||
public name = (): string => 'app information';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
let info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
|
||||
|
||||
// Fallback to live app info if no editable info is found (for metadata:pull)
|
||||
if (!info) {
|
||||
info = await context.app.getLiveAppInfoAsync();
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
// No app info available - this is OK for metadata:pull
|
||||
// when the app hasn't been set up yet
|
||||
return;
|
||||
}
|
||||
const info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
|
||||
assert(info, 'Could not resolve the editable app info to update');
|
||||
|
||||
context.info = info;
|
||||
context.infoLocales = await info.getLocalizationsAsync();
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
if (!context.info) {
|
||||
// No app info available to download - skip silently
|
||||
return;
|
||||
}
|
||||
assert(context.info, `App info not initialized, can't download info`);
|
||||
|
||||
config.setCategories(context.info.attributes);
|
||||
|
||||
for (const locale of context.infoLocales ?? []) {
|
||||
for (const locale of context.infoLocales) {
|
||||
config.setInfoLocale(locale.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
public name = (): string => 'app review detail';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
|
||||
assert(context.version, `App version not initialized, can't download store review details`);
|
||||
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
|
||||
@@ -57,11 +57,7 @@ export class AppVersionTask extends AppleTask {
|
||||
this.options
|
||||
);
|
||||
|
||||
if (!version) {
|
||||
// No live or editable version available - this is OK for metadata:pull
|
||||
// when the app hasn't been submitted yet
|
||||
return;
|
||||
}
|
||||
assert(version, 'Could not resolve a live or editable app version');
|
||||
|
||||
context.version = version;
|
||||
context.versionIsFirst = versionIsFirst;
|
||||
@@ -71,16 +67,13 @@ export class AppVersionTask extends AppleTask {
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
if (!context.version) {
|
||||
// No version available to download - skip silently
|
||||
return;
|
||||
}
|
||||
assert(context.version, `App version not initialized, can't download version`);
|
||||
|
||||
config.setVersion(context.version.attributes);
|
||||
config.setVersionReleaseType(context.version.attributes);
|
||||
config.setVersionReleasePhased(context.versionPhasedRelease?.attributes);
|
||||
|
||||
for (const locale of context.versionLocales ?? []) {
|
||||
for (const locale of context.versionLocales) {
|
||||
config.setVersionLocale(locale.attributes);
|
||||
}
|
||||
}
|
||||
@@ -218,12 +211,6 @@ async function resolveVersionAsync(
|
||||
version = await retryIfNullAsync(() => app.getEditAppStoreVersionAsync({ platform }));
|
||||
}
|
||||
|
||||
// Fallback to live version if no editable version is found (for metadata:pull)
|
||||
if (!version) {
|
||||
version = await app.getLiveAppStoreVersionAsync({ platform });
|
||||
versionIsLive = !!version;
|
||||
}
|
||||
|
||||
const versions = await app.getAppStoreVersionsAsync({
|
||||
query: { limit: 2, filter: { platform } },
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { AppPlatform } from './graphql/generated';
|
||||
import Log from './log';
|
||||
import { promptAsync } from './prompts';
|
||||
|
||||
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
|
||||
@@ -48,42 +47,21 @@ export async function selectRequestedPlatformAsync(platform?: string): Promise<R
|
||||
return requestedPlatform;
|
||||
}
|
||||
|
||||
export async function selectPlatformWithExitOptionAsync(platform?: string): Promise<Platform> {
|
||||
return await selectPlatformInternalAsync(platform, true);
|
||||
}
|
||||
export async function selectPlatformAsync(platform?: string): Promise<Platform> {
|
||||
return await selectPlatformInternalAsync(platform, false);
|
||||
}
|
||||
|
||||
async function selectPlatformInternalAsync(
|
||||
platform?: string,
|
||||
allowExit?: boolean
|
||||
): Promise<Platform> {
|
||||
if (platform && Object.values(Platform).includes(platform.toLowerCase() as Platform)) {
|
||||
return platform.toLowerCase() as Platform;
|
||||
}
|
||||
|
||||
const platformChoices: { title: string; value: Platform | 'Exit' }[] = [
|
||||
{ title: 'Android', value: Platform.ANDROID },
|
||||
{ title: 'iOS', value: Platform.IOS },
|
||||
];
|
||||
|
||||
if (allowExit) {
|
||||
platformChoices.push({ title: 'Exit', value: 'Exit' });
|
||||
}
|
||||
|
||||
const result: any = await promptAsync({
|
||||
const { resolvedPlatform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Select platform',
|
||||
name: 'resolvedPlatform',
|
||||
choices: platformChoices,
|
||||
choices: [
|
||||
{ title: 'Android', value: Platform.ANDROID },
|
||||
{ title: 'iOS', value: Platform.IOS },
|
||||
],
|
||||
});
|
||||
if (result.resolvedPlatform === 'Exit') {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Exiting');
|
||||
process.exit(0);
|
||||
}
|
||||
return result.resolvedPlatform;
|
||||
return resolvedPlatform;
|
||||
}
|
||||
|
||||
export function toPlatforms(requestedPlatform: RequestedPlatform): Platform[] {
|
||||
|
||||
@@ -9,11 +9,9 @@ import { AssetMetadataStatus } from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import { PublishQuery } from '../../graphql/queries/PublishQuery';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
import {
|
||||
MetadataJoi,
|
||||
RawAsset,
|
||||
buildBundlesAsync,
|
||||
buildUnsortedUpdateInfoGroupAsync,
|
||||
collectAssetsAsync,
|
||||
convertAssetToUpdateInfoGroupFormatAsync,
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
getAssetHashFromPath,
|
||||
getBase64URLEncoding,
|
||||
getOriginalPathFromAssetMap,
|
||||
getSourceMapExportCommandArgs,
|
||||
getStorageKey,
|
||||
getStorageKeyForAssetAsync,
|
||||
guessContentTypeFromExtension,
|
||||
@@ -33,11 +30,6 @@ import {
|
||||
|
||||
jest.mock('../../uploads');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../utils/expoCli', () => ({
|
||||
expoCommandAsync: jest.fn(),
|
||||
shouldUseVersionedExpoCLI: jest.fn(() => true),
|
||||
shouldUseVersionedExpoCLIWithExplicitPlatforms: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
const dummyFileBuffer = Buffer.from('dummy-file');
|
||||
|
||||
@@ -710,142 +702,3 @@ describe(uploadAssetsAsync, () => {
|
||||
expect(onAssetUploadResultsChangedFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSourceMapExportCommandArgs, () => {
|
||||
it('returns --dump-sourcemap when sourceMaps is undefined, empty array when "false"', () => {
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: undefined, sdkVersion: '50.0.0' })).toEqual([
|
||||
'--dump-sourcemap',
|
||||
]);
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'false', sdkVersion: '55.0.0' })).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('returns --source-maps with value only for SDK >= 55', () => {
|
||||
// SDK < 55: no value
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'inline', sdkVersion: '50.0.0' })).toEqual([
|
||||
'--source-maps',
|
||||
]);
|
||||
// SDK >= 55: includes value
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'inline', sdkVersion: '55.0.0' })).toEqual([
|
||||
'--source-maps',
|
||||
'inline',
|
||||
]);
|
||||
// "true" never includes a value
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'true', sdkVersion: '55.0.0' })).toEqual([
|
||||
'--source-maps',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildBundlesAsync, () => {
|
||||
const projectDir = '/test-project';
|
||||
const inputDir = 'dist';
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(projectDir);
|
||||
});
|
||||
|
||||
it('uses --source-maps instead of --dump-sourcemap when provided', async () => {
|
||||
// SDK < 55: --source-maps is a boolean flag, value not supported
|
||||
// When sourceMaps is 'inline' but SDK < 55, pass --source-maps without the value
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'inline',
|
||||
});
|
||||
let args = jest.mocked(expoCommandAsync).mock.calls[0][1];
|
||||
expect(args).toContain('--source-maps');
|
||||
expect(args).not.toContain('inline');
|
||||
expect(args).not.toContain('--dump-sourcemap');
|
||||
|
||||
// SDK >= 55: --source-maps supports a value (e.g., 'inline')
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '55.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'inline',
|
||||
});
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
expect.arrayContaining(['--source-maps', 'inline']),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap');
|
||||
|
||||
// When sourceMaps is 'true', pass --source-maps without a value (regardless of SDK version)
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '55.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'true',
|
||||
});
|
||||
args = jest.mocked(expoCommandAsync).mock.calls[0][1];
|
||||
expect(args).toContain('--source-maps');
|
||||
expect(args).not.toContain('true');
|
||||
expect(args).not.toContain('--dump-sourcemap');
|
||||
|
||||
// When sourceMaps is "false", don't pass any source map flags
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'false',
|
||||
});
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap');
|
||||
|
||||
// When sourceMaps is undefined, fall back to --dump-sourcemap
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
});
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).toContain('--dump-sourcemap');
|
||||
});
|
||||
|
||||
it('passes --no-bytecode only when true', async () => {
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
noBytecode: true,
|
||||
});
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
expect.arrayContaining(['--no-bytecode']),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
noBytecode: false,
|
||||
});
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--no-bytecode');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import mime from 'mime';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import semver from 'semver';
|
||||
|
||||
import { maybeUploadFingerprintAsync } from './maybeUploadFingerprintAsync';
|
||||
import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync } from './projectUtils';
|
||||
@@ -209,8 +208,6 @@ export async function buildBundlesAsync({
|
||||
exp,
|
||||
platformFlag,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
extraEnv,
|
||||
}: {
|
||||
projectDir: string;
|
||||
@@ -218,8 +215,6 @@ export async function buildBundlesAsync({
|
||||
exp: Pick<ExpoConfig, 'sdkVersion' | 'web'>;
|
||||
platformFlag: ExpoCLIExportPlatformFlag;
|
||||
clearCache?: boolean;
|
||||
noBytecode?: boolean;
|
||||
sourceMaps?: string;
|
||||
extraEnv?: Record<string, string | undefined> | undefined;
|
||||
}): Promise<void> {
|
||||
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
|
||||
@@ -257,19 +252,16 @@ export async function buildBundlesAsync({
|
||||
? ['--platform', 'ios', '--platform', 'android']
|
||||
: ['--platform', platformFlag];
|
||||
|
||||
const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion });
|
||||
|
||||
await expoCommandAsync(
|
||||
projectDir,
|
||||
[
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
...sourceMapArgs,
|
||||
'--dump-sourcemap',
|
||||
'--dump-assetmap',
|
||||
...platformArgs,
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
...(noBytecode ? ['--no-bytecode'] : []),
|
||||
],
|
||||
{
|
||||
extraEnv,
|
||||
@@ -288,19 +280,16 @@ export async function buildBundlesAsync({
|
||||
);
|
||||
}
|
||||
|
||||
const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion });
|
||||
|
||||
await expoCommandAsync(
|
||||
projectDir,
|
||||
[
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
...sourceMapArgs,
|
||||
'--dump-sourcemap',
|
||||
'--dump-assetmap',
|
||||
`--platform=${platformFlag}`,
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
...(noBytecode ? ['--no-bytecode'] : []),
|
||||
],
|
||||
{
|
||||
extraEnv,
|
||||
@@ -1122,34 +1111,3 @@ export async function getUpdateRolloutInfoGroupAsync(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command line arguments for source map generation in expo export.
|
||||
*
|
||||
* Uses --source-maps if provided, otherwise falls back to --dump-sourcemap.
|
||||
* SDK 55+ supports --source-maps with a value (e.g., 'inline'), but older SDKs
|
||||
* only support it as a boolean flag. Passing a value to older SDKs causes it
|
||||
* to be parsed as the project root positional argument.
|
||||
*/
|
||||
export function getSourceMapExportCommandArgs({
|
||||
sourceMaps,
|
||||
sdkVersion,
|
||||
}: {
|
||||
sourceMaps: string | undefined;
|
||||
sdkVersion: string | undefined;
|
||||
}): string[] {
|
||||
if (!sourceMaps) {
|
||||
return ['--dump-sourcemap'];
|
||||
}
|
||||
|
||||
if (sourceMaps === 'false') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const supportsSourceMapModes = sdkVersion && semver.satisfies(sdkVersion, '>=55.0.0');
|
||||
if (supportsSourceMapModes && sourceMaps !== 'true') {
|
||||
return ['--source-maps', sourceMaps];
|
||||
}
|
||||
|
||||
return ['--source-maps'];
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fetch from 'node-fetch';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
import Log from '../../log';
|
||||
|
||||
type ApiSchema = {
|
||||
[Path in string]: {
|
||||
path?: z.ZodType<Record<string, string>>;
|
||||
request: z.ZodType<unknown>;
|
||||
response: z.ZodType<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const GetApi = {
|
||||
'/v1/apps/:id': {
|
||||
path: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
request: z.object({
|
||||
'fields[apps]': z.array(z.enum(['bundleId', 'name'])).refine(opts => {
|
||||
// Let's say we currently require fetching these two and nothing else.
|
||||
return opts.includes('bundleId') && opts.includes('name');
|
||||
}),
|
||||
}),
|
||||
response: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('apps'),
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
bundleId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/v1/buildUploadFiles/:id': {
|
||||
path: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
request: z.object({
|
||||
'fields[buildUploadFiles]': z.array(z.enum(['assetDeliveryState'])).refine(opts => {
|
||||
return opts.includes('assetDeliveryState');
|
||||
}),
|
||||
}),
|
||||
response: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('buildUploadFiles'),
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/appmediaassetstate
|
||||
assetDeliveryState: z.object({
|
||||
state: z.enum(['AWAITING_UPLOAD', 'UPLOAD_COMPLETE', 'COMPLETE', 'FAILED']),
|
||||
errors: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
warnings: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/v1/buildUploads/:id': {
|
||||
path: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
request: z.object({
|
||||
'fields[buildUploads]': z.array(z.enum(['build', 'state'])).refine(opts => {
|
||||
return opts.includes('build') && opts.includes('state');
|
||||
}),
|
||||
include: z.array(z.enum(['build'])).refine(opts => {
|
||||
return opts.includes('build');
|
||||
}),
|
||||
}),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadresponse
|
||||
response: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('buildUploads'),
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/buildupload/attributes-data.dictionary/state-data.dictionary
|
||||
state: z.object({
|
||||
state: z.enum(['AWAITING_UPLOAD', 'PROCESSING', 'COMPLETE', 'FAILED']),
|
||||
infos: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
errors: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
warnings: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
const PostApi = {
|
||||
'/v1/buildUploads': {
|
||||
request: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary
|
||||
data: z.object({
|
||||
type: z.literal('buildUploads'),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary/attributes-data.dictionary
|
||||
attributes: z.object({
|
||||
cfBundleShortVersionString: z.string(),
|
||||
cfBundleVersion: z.string(),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/platform
|
||||
platform: z.enum(['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS']),
|
||||
}),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary/relationships-data.dictionary
|
||||
relationships: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary/relationships-data.dictionary/app-data.dictionary
|
||||
app: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary/relationships-data.dictionary/app-data.dictionary/data-data.dictionary
|
||||
data: z.object({
|
||||
type: z.literal('apps'),
|
||||
id: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadresponse
|
||||
response: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/buildupload
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('buildUploads'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'/v1/buildUploadFiles': {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfilecreaterequest
|
||||
request: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfilecreaterequest/data-data.dictionary
|
||||
data: z.object({
|
||||
type: z.literal('buildUploadFiles'),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfilecreaterequest/data-data.dictionary/attributes-data.dictionary
|
||||
attributes: z.object({
|
||||
assetType: z.enum(['ASSET', 'ASSET_DESCRIPTION', 'ASSET_SPI']),
|
||||
fileName: z.string(),
|
||||
fileSize: z.number().min(1).max(9007199254740991),
|
||||
uti: z.enum([
|
||||
'com.apple.binary-property-list',
|
||||
'com.apple.ipa',
|
||||
'com.apple.pkg',
|
||||
'com.apple.xml-property-list',
|
||||
'com.pkware.zip-archive',
|
||||
]),
|
||||
}),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfilecreaterequest/data-data.dictionary/relationships-data.dictionary
|
||||
relationships: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfilecreaterequest/data-data.dictionary/relationships-data.dictionary/buildupload-data.dictionary
|
||||
buildUpload: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfilecreaterequest/data-data.dictionary/relationships-data.dictionary/buildupload-data.dictionary/data-data.dictionary
|
||||
data: z.object({
|
||||
type: z.literal('buildUploads'),
|
||||
id: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfileresponse
|
||||
response: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfile
|
||||
data: z.object({
|
||||
type: z.literal('buildUploadFiles'),
|
||||
id: z.string(),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfile/attributes-data.dictionary
|
||||
attributes: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/appmediaassetstate
|
||||
uploadOperations: z.array(
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/deliveryfileuploadoperation
|
||||
z.object({
|
||||
length: z.number().min(1).max(9007199254740991),
|
||||
method: z.string(),
|
||||
offset: z.number().min(0).max(9007199254740991),
|
||||
partNumber: z.number().min(1).max(9007199254740991),
|
||||
requestHeaders: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
),
|
||||
url: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
const PatchApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builduploadfiles-_id_
|
||||
'/v1/buildUploadFiles/:id': {
|
||||
path: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
request: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfileupdaterequest/data-data.dictionary
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('buildUploadFiles'),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfileupdaterequest/data-data.dictionary/attributes-data.dictionary
|
||||
attributes: z.object({
|
||||
uploaded: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
response: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfile
|
||||
data: z.object({
|
||||
type: z.literal('buildUploadFiles'),
|
||||
id: z.string(),
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadfile/attributes-data.dictionary
|
||||
attributes: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/appmediaassetstate
|
||||
assetDeliveryState: z.object({
|
||||
state: z.enum(['AWAITING_UPLOAD', 'UPLOAD_COMPLETE', 'COMPLETE', 'FAILED']),
|
||||
errors: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
warnings: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
export type AscApiClientPostApi = {
|
||||
[Path in keyof typeof PostApi]: {
|
||||
request: z.input<(typeof PostApi)[Path]['request']>;
|
||||
response: z.output<(typeof PostApi)[Path]['response']>;
|
||||
};
|
||||
};
|
||||
|
||||
export type AscApiClientPatchApi = {
|
||||
[Path in keyof typeof PatchApi]: {
|
||||
request: z.input<(typeof PatchApi)[Path]['request']>;
|
||||
response: z.output<(typeof PatchApi)[Path]['response']>;
|
||||
};
|
||||
};
|
||||
|
||||
export class AscApiClient {
|
||||
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
|
||||
private readonly token: string;
|
||||
|
||||
constructor({ token }: { token: string }) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public async getAsync<TPath extends keyof typeof GetApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof GetApi)[TPath]['request']>,
|
||||
params?: z.input<(typeof GetApi)[TPath]['path']>
|
||||
): Promise<z.output<(typeof GetApi)[TPath]['response']>> {
|
||||
const schema = GetApi[path];
|
||||
|
||||
let effectivePath: string = path;
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
effectivePath = effectivePath.replace(`:${key}`, String(value));
|
||||
}
|
||||
|
||||
return await this.sendRequestAsync({
|
||||
method: 'GET',
|
||||
path: `${effectivePath}?${new URLSearchParams(body).toString()}`,
|
||||
body,
|
||||
requestSchema: schema.request,
|
||||
responseSchema: schema.response,
|
||||
});
|
||||
}
|
||||
|
||||
public async postAsync<TPath extends keyof typeof PostApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>
|
||||
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
|
||||
const schema = PostApi[path];
|
||||
return await this.sendRequestAsync({
|
||||
method: 'POST',
|
||||
path,
|
||||
body,
|
||||
requestSchema: schema.request,
|
||||
responseSchema: schema.response,
|
||||
});
|
||||
}
|
||||
|
||||
public async patchAsync<TPath extends keyof typeof PatchApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PatchApi)[TPath]['request']>,
|
||||
params: z.input<(typeof PatchApi)[TPath]['path']>
|
||||
): Promise<z.output<(typeof PatchApi)[TPath]['response']>> {
|
||||
const schema = PatchApi[path];
|
||||
|
||||
let effectivePath: string = path;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
effectivePath = effectivePath.replace(`:${key}`, String(value));
|
||||
}
|
||||
|
||||
return await this.sendRequestAsync({
|
||||
method: 'PATCH',
|
||||
path: effectivePath,
|
||||
body,
|
||||
requestSchema: schema.request,
|
||||
responseSchema: schema.response,
|
||||
});
|
||||
}
|
||||
|
||||
private async sendRequestAsync({
|
||||
path,
|
||||
method,
|
||||
body,
|
||||
requestSchema,
|
||||
responseSchema,
|
||||
}: {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
path: string;
|
||||
body: unknown;
|
||||
requestSchema: z.ZodType<any>;
|
||||
responseSchema: z.ZodType<any>;
|
||||
}): Promise<any> {
|
||||
const url = new URL(path, this.baseUrl).toString();
|
||||
|
||||
const parsedBody = await asyncResult((async () => requestSchema.parse(body))());
|
||||
if (!parsedBody.ok) {
|
||||
throw new Error(
|
||||
`Malformed request to App Store Connect: ${z.prettifyError(
|
||||
parsedBody.enforceError() as ZodError
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
body: method === 'GET' ? undefined : JSON.stringify(parsedBody.value),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Unexpected response (${response.status}) from App Store Connect: ${text}`, {
|
||||
cause: response,
|
||||
});
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
Log.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
|
||||
|
||||
const parsedResponse = await asyncResult((async () => responseSchema.parse(json))());
|
||||
if (!parsedResponse.ok) {
|
||||
throw new Error(
|
||||
`Malformed response from App Store Connect: ${z.prettifyError(
|
||||
parsedResponse.enforceError() as ZodError
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return parsedResponse.value;
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient } from '../AscApiClient';
|
||||
|
||||
describe(AscApiClient, () => {
|
||||
const token = 'test-token';
|
||||
const client = new AscApiClient({ token });
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('fetches app info', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = require('./fixtures/apps/get-apps-200.json');
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(200, responseFixture);
|
||||
|
||||
const result = await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: appId }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: appId,
|
||||
attributes: {
|
||||
name: 'turtle-cli-example',
|
||||
bundleId: 'com.expo.turtle.cli.example',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates build upload', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const responseFixture = require('./fixtures/buildUploads/post-buildUploads-200.json');
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.post('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: '1.0',
|
||||
cfBundleVersion: '13',
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: '1491144534',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.reply(200, responseFixture);
|
||||
|
||||
const result = await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: '1.0',
|
||||
cfBundleVersion: '13',
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: '1491144534',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: buildUploadId,
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates build upload file', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const fileId = '5b110930-f947-4998-a129-5926ffcedde5';
|
||||
const responseFixture = require('./fixtures/buildUploadFiles/post-buildUploadFiles-201.json');
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.post('/v1/buildUploadFiles', {
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
attributes: {
|
||||
assetType: 'ASSET',
|
||||
fileName: 'app.ipa',
|
||||
fileSize: 359,
|
||||
uti: 'com.apple.ipa',
|
||||
},
|
||||
relationships: {
|
||||
buildUpload: {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: buildUploadId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.reply(201, responseFixture);
|
||||
|
||||
const result = await client.postAsync('/v1/buildUploadFiles', {
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
attributes: {
|
||||
assetType: 'ASSET',
|
||||
fileName: 'app.ipa',
|
||||
fileSize: 359,
|
||||
uti: 'com.apple.ipa',
|
||||
},
|
||||
relationships: {
|
||||
buildUpload: {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: buildUploadId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: fileId,
|
||||
attributes: {
|
||||
uploadOperations: [
|
||||
{
|
||||
method: 'PUT',
|
||||
url: 'https://storage/upload',
|
||||
length: 359,
|
||||
offset: 0,
|
||||
requestHeaders: [
|
||||
{
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
},
|
||||
],
|
||||
partNumber: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('commits upload', async () => {
|
||||
const fileId = '5b110930-f947-4998-a129-5926ffcedde5';
|
||||
const responseFixture = require('./fixtures/buildUploadFiles/patch-buildUploadFiles-200.json');
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/buildUploadFiles/${fileId}`, {
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: fileId,
|
||||
attributes: {
|
||||
uploaded: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
.reply(200, responseFixture);
|
||||
|
||||
const result = await client.patchAsync(
|
||||
'/v1/buildUploadFiles/:id',
|
||||
{
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: fileId,
|
||||
attributes: {
|
||||
uploaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: fileId }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: fileId,
|
||||
attributes: {
|
||||
assetDeliveryState: responseFixture.data.attributes.assetDeliveryState,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches build upload info', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const responseFixture = require('./fixtures/buildUploads/get-buildUploads-200.json');
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/buildUploads/${buildUploadId}`)
|
||||
.query({
|
||||
'fields[buildUploads]': 'build,state',
|
||||
include: 'build',
|
||||
})
|
||||
.reply(200, responseFixture);
|
||||
|
||||
const result = await client.getAsync(
|
||||
'/v1/buildUploads/:id',
|
||||
{
|
||||
'fields[buildUploads]': ['build', 'state'],
|
||||
include: ['build'],
|
||||
},
|
||||
{ id: buildUploadId }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: buildUploadId,
|
||||
attributes: {
|
||||
state: responseFixture.data.attributes.state,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches build upload file info', async () => {
|
||||
const fileId = '5b110930-f947-4998-a129-5926ffcedde5';
|
||||
const responseFixture = require('./fixtures/buildUploadFiles/get-buildUploadFiles-200.json');
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/buildUploadFiles/${fileId}`)
|
||||
.query({
|
||||
'fields[buildUploadFiles]': 'assetDeliveryState',
|
||||
})
|
||||
.reply(200, responseFixture);
|
||||
|
||||
const result = await client.getAsync(
|
||||
'/v1/buildUploadFiles/:id',
|
||||
{
|
||||
'fields[buildUploadFiles]': ['assetDeliveryState'],
|
||||
},
|
||||
{ id: fileId }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: fileId,
|
||||
attributes: {
|
||||
assetDeliveryState: responseFixture.data.attributes.assetDeliveryState,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "apps",
|
||||
"id": "1491144534",
|
||||
"attributes": {
|
||||
"name": "turtle-cli-example",
|
||||
"bundleId": "com.expo.turtle.cli.example"
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/apps/1491144534"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/apps/1491144534?fields%5Bapps%5D=bundleId%2Cname"
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "buildUploadFiles",
|
||||
"id": "5b110930-f947-4998-a129-5926ffcedde5",
|
||||
"attributes": {
|
||||
"assetDeliveryState": {
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"state": "COMPLETE"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploadFiles/5b110930-f947-4998-a129-5926ffcedde5"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploadFiles/5b110930-f947-4998-a129-5926ffcedde5?fields%5BbuildUploadFiles%5D=assetDeliveryState"
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "buildUploadFiles",
|
||||
"id": "5b110930-f947-4998-a129-5926ffcedde5",
|
||||
"attributes": {
|
||||
"assetDeliveryState": {
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"state": "COMPLETE"
|
||||
},
|
||||
"assetToken": "token.json",
|
||||
"assetType": "ASSET",
|
||||
"fileName": "app.ipa",
|
||||
"fileSize": 359,
|
||||
"sourceFileChecksums": {
|
||||
"file": null,
|
||||
"composite": {
|
||||
"hash": "hash",
|
||||
"algorithm": "MD5"
|
||||
}
|
||||
},
|
||||
"uploadOperations": null,
|
||||
"uti": "com.apple.ipa"
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploadFiles/5b110930-f947-4998-a129-5926ffcedde5"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploadFiles/5b110930-f947-4998-a129-5926ffcedde5"
|
||||
}
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "buildUploadFiles",
|
||||
"id": "5b110930-f947-4998-a129-5926ffcedde5",
|
||||
"attributes": {
|
||||
"assetDeliveryState": {
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"state": "AWAITING_UPLOAD"
|
||||
},
|
||||
"assetToken": "token.json",
|
||||
"assetType": "ASSET",
|
||||
"fileName": "app.ipa",
|
||||
"fileSize": 359,
|
||||
"sourceFileChecksums": {
|
||||
"file": null,
|
||||
"composite": null
|
||||
},
|
||||
"uploadOperations": [
|
||||
{
|
||||
"method": "PUT",
|
||||
"url": "https://storage/upload",
|
||||
"length": 359,
|
||||
"offset": 0,
|
||||
"requestHeaders": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"expiration": "2026-02-06T01:31:03.779-08:00",
|
||||
"partNumber": 1,
|
||||
"entityTag": "tag"
|
||||
}
|
||||
],
|
||||
"uti": "com.apple.ipa"
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploadFiles/5b110930-f947-4998-a129-5926ffcedde5"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploadFiles"
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "buildUploads",
|
||||
"id": "fdf9c476-aaa4-4ead-b91c-6e3cc3a47805",
|
||||
"attributes": {
|
||||
"state": {
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"infos": [],
|
||||
"state": "PROCESSING"
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"build": {
|
||||
"data": null
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploads/fdf9c476-aaa4-4ead-b91c-6e3cc3a47805"
|
||||
}
|
||||
},
|
||||
"included": [],
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploads/fdf9c476-aaa4-4ead-b91c-6e3cc3a47805?fields%5BbuildUploads%5D=build%2Cstate&include=build"
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "buildUploads",
|
||||
"id": "fdf9c476-aaa4-4ead-b91c-6e3cc3a47805",
|
||||
"attributes": {
|
||||
"cfBundleShortVersionString": "1.0",
|
||||
"cfBundleVersion": "13",
|
||||
"createdDate": "2026-01-30T01:31:03.007-08:00",
|
||||
"state": {
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"infos": [],
|
||||
"state": "AWAITING_UPLOAD"
|
||||
},
|
||||
"platform": "IOS",
|
||||
"uploadedDate": null
|
||||
},
|
||||
"relationships": {
|
||||
"buildUploadFiles": {
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploads/fdf9c476-aaa4-4ead-b91c-6e3cc3a47805/relationships/buildUploadFiles",
|
||||
"related": "https://api.appstoreconnect.apple.com/v1/buildUploads/fdf9c476-aaa4-4ead-b91c-6e3cc3a47805/buildUploadFiles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploads/fdf9c476-aaa4-4ead-b91c-6e3cc3a47805"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "https://api.appstoreconnect.apple.com/v1/buildUploads"
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,9 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
filter?: ViewUpdateGroupsOnAppQueryVariables['filter'];
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
@@ -49,7 +47,6 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
|
||||
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
appId: projectId,
|
||||
filter,
|
||||
});
|
||||
renderUpdateGroupsOnApp({ updateGroups, paginatedQueryOptions });
|
||||
} else {
|
||||
@@ -57,7 +54,7 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
|
||||
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId, filter }),
|
||||
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
|
||||
promptOptions: {
|
||||
title: 'Load more update groups?',
|
||||
renderListItems: updateGroups => {
|
||||
@@ -73,12 +70,10 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
|
||||
{
|
||||
projectId,
|
||||
branchName,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
branchName: string;
|
||||
filter?: ViewUpdateGroupsOnBranchQueryVariables['filter'];
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
@@ -88,7 +83,6 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
|
||||
offset: paginatedQueryOptions.offset,
|
||||
appId: projectId,
|
||||
branchName,
|
||||
filter,
|
||||
});
|
||||
renderUpdateGroupsOnBranch({ updateGroups, branchName, paginatedQueryOptions });
|
||||
} else {
|
||||
@@ -101,7 +95,6 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
|
||||
offset,
|
||||
appId: projectId,
|
||||
branchName,
|
||||
filter,
|
||||
}),
|
||||
promptOptions: {
|
||||
title: 'Load more update groups?',
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
|
||||
import { ImageNonPngError, ImageTransparencyError, ensurePNGIsNotTransparentAsync } from '../image';
|
||||
|
||||
const TEST_SERVER_PORT = 9999;
|
||||
const TEST_SERVER_PORT = 2137;
|
||||
|
||||
const fixturesPath = path.join(__dirname, 'fixtures');
|
||||
const transparentPngPath = path.join(fixturesPath, 'icon-alpha.png');
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
EstimatedUsage,
|
||||
UsageMetricType,
|
||||
} from '../../../graphql/generated';
|
||||
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
|
||||
import { AccountUsageQuery } from '../../../graphql/queries/AccountUsageQuery';
|
||||
import Log, { link } from '../../../log';
|
||||
import {
|
||||
createProgressBar,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
maybeWarnAboutUsageOveragesAsync,
|
||||
} from '../checkForOverages';
|
||||
|
||||
jest.mock('../../../graphql/queries/AccountQuery');
|
||||
jest.mock('../../../graphql/queries/AccountUsageQuery');
|
||||
jest.mock('../../../log', () => ({
|
||||
...jest.requireActual('../../../log'),
|
||||
warn: jest.fn(),
|
||||
@@ -72,7 +72,7 @@ function createMockAccountUsage({
|
||||
describe('maybeWarnAboutUsageOveragesAsync', () => {
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockGetUsageForOverageWarningAsync = jest.mocked(
|
||||
AccountQuery.getUsageForOverageWarningAsync
|
||||
AccountUsageQuery.getUsageForOverageWarningAsync
|
||||
);
|
||||
const mockWarn = jest.mocked(Log.warn);
|
||||
const mockDebug = jest.mocked(Log.debug);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AccountQuery } from '../../graphql/queries/AccountQuery';
|
||||
import { AccountUsageQuery } from '../../graphql/queries/AccountUsageQuery';
|
||||
import Log, { link } from '../../log';
|
||||
|
||||
const THRESHOLD_PERCENT = 85;
|
||||
@@ -19,7 +19,11 @@ export async function maybeWarnAboutUsageOveragesAsync({
|
||||
name,
|
||||
subscription,
|
||||
usageMetrics: { EAS_BUILD },
|
||||
} = await AccountQuery.getUsageForOverageWarningAsync(graphqlClient, accountId, currentDate);
|
||||
} = await AccountUsageQuery.getUsageForOverageWarningAsync(
|
||||
graphqlClient,
|
||||
accountId,
|
||||
currentDate
|
||||
);
|
||||
|
||||
const planMetric = EAS_BUILD?.planMetrics?.[0];
|
||||
if (!planMetric || !subscription) {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# CLAUDE.md - eas-json
|
||||
|
||||
This file provides guidance for working with the **@expo/eas-json** package.
|
||||
|
||||
## Overview
|
||||
|
||||
A library for parsing and validating `eas.json` configuration files. Published as `@expo/eas-json` on npm.
|
||||
|
||||
- Validates `eas.json` configuration files using **Joi schemas**
|
||||
- Manages build, submit, and deploy profiles
|
||||
- Supports **JSON5** via golden-fleece
|
||||
- Used by eas-cli and other Expo tools
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn build # TypeScript compilation (src → build)
|
||||
yarn watch # Watch mode
|
||||
yarn typecheck # Type check
|
||||
yarn test # Run tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Schema Validation
|
||||
|
||||
The package uses **Joi** for schema validation:
|
||||
|
||||
**Location**: `src/schema.ts`
|
||||
|
||||
The schema defines valid structure for:
|
||||
- Build profiles
|
||||
- Submit profiles
|
||||
- Deploy profiles
|
||||
- CLI version constraints
|
||||
|
||||
### JSON5 Support
|
||||
|
||||
Uses **golden-fleece** library to support JSON5 syntax in `eas.json`:
|
||||
- Comments
|
||||
- Trailing commas
|
||||
- Unquoted keys
|
||||
- Single quotes
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### Reading eas.json
|
||||
|
||||
```typescript
|
||||
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
|
||||
// Read eas.json from project directory
|
||||
const easJson = await EasJsonUtils.readAsync(projectDir);
|
||||
|
||||
// Access build profiles
|
||||
const buildProfile = easJson.build?.[profileName];
|
||||
if (!buildProfile) {
|
||||
throw new Error(`Build profile "${profileName}" not found`);
|
||||
}
|
||||
|
||||
// Access submit profiles
|
||||
const submitProfile = easJson.submit?.[profileName];
|
||||
|
||||
// Check CLI version constraint
|
||||
const cliVersion = easJson.cli?.version;
|
||||
```
|
||||
|
||||
### Using EasJsonAccessor
|
||||
|
||||
```typescript
|
||||
import { EasJsonAccessor } from '@expo/eas-json';
|
||||
|
||||
// Create accessor from project path
|
||||
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
|
||||
// Read raw eas.json
|
||||
const rawJson = await accessor.readRawAsync();
|
||||
|
||||
// Read and validate eas.json
|
||||
const easJson = await accessor.readAsync();
|
||||
|
||||
// Write eas.json
|
||||
await accessor.writeAsync(easJson);
|
||||
```
|
||||
|
||||
### Validating eas.json
|
||||
|
||||
```typescript
|
||||
import { validateEasJsonAsync } from '@expo/eas-json';
|
||||
|
||||
try {
|
||||
await validateEasJsonAsync(projectDir);
|
||||
console.log('eas.json is valid');
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Structure
|
||||
|
||||
### Build Profiles
|
||||
|
||||
Build profiles can contain:
|
||||
- `distribution`: Distribution type (store, internal, simulator)
|
||||
- `platform`: Platform-specific configuration (iOS/Android)
|
||||
- `env`: Environment variables
|
||||
- `node`: Node.js version
|
||||
- `yarn`: Yarn version
|
||||
- `npm`: npm version
|
||||
- `cache`: Cache configuration
|
||||
- And many more build-specific options
|
||||
|
||||
### Submit Profiles
|
||||
|
||||
Submit profiles contain app store submission configuration:
|
||||
- iOS: App Store Connect credentials, ASC App ID
|
||||
- Android: Google Service Account key, track
|
||||
|
||||
### Deploy Profiles
|
||||
|
||||
Deploy profiles for EAS Deploy (worker deployments):
|
||||
- `region`: Deployment region
|
||||
- `nodeVersion`: Node.js version
|
||||
- Environment configuration
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **JSON5**: Full JSON5 syntax support via golden-fleece
|
||||
- **Validation**: Joi schema validation with helpful error messages
|
||||
- **Types**: Comprehensive TypeScript types exported
|
||||
- **CLI Version**: Supports enforcing eas-cli version via `cli.version` field
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"version": "16.30.0",
|
||||
"version": "16.25.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -32,11 +32,7 @@
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-json"
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
@@ -53,5 +49,9 @@
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/.bundle/
|
||||
/.yardoc
|
||||
/_yardoc/
|
||||
/coverage/
|
||||
/doc/
|
||||
/pkg/
|
||||
/spec/reports/
|
||||
/tmp/
|
||||
|
||||
# rspec failure tracking
|
||||
.rspec_status
|
||||
@@ -1,3 +0,0 @@
|
||||
--format documentation
|
||||
--color
|
||||
--require spec_helper
|
||||
@@ -1,8 +0,0 @@
|
||||
Metrics/BlockLength:
|
||||
Exclude:
|
||||
- "spec/**/*"
|
||||
Lint/AmbiguousBlockAssociation:
|
||||
Exclude:
|
||||
- "spec/**/*"
|
||||
Metrics/MethodLength:
|
||||
Max: 20
|
||||
@@ -1,12 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
||||
|
||||
# Specify your gem's dependencies in expo-cocoapods-proxy.gemspec
|
||||
gemspec
|
||||
|
||||
gem 'rubocop', '~> 1.48', group: :development
|
||||
|
||||
gem 'cocoapods', '~> 1.12'
|
||||
@@ -1,142 +0,0 @@
|
||||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
expo-cocoapods-proxy (0.1.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.6)
|
||||
rexml
|
||||
activesupport (7.0.8)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.1)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
algoliasearch (1.27.5)
|
||||
httpclient (~> 2.8, >= 2.8.3)
|
||||
json (>= 1.5.1)
|
||||
ast (2.4.2)
|
||||
atomos (0.1.3)
|
||||
claide (1.1.0)
|
||||
cocoapods (1.12.0)
|
||||
addressable (~> 2.8)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
cocoapods-core (= 1.12.0)
|
||||
cocoapods-deintegrate (>= 1.0.3, < 2.0)
|
||||
cocoapods-downloader (>= 1.6.0, < 2.0)
|
||||
cocoapods-plugins (>= 1.0.0, < 2.0)
|
||||
cocoapods-search (>= 1.0.0, < 2.0)
|
||||
cocoapods-trunk (>= 1.6.0, < 2.0)
|
||||
cocoapods-try (>= 1.1.0, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
escape (~> 0.0.4)
|
||||
fourflusher (>= 2.3.0, < 3.0)
|
||||
gh_inspector (~> 1.0)
|
||||
molinillo (~> 0.8.0)
|
||||
nap (~> 1.0)
|
||||
ruby-macho (>= 2.3.0, < 3.0)
|
||||
xcodeproj (>= 1.21.0, < 2.0)
|
||||
cocoapods-core (1.12.0)
|
||||
activesupport (>= 5.0, < 8)
|
||||
addressable (~> 2.8)
|
||||
algoliasearch (~> 1.0)
|
||||
concurrent-ruby (~> 1.1)
|
||||
fuzzy_match (~> 2.0.4)
|
||||
nap (~> 1.0)
|
||||
netrc (~> 0.11)
|
||||
public_suffix (~> 4.0)
|
||||
typhoeus (~> 1.0)
|
||||
cocoapods-deintegrate (1.0.5)
|
||||
cocoapods-downloader (1.6.3)
|
||||
cocoapods-plugins (1.0.0)
|
||||
nap
|
||||
cocoapods-search (1.0.1)
|
||||
cocoapods-trunk (1.6.0)
|
||||
nap (>= 0.8, < 2.0)
|
||||
netrc (~> 0.11)
|
||||
cocoapods-try (1.2.0)
|
||||
colored2 (3.1.2)
|
||||
concurrent-ruby (1.2.2)
|
||||
diff-lcs (1.5.0)
|
||||
escape (0.0.4)
|
||||
ethon (0.16.0)
|
||||
ffi (>= 1.15.0)
|
||||
ffi (1.15.5)
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.6.3)
|
||||
minitest (5.20.0)
|
||||
molinillo (0.8.0)
|
||||
nanaimo (0.3.0)
|
||||
nap (1.1.0)
|
||||
netrc (0.11.0)
|
||||
parallel (1.22.1)
|
||||
parser (3.2.1.1)
|
||||
ast (~> 2.4.1)
|
||||
public_suffix (4.0.7)
|
||||
rainbow (3.1.1)
|
||||
rake (10.5.0)
|
||||
regexp_parser (2.7.0)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rspec (3.12.0)
|
||||
rspec-core (~> 3.12.0)
|
||||
rspec-expectations (~> 3.12.0)
|
||||
rspec-mocks (~> 3.12.0)
|
||||
rspec-core (3.12.1)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-mocks (3.12.4)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-support (3.12.0)
|
||||
rubocop (1.48.1)
|
||||
json (~> 2.3)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.2.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.26.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.28.0)
|
||||
parser (>= 3.2.1.0)
|
||||
ruby-macho (2.5.1)
|
||||
ruby-progressbar (1.13.0)
|
||||
strscan (3.1.0)
|
||||
typhoeus (1.4.0)
|
||||
ethon (>= 0.9.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.4.2)
|
||||
xcodeproj (1.25.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (>= 3.3.2, < 4.0)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
bundler (~> 1.17)
|
||||
cocoapods (~> 1.12)
|
||||
expo-cocoapods-proxy!
|
||||
rake (~> 10.0)
|
||||
rspec (~> 3.0)
|
||||
rubocop (~> 1.48)
|
||||
|
||||
BUNDLED WITH
|
||||
2.1.4
|
||||
@@ -1,8 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/gem_tasks'
|
||||
require 'rspec/core/rake_task'
|
||||
|
||||
RSpec::Core::RakeTask.new(:spec)
|
||||
|
||||
task default: :spec
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/setup'
|
||||
require 'expo/cocoapods/proxy'
|
||||
|
||||
# You can add fixtures and/or initialization code here to make experimenting
|
||||
# with your gem easier. You can also use a different console, if you like.
|
||||
|
||||
# (If you use this, don't forget to add pry to your Gemfile!)
|
||||
# require "pry"
|
||||
# Pry.start
|
||||
|
||||
require 'irb'
|
||||
IRB.start(__FILE__)
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
set -vx
|
||||
|
||||
bundle install
|
||||
|
||||
# Do any other automated setup that you need to do here
|
||||
@@ -1,38 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
lib = File.expand_path('lib', __dir__)
|
||||
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
||||
require 'expo_cocoapods_proxy/gem_version'
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = 'expo-cocoapods-proxy'
|
||||
spec.version = ExpoCocoaPodsProxy::VERSION
|
||||
spec.authors = ['Mike Hampton']
|
||||
spec.email = ['[email protected]']
|
||||
|
||||
spec.summary = 'A Cocoapods plugin for routing requests through a proxy cache server.'
|
||||
spec.homepage = 'https://expo.dev'
|
||||
spec.license = 'MIT'
|
||||
|
||||
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
||||
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
||||
if spec.respond_to?(:metadata)
|
||||
spec.metadata['allowed_push_host'] = 'TODO: Set to \'http://mygemserver.com\''
|
||||
|
||||
spec.metadata['homepage_uri'] = spec.homepage
|
||||
spec.metadata['source_code_uri'] = 'https://github.com/expo/eas-cli'
|
||||
else
|
||||
raise 'RubyGems 2.0 or newer is required to protect against ' \
|
||||
'public gem pushes.'
|
||||
end
|
||||
|
||||
spec.required_ruby_version = '>= 2.7', '< 4'
|
||||
spec.files = Dir['lib/**/*.rb']
|
||||
spec.bindir = 'exe'
|
||||
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
||||
spec.require_paths = ['lib']
|
||||
|
||||
spec.add_development_dependency 'bundler', '~> 1.17'
|
||||
spec.add_development_dependency 'rake', '~> 10.0'
|
||||
spec.add_development_dependency 'rspec', '~> 3.0'
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'expo_cocoapods_proxy'
|
||||
@@ -1,4 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'expo_cocoapods_proxy/gem_version'
|
||||
require 'expo_cocoapods_proxy/proxy'
|
||||
@@ -1,6 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ExpoCocoaPodsProxy
|
||||
VERSION = '0.1.0'
|
||||
NAME = 'expo-cocoapods-proxy'
|
||||
end
|
||||
@@ -1,171 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'cocoapods'
|
||||
require 'cocoapods-downloader'
|
||||
require 'uri'
|
||||
|
||||
module Pod
|
||||
module Downloader
|
||||
# Concrete Downloader class that provides support for proxying.
|
||||
class Http
|
||||
NETRC_FILENAME = '.netrc'
|
||||
NETRC_ENV_VAR = 'NETRC'
|
||||
CURLRC_FILENAME = '.curlrc'
|
||||
CURL_HOME_ENV_VAR = 'CURL_HOME'
|
||||
XDG_CONFIG_HOME_ENV_VAR = 'XDG_CONFIG_HOME'
|
||||
ALLOWLIST = [
|
||||
%r{https?://(api\.)?github\.com/},
|
||||
%r{https?://sourceforge\.net/},
|
||||
%r{https?://repo1\.maven\.org/},
|
||||
%r{https?://boostorg\.jfrog\.io/},
|
||||
%r{https?://www\.sqlite\.org/},
|
||||
%r{https?://download\.videolan\.org/},
|
||||
%r{https?://raw\.githubusercontent\.com/}
|
||||
].freeze
|
||||
|
||||
alias orig_download_file download_file
|
||||
|
||||
def download_file_via_proxy(full_filename)
|
||||
orig_url = url
|
||||
@url = rewrite(url)
|
||||
|
||||
begin
|
||||
orig_download_file(full_filename)
|
||||
rescue ::StandardError
|
||||
@url = orig_url
|
||||
orig_download_file(full_filename)
|
||||
end
|
||||
end
|
||||
|
||||
def download_file(full_filename)
|
||||
return orig_download_file(full_filename) unless should_proxy?
|
||||
|
||||
download_file_via_proxy(full_filename)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_proxy?
|
||||
return false unless proxy
|
||||
return false if netrc_present?
|
||||
return false if curlrc_present?
|
||||
return false unless headers.nil?
|
||||
|
||||
allowed?
|
||||
end
|
||||
|
||||
def netrc_present?
|
||||
File.exist?(File.expand_path("~/#{NETRC_FILENAME}")) || ENV[NETRC_ENV_VAR]
|
||||
end
|
||||
|
||||
def curlrc_present?
|
||||
prefix = ENV[CURL_HOME_ENV_VAR] || ENV[XDG_CONFIG_HOME_ENV_VAR] || Dir.home
|
||||
File.exist?(File.join(prefix, CURLRC_FILENAME))
|
||||
end
|
||||
|
||||
def allowed?
|
||||
ALLOWLIST.any? { |reg| reg.match?(url) }
|
||||
end
|
||||
|
||||
def proxy
|
||||
ENV['EAS_BUILD_COCOAPODS_CACHE_URL']
|
||||
end
|
||||
|
||||
def parsed_url
|
||||
@parsed_url ||= URI.parse(url)
|
||||
end
|
||||
|
||||
def rewrite(url)
|
||||
url.gsub("#{parsed_url.scheme}://#{parsed_url.host}", "#{proxy}/#{parsed_url.host}")
|
||||
end
|
||||
end
|
||||
|
||||
# Concrete Downloader class that provides support for fetching GitHub
|
||||
# tarballs directly.
|
||||
class Git
|
||||
ALLOWLIST = [
|
||||
%r{https://github.com/}
|
||||
].freeze
|
||||
|
||||
alias orig_download! download!
|
||||
alias orig_checkout_options checkout_options
|
||||
|
||||
def download_from_github!
|
||||
ref = options[:commit] || options[:tag] || options[:branch]
|
||||
download_url = github_url_from_ref(ref)
|
||||
|
||||
begin
|
||||
Http.new(target_path, download_url, {}).download
|
||||
rescue ::StandardError
|
||||
# primarily intended in the case of a private repo that the above will
|
||||
# fail on
|
||||
FileUtils.remove_dir(target_path, true)
|
||||
orig_download!
|
||||
end
|
||||
end
|
||||
|
||||
def download!
|
||||
return orig_download! unless should_proxy?
|
||||
|
||||
download_from_github!
|
||||
end
|
||||
|
||||
def checkout_options
|
||||
# this should work almost always
|
||||
orig_checkout_options
|
||||
rescue ::StandardError
|
||||
hash_from_github
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_proxy?
|
||||
proxy && allowed? && !options[:submodules]
|
||||
end
|
||||
|
||||
def allowed?
|
||||
ALLOWLIST.any? { |reg| reg.match?(url) }
|
||||
end
|
||||
|
||||
def proxy
|
||||
ENV['EAS_BUILD_COCOAPODS_CACHE_URL']
|
||||
end
|
||||
|
||||
def github_url_from_ref(ref)
|
||||
base_url = url.chomp('.git').chomp('/')
|
||||
|
||||
return "#{base_url}/archive/#{ref}.tar.gz" unless ref.nil?
|
||||
|
||||
api_url = base_url.gsub('https://github.com/', 'https://api.github.com/repos/')
|
||||
"#{api_url}/tarball"
|
||||
end
|
||||
|
||||
def hash_from_github
|
||||
return options[:commit] if options[:commit]
|
||||
return options[:tag] if options[:tag]
|
||||
|
||||
command = [
|
||||
'ls-remote',
|
||||
'--',
|
||||
url,
|
||||
options[:branch]
|
||||
]
|
||||
|
||||
output = Git.execute_command('git', command)
|
||||
match = commit_from_ls_remote(output, options[:branch])
|
||||
# we didn't find the commit, so just return something
|
||||
return 'deadbeef' if match.nil?
|
||||
|
||||
match
|
||||
end
|
||||
|
||||
def commit_from_ls_remote(output, branch_name)
|
||||
return nil if branch_name.nil?
|
||||
|
||||
encoded_branch_name = branch_name.dup.force_encoding(Encoding::ASCII_8BIT)
|
||||
match = %r{([a-z0-9]*)\trefs\/(heads|tags)\/#{Regexp.quote(encoded_branch_name)}}.match(output) # rubocop:disable Style/RedundantRegexpEscape
|
||||
match[1] unless match.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,203 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'pathname'
|
||||
|
||||
RSpec.describe ExpoCocoaPodsProxy do
|
||||
describe 'With EAS_BUILD_COCOAPODS_CACHE_URL not set' do
|
||||
it 'does not rewrite GitHub repos' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/expo.git'
|
||||
opts = { branch: 'main' }
|
||||
downloader = Pod::Downloader::Git.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:orig_download!) {}
|
||||
|
||||
downloader.download!
|
||||
end
|
||||
|
||||
it 'does not proxy Http pods' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:orig_download_file) {}
|
||||
|
||||
downloader.download_file(path.to_s)
|
||||
expect(downloader).to have_attributes(url: url)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'With EAS_BUILD_COCOAPODS_CACHE_URL set' do
|
||||
before(:context) do
|
||||
ENV['EAS_BUILD_COCOAPODS_CACHE_URL'] = 'http://localhost:9001'
|
||||
end
|
||||
|
||||
it 'rewrites github urls when submodules is not set' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo.git'
|
||||
opts = { branch: 'main' }
|
||||
downloader = Pod::Downloader::Git.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:download_from_github!) {}
|
||||
|
||||
downloader.download!
|
||||
end
|
||||
|
||||
it 'does not fetch tarballs when submodules is set' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo.git'
|
||||
opts = { branch: 'main', submodules: true }
|
||||
downloader = Pod::Downloader::Git.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:orig_download!) {}
|
||||
|
||||
downloader.download!
|
||||
end
|
||||
|
||||
it 'does not create tarballs for non-github repos' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://not-github.com/expo/test-repo.git'
|
||||
opts = { branch: 'main' }
|
||||
downloader = Pod::Downloader::Git.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:orig_download!) {}
|
||||
|
||||
downloader.download!
|
||||
end
|
||||
|
||||
it 'modifies github repos to be tarballs' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo.git'
|
||||
opts = { branch: 'main' }
|
||||
expected_url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
downloader = Pod::Downloader::Git.new(path, url, opts)
|
||||
|
||||
allow(Pod::Downloader::Http).to receive(:initialize).with(path, expected_url, anything)
|
||||
expect_any_instance_of(Pod::Downloader::Http).to receive(:download) {}
|
||||
|
||||
downloader.download_from_github!
|
||||
end
|
||||
|
||||
it 'modifies github repos to be tarballs from the api when branch not set' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo.git'
|
||||
opts = {}
|
||||
expected_url = 'https://api.github.com/repos/expo/test-repo/tarball'
|
||||
downloader = Pod::Downloader::Git.new(path, url, opts)
|
||||
|
||||
allow(Pod::Downloader::Http).to receive(:initialize).with(path, expected_url, anything)
|
||||
expect_any_instance_of(Pod::Downloader::Http).to receive(:download) {}
|
||||
|
||||
downloader.download_from_github!
|
||||
end
|
||||
|
||||
it 'does not proxy when netrc is present' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
expect(File).to receive(:exist?).with("#{Dir.home}/.netrc") { true }
|
||||
expect(downloader).to receive(:orig_download_file) {}
|
||||
|
||||
downloader.download_file(path.to_s)
|
||||
end
|
||||
|
||||
it 'does not proxy when NETRC env var is set' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
expect(ENV).to receive(:[]).with('NETRC').and_return('psyduck')
|
||||
expect(downloader).to receive(:orig_download_file) {}
|
||||
|
||||
downloader.download_file(path.to_s)
|
||||
expect(downloader).to have_attributes(url: url)
|
||||
end
|
||||
|
||||
it 'falls back to original download when proxy fails' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:orig_download_file).once.and_raise
|
||||
expect(downloader).to receive(:orig_download_file).once {}
|
||||
|
||||
downloader.download_file(path.to_s)
|
||||
expect(downloader).to have_attributes(url: url)
|
||||
end
|
||||
|
||||
it 'fetches via proxy when url matches an allowlist regex' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:download_file_via_proxy).once {}
|
||||
|
||||
downloader.download_file(path.to_s)
|
||||
end
|
||||
|
||||
it 'does not proxy when url doesn\'t match an allowlist regex' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://not-github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
expect(downloader).to receive(:orig_download_file).once {}
|
||||
|
||||
downloader.download_file(path.to_s)
|
||||
end
|
||||
|
||||
it 'does not proxy when curlrc is present in home directory' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
allow(File).to receive(:exist?).and_call_original
|
||||
expect(File).to receive(:exist?).at_least(1).times.with("#{Dir.home}/.curlrc") { true }
|
||||
|
||||
expect(downloader).to receive(:orig_download_file).once {}
|
||||
downloader.download_file(path.to_s)
|
||||
end
|
||||
|
||||
it 'does not proxy when curlrc is present in CURL_HOME' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
curl_home = '/this/is/my/home'
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
expect(ENV).to receive(:[]).with('CURL_HOME').and_return(curl_home)
|
||||
|
||||
allow(File).to receive(:exist?).and_call_original
|
||||
expect(File).to receive(:exist?).at_least(1).times.with("#{curl_home}/.curlrc") { true }
|
||||
|
||||
expect(downloader).to receive(:orig_download_file).once {}
|
||||
downloader.download_file(path.to_s)
|
||||
end
|
||||
|
||||
it 'does not proxy when curlrc is present in XDG_CONFIG_HOME' do
|
||||
path = Pathname.new('some/fake/path')
|
||||
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
|
||||
opts = {}
|
||||
xdg_config_home = '/this/is/my/home'
|
||||
downloader = Pod::Downloader::Http.new(path, url, opts)
|
||||
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
allow(ENV).to receive(:[]).with('XDG_CONFIG_HOME').and_return(xdg_config_home)
|
||||
|
||||
allow(File).to receive(:exist?).and_call_original
|
||||
expect(File).to receive(:exist?).at_least(1).times.with("#{xdg_config_home}/.curlrc") { true }
|
||||
|
||||
expect(downloader).to receive(:orig_download_file).once {}
|
||||
downloader.download_file(path.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/setup'
|
||||
require 'expo_cocoapods_proxy/proxy'
|
||||
|
||||
RSpec.configure do |config|
|
||||
# Enable flags like --only-failures and --next-failure
|
||||
config.example_status_persistence_file_path = '.rspec_status'
|
||||
|
||||
# Disable RSpec exposing methods globally on `Module` and `main`
|
||||
config.disable_monkey_patching!
|
||||
|
||||
config.expect_with :rspec do |c|
|
||||
c.syntax = :expect
|
||||
end
|
||||
end
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source_up
|
||||
|
||||
access_secret_version() {
|
||||
name="$1"
|
||||
version="$2"
|
||||
gcloud secrets versions access "$version" --secret "$name" --project exponentjs
|
||||
}
|
||||
|
||||
source_secrets_from_gcsm_json() {
|
||||
name="$1"
|
||||
version="$2"
|
||||
|
||||
secrets=$(access_secret_version "$name" "$version")
|
||||
eval "export $(echo "$secrets" | jq -r 'to_entries | map("\(.key)=\(.value)") | @sh')"
|
||||
}
|
||||
|
||||
source_secrets_from_gcsm_json "turtle_v2__worker_tests" 5
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Build Worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_artifact:
|
||||
description: Whether to upload artifacts
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
build_android_worker:
|
||||
name: Build Android Worker
|
||||
runs_on: linux-medium
|
||||
image: latest
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
|
||||
- name: Build Android worker tarball
|
||||
run: ./package.sh worker-android.tar.gz android
|
||||
|
||||
- name: Upload Android worker tarball as artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: eas/upload_artifact
|
||||
with:
|
||||
name: worker-android-tarball
|
||||
path: packages/worker/worker-android.tar.gz
|
||||
|
||||
build_ios_worker:
|
||||
name: Build iOS Worker
|
||||
runs_on: macos-medium
|
||||
image: latest
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
|
||||
- name: Build iOS worker tarball
|
||||
run: ./package.sh worker-ios.tar.gz ios
|
||||
|
||||
- name: Upload iOS worker tarball as artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: eas/upload_artifact
|
||||
with:
|
||||
name: worker-ios-tarball
|
||||
path: packages/worker/worker-ios.tar.gz
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export ENVIRONMENT="development"
|
||||
export PORT="3005"
|
||||
export WORKER_PORT="3005"
|
||||
export WORKER_IP="localhost"
|
||||
export LOGGER_LEVEL="debug"
|
||||
|
||||
unset _VOLTA_TOOL_RECURSION
|
||||
@@ -1,31 +0,0 @@
|
||||
const rootEslintrc = require('../../.eslintrc.js');
|
||||
|
||||
module.exports = {
|
||||
...rootEslintrc,
|
||||
rules: {
|
||||
...rootEslintrc.rules,
|
||||
'async-protect/async-suffix': ['off'],
|
||||
'no-underscore-dangle': [
|
||||
'error',
|
||||
{
|
||||
allow: [...rootEslintrc.rules['no-underscore-dangle'][1].allow, '__WORKFLOW_JOB_ID'],
|
||||
allowAfterThis: true,
|
||||
},
|
||||
],
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: ['**/__integration__/**/*', '**/__unit__/**/*', '**/__mocks__/**/*'],
|
||||
},
|
||||
],
|
||||
'node/no-sync': ['off'],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.test.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
workingdir/*
|
||||
!workingdir/.gitkeep
|
||||
@@ -1,158 +0,0 @@
|
||||
# CLAUDE.md - worker
|
||||
|
||||
This file provides guidance for working with the **worker** package.
|
||||
|
||||
## Overview
|
||||
|
||||
Turtle Worker is a service running on every EAS build worker VM or pod. It's a **private package** (not published to npm).
|
||||
|
||||
- Exposes a **WebSocket server** to communicate with Turtle Launcher
|
||||
- Wrapper for `@expo/build-tools` library
|
||||
- Receives build requests and handles actual React Native project builds
|
||||
- Runs on EAS build infrastructure (VMs/pods)
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Copy environment configuration
|
||||
cp ./.direnv/local/.envrc.example ./.direnv/local/.envrc
|
||||
|
||||
# Fill out the .envrc file with your secrets
|
||||
# Then load it with direnv or source it manually
|
||||
|
||||
# Install dependencies (from repo root)
|
||||
yarn install
|
||||
|
||||
# Build
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Development
|
||||
```bash
|
||||
yarn start # Start the worker in development mode
|
||||
|
||||
# If you made changes in packages from libs/ directory:
|
||||
# Type 'rs' in the console and press ENTER
|
||||
# Or press CTRL+C, then UP arrow, then ENTER to restart
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
yarn test # Run unit tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### WebSocket Communication
|
||||
|
||||
The worker communicates with Turtle Launcher via WebSocket messages.
|
||||
|
||||
**Message Types** (defined in `/src/libs/turtle-common/src/messages.ts`):
|
||||
|
||||
1. **state-query** (launcher → worker)
|
||||
- Launcher queries current worker state
|
||||
|
||||
2. **state-response** (worker → launcher)
|
||||
- Worker responds with current state
|
||||
- Possible statuses: `"new"`, `"in-progress"`, `"finished"`
|
||||
|
||||
3. **dispatch** (launcher → worker)
|
||||
- Launcher dispatches a new build to worker
|
||||
- Only sent when worker status is `"new"`
|
||||
|
||||
4. **success** / **error** (worker → launcher)
|
||||
- Worker reports build completion
|
||||
- Contains build results and artifacts
|
||||
|
||||
5. **close** (launcher → worker)
|
||||
- Launcher initiates graceful shutdown of VM/pod
|
||||
|
||||
### Communication Flow
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Launcher │ │ Worker │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ state-query │
|
||||
├─────────────────────────────────>│
|
||||
│ │
|
||||
│ state-response (status: "new") │
|
||||
│<─────────────────────────────────┤
|
||||
│ │
|
||||
│ dispatch (build request) │
|
||||
├─────────────────────────────────>│
|
||||
│ │
|
||||
│ (build runs) │
|
||||
│ │
|
||||
│ success/error (build result) │
|
||||
│<─────────────────────────────────┤
|
||||
│ │
|
||||
│ close (shutdown) │
|
||||
├─────────────────────────────────>│
|
||||
│ │
|
||||
```
|
||||
|
||||
### Recovery Process
|
||||
|
||||
If the launcher goes down during a build, recovery happens when it comes back:
|
||||
|
||||
1. New launcher instance sends `state-query`
|
||||
2. Worker responds with `state-response` containing current status:
|
||||
- If build finished: launcher sends `close` message
|
||||
- If build in progress: launcher takes no action, waits for completion
|
||||
|
||||
### Build Execution
|
||||
|
||||
Worker wraps `@expo/build-tools` library to execute builds:
|
||||
- Receives build job configuration
|
||||
- Sets up build environment
|
||||
- Runs actual React Native build process
|
||||
- Uploads artifacts
|
||||
- Reports results back to launcher
|
||||
|
||||
## Deployment
|
||||
|
||||
### Distribution
|
||||
|
||||
Worker code is distributed via **GCS bucket**. Every pod/VM installs it during startup.
|
||||
|
||||
**Archive Structure**:
|
||||
- Contains worker source code from repository
|
||||
- All other dependencies downloaded from npm at install time
|
||||
|
||||
### Deployment Process
|
||||
|
||||
**For every commit on main**:
|
||||
- GitHub Actions creates archives: `worker-{{platform}}-{{hash}}`
|
||||
- Creates staging archives: `worker-{{platform}}-staging`
|
||||
|
||||
**Production Deployment**:
|
||||
- Guarded with manual `approve` step in GitHub Actions
|
||||
- Creates copy: `worker-{{platform}}-staging` → `worker-{{platform}}-production`
|
||||
|
||||
### Docker Images (Android)
|
||||
|
||||
- Built for every PR with changes in `infra/eas-build-worker-images/android/{{ imageName }}`
|
||||
- Pushed to Docker registry hosted on GCloud
|
||||
|
||||
### VM Templates (iOS)
|
||||
|
||||
- Built for every PR with changes in `infra/eas-build-worker-images/ios/{{ templateName }}`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Key dependencies:
|
||||
- `@expo/build-tools` - Core build execution
|
||||
- `koa`, `koa-body`, `koa-router` - HTTP server
|
||||
- `ws` - WebSocket server
|
||||
- `@sentry/node` - Error tracking
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Not Published**: This is a private package, not published to npm
|
||||
- **Build Output**: `src/` → `dist/`
|
||||
- **WebSocket**: Primary communication method with launcher
|
||||
- **Stateful**: Maintains build state for recovery scenarios
|
||||
- **Infrastructure**: Runs on EAS build VMs (iOS) and pods (Android)
|
||||
@@ -1,44 +0,0 @@
|
||||
# Turtle Worker
|
||||
|
||||
<h3 align=center>
|
||||
<img src="./assets/turtle-workers.png">
|
||||
</h3>
|
||||
|
||||
Turtle Worker is a service running on every worker VM or pod. It exposes a WebSocket server to communicate with [Turtle Launcher](/src/services/launcher). The service is basically a wrapper for @expo/build-tools library. It receives a build request and handles the actual build of the React Native project.
|
||||
|
||||
## WebSocket messages
|
||||
|
||||
There are a couple of message types that could be exchanged between a launcher and a worker, definition of those messages are [here](/src/libs/turtle-common/src/messages.ts).
|
||||
|
||||
Communication between worker and launcher starts with `state-query` message (`launcher -> worker`) and worker responds with `state-response`
|
||||
that contains the current state of a worker. The next steps depend on the state of the worker:
|
||||
|
||||
- if this was a new instance of the worker then `state-response` message contained `"status": "new"` and the launcher will send `dispatch` method that should start a new build on the worker.
|
||||
- if this happened during the recovery process and the build was already finished(either success or failure), then `state-response` contained information about the build result and launcher will send `close` message.
|
||||
- if this happened during the recovery process and the build is still in progress, then `state-response` contained `"status": "in-progress"` and the launcher does not take any actions.
|
||||
|
||||
When the build is finished worker sends `success` or `error` messages with information about build results and the launcher will respond with `close` message that initiates a gracefull shutdown of a vm/pod.
|
||||
If for some reason the launcher is down when the build has finished then the new launcher instance will attempt to recover that build as described above.
|
||||
|
||||
## Development
|
||||
|
||||
- Run `cp ./.direnv/local/.envrc.example ./.direnv/local/.envrc` and fill out the new file with your secrets.
|
||||
- Run `yarn` (if you haven't run it in the root dir of the repo) and then `yarn start`.
|
||||
- If you made a change in packages from directory `libs`, type `rs` in the console and press ENTER (or `CTRL+C`, `UP`, `ENTER`).
|
||||
|
||||
## Deployment
|
||||
|
||||
The worker code is distributed via GCS bucket and every pod/vm installs it as part of it's startup or configuration steps. Archive uploaded to GCS bucket contain version of worker source from the repository, but all other libraries that worker depends on are downloaded from npm.
|
||||
|
||||
### Worker code
|
||||
|
||||
- For every commit on main Github Actions job creates archive with worker code and node-modules. It will create two archives for every platform `worker-{{platform}}-{{hash}}` and `worker-{{platform}}-staging`.
|
||||
- Deployment to production is guarded with `approve` step and it creating copy of `worker-{{platform}}-staging` under `worker-{{platform}}-production`.
|
||||
|
||||
#### Docker image
|
||||
|
||||
Github action builds a docker image for every PR with any change in `infra/eas-build-worker-images/android/{{ imageName }}`, after that image is pushed to docker registry hosted on gcloud.
|
||||
|
||||
### VM template
|
||||
|
||||
Github action builds a new vm template for every PR with any change in `infra/eas-build-worker-images/ios/{{ templateName }}`.
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "EAS Worker",
|
||||
"slug": "eas-worker",
|
||||
"platforms": ["ios"],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "90798684-1384-4ce4-a73b-9f3fa3e3e368"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
rootDir: 'src',
|
||||
testMatch: ['**/__integration__/*.test.ts'],
|
||||
collectCoverage: true,
|
||||
coverageReporters: ['json', 'lcov', 'text-summary'],
|
||||
coverageDirectory: '../coverage/integration',
|
||||
// Exclude main.ts - it has a self-executing main() that starts servers
|
||||
coveragePathIgnorePatterns: ['/node_modules/', 'main\\.ts$'],
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/integration-setup.ts'],
|
||||
// Force exit after tests complete - internal async operations (logger streams,
|
||||
// WebSocket cleanup timing) may keep handles open that prevent clean exit.
|
||||
forceExit: true,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
rootDir: __dirname,
|
||||
testMatch: ['**/__unit__/*.test.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['./jest/unit-setup.ts'],
|
||||
} satisfies Config;
|
||||
@@ -1,16 +0,0 @@
|
||||
import logger from '../src/logger';
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error("NODE_ENV environment variable must be set to 'test'.");
|
||||
}
|
||||
|
||||
if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) {
|
||||
process.on('unhandledRejection', reason => {
|
||||
logger.error('UNHANDLED PROMISE REJECTION', reason);
|
||||
});
|
||||
// Avoid memory leak by adding too many listeners
|
||||
process.env.LISTENING_TO_UNHANDLED_REJECTION = 'true';
|
||||
}
|
||||
|
||||
// Always mock:
|
||||
jest.mock('../src/CacheManager');
|
||||
@@ -1,20 +0,0 @@
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error("NODE_ENV environment variable must be set to 'test'.");
|
||||
}
|
||||
|
||||
process.env.ENVIRONMENT = 'test';
|
||||
process.env.LOGGER_LEVEL = 'debug';
|
||||
process.env.WORKER_RUNTIME_CONFIG_BASE64 = 'eyAiaW1hZ2UiOiAiIiB9'; // { "image": "" }
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
process.env.__API_SERVER_URL = 'http://api.expo.test';
|
||||
process.env.PORT = '3015';
|
||||
|
||||
// Always mock:
|
||||
jest.mock('@expo/build-tools', () => {
|
||||
return {
|
||||
...jest.requireActual('@expo/build-tools'),
|
||||
GCS: {
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "16.32.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"watch": "yarn compile --watch --preserveWatchOutput",
|
||||
"build": "npm run clean && npm run compile",
|
||||
"clean": "rm -rf ./dist",
|
||||
"clean-all": "yarn clean && rm -rf node_modules",
|
||||
"compile": "tsc -p tsconfig.build.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "yarn test:unit",
|
||||
"test:unit": "./resources/jest --config jest.config.unit.ts",
|
||||
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
|
||||
"test:integration": "./resources/jest --runInBand --config jest.config.integration.js",
|
||||
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.js --watchAll",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "1.0.272",
|
||||
"@expo/config-plugins": "8.0.8",
|
||||
"@expo/config-types": "52.0.1",
|
||||
"@expo/downloader": "1.0.260",
|
||||
"@expo/eas-build-job": "1.0.265",
|
||||
"@expo/logger": "1.0.260",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "1.0.271",
|
||||
"@expo/template-file": "1.0.260",
|
||||
"@expo/turtle-spawn": "1.0.260",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"@sentry/node": "7.77.0",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"filesize": "7.0.0",
|
||||
"flatted": "3.2.5",
|
||||
"fs-extra": "11.2.0",
|
||||
"koa": "3.1.1",
|
||||
"koa-body": "7.0.1",
|
||||
"koa-router": "14.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-os-utils": "1.3.7",
|
||||
"nullthrows": "1.1.1",
|
||||
"semver": "7.6.3",
|
||||
"tar": "6.2.1",
|
||||
"uuid": "3.4.0",
|
||||
"ws": "8.18.0",
|
||||
"xml2js": "0.6.2",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/koa": "3.0.1",
|
||||
"@types/koa-router": "7.4.9",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.7",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node-fetch": "2.6.7",
|
||||
"@types/node-os-utils": "1.3.0",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/tar": "6.1.10",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@types/ws": "8.5.10",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.1",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.1.4",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eo pipefail
|
||||
set -x
|
||||
|
||||
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/../.. && pwd )"
|
||||
WORKER_DIR=$( dirname "${BASH_SOURCE[0]}" )
|
||||
|
||||
OUTPUT_FILE=$1
|
||||
PLATFORM=$2
|
||||
|
||||
if [[ -z "$OUTPUT_FILE" || -z "$PLATFORM" ]]; then
|
||||
echo "Please specify the output file and target platform (.tar.gz)"
|
||||
echo "Usage: ./package.sh OUTPUT_FILE (ios|android)"
|
||||
echo "Example: ./package.sh worker.tar.gz ios"
|
||||
exit -1
|
||||
fi
|
||||
|
||||
if [[ "$OUTPUT_FILE" != *.tar.gz ]]; then
|
||||
OUTPUT_FILE="$OUTPUT_FILE.tar.gz"
|
||||
fi
|
||||
|
||||
echo "Building $OUTPUT_FILE"
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
target_root_dir="$tmp_dir"
|
||||
target_worker_dir="$tmp_dir/packages/worker"
|
||||
|
||||
mkdir -p "$target_worker_dir"
|
||||
|
||||
copy_package() {
|
||||
src=$1
|
||||
dst=$2
|
||||
mkdir -p $dst
|
||||
cp "$src/package.json" "$dst/package.json"
|
||||
cp "$src/tsconfig.json" "$dst/tsconfig.json"
|
||||
cp "$src/tsconfig.build.json" "$dst/tsconfig.build.json"
|
||||
cp -r "$src/src" "$dst/src"
|
||||
}
|
||||
|
||||
cp "$ROOT_DIR/yarn.lock" $target_root_dir
|
||||
cp "$ROOT_DIR/package.json" $target_root_dir
|
||||
cp "$ROOT_DIR/lerna.json" $target_root_dir
|
||||
|
||||
copy_package $WORKER_DIR $target_worker_dir
|
||||
|
||||
pushd $target_root_dir >/dev/null 2>&1
|
||||
yarn install --silent
|
||||
yarn build
|
||||
yarn install --silent --production=true
|
||||
rm -rf tsconfig.json tsconfig.build.json
|
||||
popd >/dev/null 2>&1
|
||||
|
||||
if [[ "$PLATFORM" == "ios" ]]; then
|
||||
# build plugin
|
||||
pushd "$ROOT_DIR/packages/expo-cocoapods-proxy" >/dev/null 2>&1
|
||||
if command -v brew &> /dev/null; then
|
||||
eval "$(brew shellenv)"
|
||||
else
|
||||
echo "Error: brew command not found in PATH. Please ensure Homebrew is installed and in your PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
bundle install
|
||||
gem build expo-cocoapods-proxy.gemspec
|
||||
mv expo-cocoapods-proxy-*.gem "$target_worker_dir/expo-cocoapods-proxy.gem"
|
||||
popd >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf $target_root_dir/.volta/
|
||||
|
||||
# Create backward-compatible symlink for orchestrator
|
||||
# The orchestrator expects ./src/services/worker/dist/main.js
|
||||
# but the new structure has ./packages/worker/dist/main.js
|
||||
# We're only symlinking the specific file to discover any other legacy path usage
|
||||
mkdir -p "$target_root_dir/src/services/worker/dist"
|
||||
ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/worker/dist/main.js"
|
||||
|
||||
tar zcf $OUTPUT_FILE -C $target_root_dir .
|
||||
|
||||
echo "Tarball is ready: $OUTPUT_FILE"
|
||||
|
||||
rm -rf $tmp_dir
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PACKAGE_DIR="$( cd "$DIR/.." && pwd)"
|
||||
ROOT_DIR="$( cd "$DIR/../../../.." && pwd)"
|
||||
|
||||
pushd $PACKAGE_DIR >/dev/null 2>&1
|
||||
./resources/jest --config jest.config.unit.ts
|
||||
popd >/dev/null 2>&1
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
ROOT_DIR="$( cd "$DIR/../../../.." && pwd)"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=test-env.sh
|
||||
source "$DIR/test-env.sh"
|
||||
|
||||
yarn run jest "$@"
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user