Compare commits

...
11 Commits
Author SHA1 Message Date
Expo CI 079aa5dc66 v16.32.0 2026-01-30 10:11:55 +00:00
Stanisław Chmiela fa8980759f [eas-cli] Add submit:upload-to-asc command (#3358)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We want to move iOS submissions to Linux machines, which requires us to either:
- have Builds generate not only an IPA but also an Xcode asset file for use with Transporter
- reimplement upload ourselves with App Store Connect API.

# How

As much as I fear the unknown (does this support App Clips? do App Clips come in a different package files? what about macOS apps?) I wonder if, by any chance, this would… just work?

Planning to wire this to submissions in some opt-in way and ask for early feedback in Discord.

# Test Plan

Tested manually — uploaded a file as IPA. It obviously failed to process, but at least it uploaded all right.

```
Generating JWT...
Reading App information...
Uploading Build to "turtle-cli-example" (com.expo.turtle.cli.example)...
Creating Build Upload...
Build Upload initialized (ID: 6a3f6a72-d088-4ac8-9cdd-7d1fb83f7c8b). Preparing IPA upload...
IPA Upload initialized (ID: 0374ffc4-c51b-42df-9b2f-48e9a81a62eb). Uploading IPA...
  Uploading chunk 1/1 (offset: 0, length: 359)...
  Chunk 1/1 uploaded.
Committing upload...
Checking upload file status...
File upload (ID: 0374ffc4-c51b-42df-9b2f-48e9a81a62eb) complete!
Checking build upload status...
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)


Errors:
- Invalid package. The uploaded package is corrupt. Verify that you have sufficient disk space on your computer and are uploading only one package at a time. If you created the package manually, verify that it‘s a valid ZIP archive file. (90168)

Build upload (ID: 6a3f6a72-d088-4ac8-9cdd-7d1fb83f7c8b) failed.
    Error: submit:upload-to-asc command failed.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```
2026-01-30 11:08:07 +01:00
Stanisław Chmiela 790085ea9e [worker] Bump build-tools (#3357)
https://github.com/expo/eas-build/pull/668
2026-01-29 11:31:46 -05:00
Will Schurman 🦦 9c7ac49f3e [eas-cli] Remove unnecessary method re-declaration (#3352) 2026-01-28 15:48:17 -08:00
Evan Bacon fa244d6410 Add eas account:usage command (#3334)
## Summary

Add a new `eas account:usage` command that displays account usage and billing information for the current billing cycle. The command is hidden for now, until we fully test it in production.

```
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 estimates for the current cycle

```

- View EAS Build usage with iOS/Android platform breakdown
- View EAS Update usage (MAU and bandwidth)
- Display billing period dates and progressOptional account name argument
- Display upcoming invoice, similarly to the billing website
- If no account name passed in, and user has more than one account, a selection list is shown

- `--json` flag for JSON output (optimized for agents/automation)
- Supports `--non-interactive`
    - Forces JSON output
    - Requires account name if user has more than one account

## Example Output

```
✔ Usage data loaded for example-customer

Account: example-customer
Plan: Production
Concurrencies: 6 total (iOS: 6, Android: 6)
Billing Period: Jan 9, 2026 - Feb 9, 2026
Days: 18 elapsed / 31 total (14 remaining)

EAS Build
  Builds (plan): 10,000/10,000 builds
  ████████████████████ 100%
  Builds (overage): 781 builds ($1462.00)
  Breakdown by platform/worker:
    Android large: 86 builds
    iOS medium: 695 builds

EAS Update
  Unique Updaters (plan): 50,000/50,000 users
  ████████████████████ 100%
  Unique Updaters (overage): 1,021,888 users ($5109.44)
  Bandwidth (plan): 17.7 TiB/40.0 TiB
  █████████░░░░░░░░░░░ 44%

Billing
  Addons:
    Additional Concurrency: 4

  Upcoming invoice:
    EAS Build - Plan credit: $-100.00
    EAS Build - Build (Android: 64 large builds; iOS: 628 medium builds): $1384.00
    EAS Update - 1,094,102 monthly active users: $3857.30
    EAS Workflows - 5,347 jobs (3,970.4 minutes): $71.46
    1 × Expo Application Services - Production (at $99.00 / month): $99.00
    4 Concurrency × EAS Build - Additional Concurrency (at $50.00 / month): $200.00

  Invoice total: $5511.76

View detailed billing: https://expo.dev/accounts/example-customer/settings/billing)
```

## Test plan

- [x] Build passes (`yarn build`)
- [x] TypeScript type check passes (`yarn typecheck`)
- [x] Lint passes for new files
- [x] Unit tests pass (`yarn test packages/eas-cli/src/commands/account/__tests__/usage-test.ts`)
- [x] Manual testing with real account

Slack thread: https://exponent-internal.slack.com/archives/C017N0N99RA/p1769054123592549?thread_ts=1768439335.822519&cid=C017N0N99RA

Closes ENG-18827
2026-01-27 14:22:56 -08:00
Stanisław Chmiela db8a3770f8 [worker] Set DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS in Xcode 26.0 (#3350)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

> Apple's altool in Xcode 26.0 has a bug where it incorrectly selects the wrong app when multiple apps share a common bundle ID prefix (e.g., `com.example`, `com.example.app1`, `com.example.app2`). This causes builds to be uploaded to the wrong app on App Store Connect.

> See: https://github.com/fastlane/fastlane/issues/29698

https://github.com/expo/universe/pull/23310 + https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1765365928911459

# How

If `xcodebuild -version` matches "Xcode 26.0", we'll add `DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS='--use-old-altool'`. This helps if the code does not re-override `$DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS` further, in which case it should do `DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS="overrides $DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS"` or sth like that.

I hope this fixes Launch.

# Test Plan

Tested manually on my computer

```
> spawnSync("xcodebuild", ["-version"]).stdout.includes("Xcode")
true

> spawnSync("xcodebuild", ["-version"]).stdout.includes("Xcode 26.0")
false

(below did not throw)
> spawnSync("abc")
{
  error: Error: spawnSync abc ENOENT
      at Object.spawnSync (node:internal/child_process:1123:20)
      at spawnSync (node:child_process:877:24)
      at REPL12:1:1
      at ContextifyScript.runInThisContext (node:vm:137:12)
      at REPLServer.defaultEval (node:repl:598:22)
      at bound (node:domain:433:15)
      at REPLServer.runBound [as eval] (node:domain:444:12)
      at REPLServer.onLine (node:repl:927:10)
      at REPLServer.emit (node:events:536:35)
      at REPLServer.emit (node:domain:489:12) {
    errno: -2,
    code: 'ENOENT',
    syscall: 'spawnSync abc',
    path: 'abc',
    spawnargs: []
  },
  status: null,
  signal: null,
  output: null,
  pid: 0,
  stdout: null,
  stderr: null
}
```
2026-01-27 23:08:35 +01:00
Stanisław Chmiela be16b0032f [chore] Bring back tagging as it was before (#3349)
https://github.com/expo/eas-cli/commit/15b690c85763fb8428a4255a9ded7e7e9126d4e8 did not work. Maybe `NODE_AUTH_TOKEN` is a special variable or something.
2026-01-27 17:51:53 +01:00
Expo CI 3ef14b5eb7 v16.31.0 2026-01-27 14:09:37 +00:00
Stanisław Chmiela c777b25e92 Add no-index-store Xcode setting (#3348)
See https://stackoverflow.com/questions/71109718/what-is-the-compiler-index-store-enable-xcode-build-settings-default-value#71710412 and https://github.com/flutter/flutter/issues/37231.
2026-01-27 14:51:28 +01:00
Stanisław Chmiela 15b690c857 [chore] Use NPM_ACCESS_TOKEN in steps moving tags between eas-cli versions (#3347)
We still need `NPM_ACCESS_TOKEN` despite OIDC for publishing. https://github.com/expo/eas-cli/actions/runs/21363225445/job/61513769796

------
[Codex Task](https://chatgpt.com/codex/tasks/task_e_6977d0214f5c832898a83f54ba0a68fd)
2026-01-27 12:25:40 +01:00
Will Schurman 🦦 4d04463714 [eas-cli] Remove commented-out code in eas update command (#3344) 2026-01-26 22:08:30 -08:00
31 changed files with 9151 additions and 636 deletions

No files matched your search

+3 -3
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
@@ -46,7 +46,7 @@ jobs:
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_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=$NODE_AUTH_TOKEN > ~/.npmrc
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
+6
View File
@@ -176,12 +176,18 @@ jobs:
- 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 }}
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 }}
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
release-changelog:
name: Update changelog and publish release
+1
View File
@@ -8,6 +8,7 @@ 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))
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.30.0",
"version": "16.32.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+110 -87
View File
@@ -57,6 +57,7 @@ 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)
@@ -176,7 +177,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -193,7 +194,29 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/account/logout.ts)_
_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)_
## `eas account:view`
@@ -210,7 +233,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -224,7 +247,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -276,7 +299,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -297,7 +320,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -317,7 +340,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -337,7 +360,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -360,7 +383,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -401,7 +424,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -420,7 +443,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -437,7 +460,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.30.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.32.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -456,7 +479,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -476,7 +499,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.30.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.32.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -497,7 +520,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.30.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.32.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -532,7 +555,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.30.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.32.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -584,7 +607,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.30.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.32.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -613,7 +636,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.30.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.32.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -639,7 +662,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.30.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.32.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -693,7 +716,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.30.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.32.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -712,7 +735,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.30.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.32.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -731,7 +754,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.30.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.32.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -748,7 +771,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.30.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.32.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -769,7 +792,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -790,7 +813,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -812,7 +835,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.30.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.32.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -832,7 +855,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -854,7 +877,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.30.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.32.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -876,7 +899,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.30.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.32.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -909,7 +932,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.30.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.32.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -932,7 +955,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -953,7 +976,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.30.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.32.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -970,7 +993,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -988,7 +1011,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.30.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.32.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1016,7 +1039,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1041,7 +1064,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1062,7 +1085,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.30.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.32.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1083,7 +1106,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1120,7 +1143,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.30.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.32.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1140,7 +1163,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.30.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.32.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1161,7 +1184,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.30.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.32.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1182,7 +1205,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1196,7 +1219,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.30.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.32.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1210,7 +1233,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1240,7 +1263,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.30.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.32.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1265,7 +1288,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.30.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.32.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1287,7 +1310,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.30.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.32.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1312,7 +1335,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.30.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.32.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1338,7 +1361,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.30.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.32.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1360,7 +1383,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.30.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.32.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1382,7 +1405,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.30.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.32.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1414,7 +1437,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.30.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.32.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1458,7 +1481,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.30.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.32.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1488,7 +1511,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.30.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.32.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1596,7 +1619,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.30.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.32.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1613,7 +1636,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.30.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.32.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1630,7 +1653,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.30.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.32.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1681,7 +1704,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.30.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.32.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1695,7 +1718,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.30.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.32.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1718,7 +1741,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1741,7 +1764,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1759,7 +1782,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1794,7 +1817,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1834,7 +1857,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1854,7 +1877,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.30.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.32.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1875,7 +1898,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.30.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.32.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1899,7 +1922,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.30.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.32.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1924,7 +1947,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1960,7 +1983,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.30.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.32.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -1987,7 +2010,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.30.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.32.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2015,7 +2038,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.30.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.32.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2036,7 +2059,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.30.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.32.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2056,7 +2079,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2077,7 +2100,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.30.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.32.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2098,7 +2121,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2118,7 +2141,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2136,7 +2159,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2158,7 +2181,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2175,7 +2198,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2295,7 +2318,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.30.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.32.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2315,7 +2338,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.30.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.32.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2339,7 +2362,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.30.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.32.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2379,7 +2402,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.30.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.32.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2404,7 +2427,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.30.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.32.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2432,7 +2455,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.30.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.32.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2452,7 +2475,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.30.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.32.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2474,5 +2497,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.30.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.32.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+5436 -353
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.30.0",
"version": "16.32.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -0,0 +1,308 @@
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,28 +1,12 @@
import {
AnyVariables,
Client,
OperationContext,
OperationResult,
OperationResultSource,
TypedDocumentNode,
cacheExchange,
createClient as createUrqlClient,
fetchExchange,
} from '@urql/core';
import { Client, 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';
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>>;
}
// Extend the urql Client to allow for future customizations or type additions ahead of upstream type support.
export interface ExpoGraphqlClient extends Client {}
export function createGraphqlClient(authInfo: {
accessToken: string | null;
@@ -0,0 +1,295 @@
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,
};
}
@@ -0,0 +1,240 @@
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('████████████████████');
});
});
@@ -0,0 +1,409 @@
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;
}
}
}
@@ -0,0 +1,287 @@
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);
}
}
@@ -4,7 +4,6 @@ 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';
@@ -22,7 +21,6 @@ import {
UpdateRolloutInfoGroup,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
// import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
@@ -35,7 +33,6 @@ import {
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
filterCollectedAssetsByRequestedPlatforms,
// findCompatibleBuildsAsync,
generateEasMetadataAsync,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
@@ -639,12 +636,6 @@ export default class UpdatePublish extends EasCommand {
Log.addNewLineIfNone();
// const runtimeToCompatibleBuilds = await Promise.all(
// runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(obj =>
// findCompatibleBuildsAsync(graphqlClient, projectId, obj)
// )
// );
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
@@ -716,58 +707,9 @@ export default class UpdatePublish extends EasCommand {
);
Log.addNewLineIfNone();
}
// NOTE(brentvatne): temporarily disable logging this until we can revisit the formatting
// and the logic for it - it's a bit too aggressive right now, and warns even if you're
// not using EAS Build
//
// const fingerprintsWithoutCompatibleBuilds = runtimeToCompatibleBuilds.find(
// ({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion
// )?.fingerprintInfoGroupWithCompatibleBuilds;
// if (fingerprintsWithoutCompatibleBuilds) {
// const missingBuilds = Object.entries(fingerprintsWithoutCompatibleBuilds).filter(
// ([_platform, fingerprintInfo]) => !fingerprintInfo.build
// );
// if (missingBuilds.length > 0) {
// const project = await AppQuery.byIdAsync(graphqlClient, projectId);
// Log.warn('No compatible builds found for the following fingerprints:');
// for (const [platform, fingerprintInfo] of missingBuilds) {
// const fingerprintUrl = new URL(
// `/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprintInfo.fingerprintHash}`,
// getExpoWebsiteBaseUrl()
// );
// Log.warn(
// formatFields(
// [
// {
// label: `${this.prettyPlatform(platform)} fingerprint`,
// value: fingerprintInfo.fingerprintHash,
// },
// { label: 'URL', value: fingerprintUrl.toString() },
// ],
// {
// labelFormat: label => ` ${chalk.dim(label)}:`,
// }
// )
// );
// Log.addNewLineIfNone();
// }
// }
// }
}
}
// private prettyPlatform(updatePlatform: string): string {
// switch (updatePlatform) {
// case 'android':
// return 'Android';
// case 'ios':
// return 'iOS';
// default:
// return updatePlatform;
// }
// }
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
const nonInteractive = flags['non-interactive'] ?? false;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,268 @@
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),
};
},
};
@@ -1,54 +0,0 @@
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,3 +1,5 @@
/* eslint-disable graphql/required-fields */
import { print } from 'graphql';
import gql from 'graphql-tag';
export const AccountFragmentNode = gql`
@@ -16,3 +18,153 @@ 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
}
`;
@@ -0,0 +1,358 @@
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;
}
}
@@ -0,0 +1,280 @@
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();
});
});
@@ -0,0 +1,16 @@
{
"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"
}
}
@@ -0,0 +1,19 @@
{
"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"
}
}
@@ -0,0 +1,32 @@
{
"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"
}
}
@@ -0,0 +1,45 @@
{
"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"
}
}
@@ -0,0 +1,26 @@
{
"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"
}
}
@@ -0,0 +1,33 @@
{
"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"
}
}
@@ -6,7 +6,7 @@ import {
EstimatedUsage,
UsageMetricType,
} from '../../../graphql/generated';
import { AccountUsageQuery } from '../../../graphql/queries/AccountUsageQuery';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import Log, { link } from '../../../log';
import {
createProgressBar,
@@ -14,7 +14,7 @@ import {
maybeWarnAboutUsageOveragesAsync,
} from '../checkForOverages';
jest.mock('../../../graphql/queries/AccountUsageQuery');
jest.mock('../../../graphql/queries/AccountQuery');
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(
AccountUsageQuery.getUsageForOverageWarningAsync
AccountQuery.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 { AccountUsageQuery } from '../../graphql/queries/AccountUsageQuery';
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import Log, { link } from '../../log';
const THRESHOLD_PERCENT = 85;
@@ -19,11 +19,7 @@ export async function maybeWarnAboutUsageOveragesAsync({
name,
subscription,
usageMetrics: { EAS_BUILD },
} = await AccountUsageQuery.getUsageForOverageWarningAsync(
graphqlClient,
accountId,
currentDate
);
} = await AccountQuery.getUsageForOverageWarningAsync(graphqlClient, accountId, currentDate);
const planMetric = EAS_BUILD?.planMetrics?.[0];
if (!planMetric || !subscription) {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "16.30.0",
"version": "16.32.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -22,7 +22,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "1.0.271",
"@expo/build-tools": "1.0.272",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "1.0.260",
+6
View File
@@ -51,6 +51,7 @@ export function getBuildEnv({
job.platform ?? (config.resourceClass && ResourceClassToPlatform[config.resourceClass]);
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
setEnv(env, 'USE_CCACHE', '1');
@@ -131,6 +132,11 @@ export function getBuildEnv({
setEnv(env, 'EXPO_STAGING', '1');
}
const xcodeVersionResult = spawnSync('xcodebuild', ['-version'], { env });
if (xcodeVersionResult.stdout?.includes('Xcode 26.0')) {
setEnv(env, 'DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS', '--use-old-altool');
}
return env;
}
Generated
+4 -4
View File
@@ -1546,10 +1546,10 @@
resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-2.1.13.tgz#b4950a68a54befa8f1581ae0066477b068dd4757"
integrity sha512-nt3efiJhAWTHl9ikKYrHEuv3dhqCdicsHFRE9LmvtcVsPhXl9bAsm0gbACoLPr7ClP8664H/S6SdVJOD/tw0jg==
"@expo/[email protected]1":
version "1.0.271"
resolved "https://registry.yarnpkg.com/@expo/build-tools/-/build-tools-1.0.271.tgz#07924aafc90a956be2fd465f5be07ced571f4d13"
integrity sha512-rakZ3X4ldXf/RmXQIsZDzWxsnoRHaESzOg+qRhUgkPMBIvDmemPtXEzhBtBEPOgdOmWSzAMAG5O0VMtlUxLlug==
"@expo/[email protected]2":
version "1.0.272"
resolved "https://registry.yarnpkg.com/@expo/build-tools/-/build-tools-1.0.272.tgz#d1a8fca48ca18da3a80fb5f1990df03f20c29c58"
integrity sha512-lN7ZQ+0qmvxJNYPaDQQoDo8OXzDj5xB8jgcpsKYlje58moPmWMGgRMYDXoBDOiR97H6vUn2LQ+F/HmNf0gDskQ==
dependencies:
"@expo/config" "10.0.6"
"@expo/config-plugins" "9.0.12"