Compare commits

..
Author SHA1 Message Date
Douglas Lowder b3bba882b1 Add mutation for getting workflows from git ref 2025-11-20 16:30:50 -08:00
151 changed files with 1778 additions and 21925 deletions

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
+3 -3
View File
@@ -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
+14 -24
View File
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20', '22', '24']
node: ['20', '22']
include:
- node: '20'
coverage: true
-160
View File
@@ -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
-75
View File
@@ -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 }}
-14
View File
@@ -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
-170
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.32.0",
"version": "16.28.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -4
View File
@@ -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"
}
}
-289
View File
@@ -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
View File
@@ -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 -->
+568 -6441
View File
File diff suppressed because it is too large. Load diff
+10 -12
View File
@@ -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"
}
}
-39
View File
@@ -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
View File
@@ -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);
});
});
+1 -1
View File
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
return await PublishMutation.setRolloutPercentageAsync(
graphqlClient,
u.updateId,
rolloutPercentageToSet
rolloutPercentageToSet!
);
})
);
+59 -45
View File
@@ -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'],
+1 -36
View File
@@ -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
View File
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 } },
});
+6 -28
View File
@@ -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');
});
});
+2 -44
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
+1 -8
View File
@@ -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) {
-131
View File
@@ -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
+7 -7
View File
@@ -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"
}
}
-11
View File
@@ -1,11 +0,0 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
# rspec failure tracking
.rspec_status
-3
View File
@@ -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
-12
View File
@@ -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'
-142
View File
@@ -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
-8
View File
@@ -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
-15
View File
@@ -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__)
-8
View 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
-19
View File
@@ -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
-9
View File
@@ -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
-31
View File
@@ -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',
},
},
],
};
-5
View File
@@ -1,5 +0,0 @@
node_modules/
dist/
workingdir/*
!workingdir/.gitkeep
-158
View File
@@ -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)
-44
View File
@@ -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 }}`.
-12
View File
@@ -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,
};
-10
View File
@@ -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;
-16
View File
@@ -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');
-20
View File
@@ -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(),
},
};
});
-80
View File
@@ -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"
}
}
-81
View File
@@ -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
-11
View File
@@ -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
-12
View File
@@ -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