Compare commits

...
85 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
Expo CI 1c31702653 v16.30.0 2026-01-26 15:26:12 +00:00
Stanisław Chmiela b053e0926a [chore] Switch to publishing with OIDC (#3342)
<!-- 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

https://exponent-internal.slack.com/archives/C017N0N99RA/p1769306804484099

# How

Applied a few of the changes we've done to `eas-build`:
- https://github.com/expo/eas-build/commit/784e37c96300b20e3f812083443f4334a77fc17e
- https://github.com/expo/eas-build/commit/ae9dbea0f994d2c43fdf96a885778168a60fa4f7
- https://github.com/expo/eas-build/commit/ec824f1deddef6b5a1459329a3f9273bf9d48832
- https://github.com/expo/eas-build/commit/54ff4e9017beed4e2f98b0794e47612f8f7f6d19

# Test Plan

Merge and try releasing a new version.
2026-01-26 16:21:18 +01:00
Brent Vatne 6eeb15bdce [eas-cli] Fix --source-maps flag compatibility with expo CLI (#3341) 2026-01-25 17:32:09 -08:00
Brent Vatne 279eb1595c [eas-cli] Fix changelog format 2026-01-25 14:08:27 -08:00
Brent Vatne 256b02d3e9 [eas-cli] Fix lint error 2026-01-25 13:09:06 -08:00
Brent Vatne 9f4fef7626 Add missing CHANGELOG entry 2026-01-24 19:00:09 -08:00
Brent Vatne 8cc324e11e [eas-cli] Use --dump-sourcemaps if --source-maps is not provided, for compat 2026-01-24 18:41:22 -08:00
Expo CI f33b4c131d v16.29.0 2026-01-25 01:48:46 +00:00
Brent Vatne e3eb3ad5ea [eas-cli] Add --no-bytecode and --inline-source-maps flags to eas update (#3339) 2026-01-24 17:45:02 -08:00
Stanisław Chmiela 1292465c57 [worker] Bump eas-build packages (#3337)
https://github.com/expo/eas-build/pull/658
2026-01-23 21:54:19 +01:00
Quinlan Jung 🦥 00d2022680 [eas-cli] add warning to channel:delete (#3335)
* Ad[eas-cli] add warning to channel:delete

* Temporary Commit at 1/22/2026, 3:11:19 PM
2026-01-22 15:25:25 -08:00
Stanisław Chmiela 5aaaf1b224 [worker] Bump @expo/build-tools (#3336)
https://github.com/expo/eas-build/pull/667
2026-01-22 21:08:07 +01:00
Stanisław Chmiela aaf14b8bab Add fallback repos to init.gradle (#3329)
<!-- 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

It _looks_ like if cache is configured, it replaces all other repositories, meaning if downloading an artifact from the cache fails, the whole build immediately fails. Instead, I wish Gradle tried downloading the artifact from other repositories.

We expect it to work like this already — https://github.com/expo/turtle-v2/blob/1c44f6e6206cdc703ac9f09ef813f5a0ad93ecb8/internal-docs/maven-cache.md#L7, since the beginning (https://github.com/expo/turtle-v2/pull/139).

# How

Gradle documentation is not clear regarding whether `repositories` add to or replace lists. [Here](https://docs.gradle.org/current/userguide/declaring_repositories.html) it says "add", but I'm not sure the same thing applies to `init.gradle` or if it applies to all `repositories` sections — of dependencies, plugins and initscript.

I did two things to inspect what's happening.

### `./gradlew help --scan`

[Without our Maven cache enabled](https://scans.gradle.com/s/pbd3vg3le6o3u/dependencies/repositories). [With our Maven cache enabled](https://scans.gradle.com/s/vl2ayuzigam2k/dependencies/repositories).

If you go to "Repositories" sections you'll see the one with our Maven cache enabled has our Maven cache as repository PLUS the ones that are used when Maven cache is not enabled.

#### Without cache enabled

<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 14 47" src="https://github.com/user-attachments/assets/33919a95-4f26-427d-9b3c-dca16848ae25" />
<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 14 55" src="https://github.com/user-attachments/assets/b79b951b-321b-4b15-b417-59358c4c5329" />

#### With cache enabled

<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 15 57" src="https://github.com/user-attachments/assets/ebed5389-bd49-4a19-bf49-93d2f622d502" />
<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 16 09" src="https://github.com/user-attachments/assets/27f32964-b318-4f50-8e62-b9a0a50457df" />

### Gradle script printing repositories

Inside the VM added

```gradle
gradle.beforeSettings { settings ->
  settings.pluginManagement.repositories.each { r ->
    println "pluginManagement repo: ${r.name} -> ${r.url}"
  }
  settings.dependencyResolutionManagement.repositories.each { r ->
    println "settings repo: ${r.name} -> ${r.url}"
  }
}

gradle.projectsLoaded {
  gradle.allprojects { p ->
    p.buildscript.repositories.each { r ->
      println "buildscript repo (${p.path}): ${r.name} -> ${r.url}"
    }
    p.repositories.each { r ->
      println "project repo (${p.path}): ${r.name} -> ${r.url}"
    }
  }
}
```

With `init.gradle` containing _only_ our Maven repo, we see

```
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
```

With fallbacks included (with this PR)

```
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:): Google -> https://dl.google.com/dl/android/maven2/
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:react-native-gradle-plugin): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:react-native-gradle-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:react-native-gradle-plugin): Google -> https://dl.google.com/dl/android/maven2/
project repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:react-native-gradle-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:react-native-gradle-plugin): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:settings-plugin): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:settings-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:settings-plugin): Google -> https://dl.google.com/dl/android/maven2/
project repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:settings-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:settings-plugin): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:shared): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:shared): Google -> https://dl.google.com/dl/android/maven2/
project repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:shared): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared-testutil): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:shared-testutil): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:shared-testutil): Google -> https://dl.google.com/dl/android/maven2/
project repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared-testutil): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:shared-testutil): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:): Google -> https://dl.google.com/dl/android/maven2/
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:app): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:app): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:app): Google -> https://dl.google.com/dl/android/maven2/
project repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:app): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:app): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-asset): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-asset): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-asset): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-asset): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-asset): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-constants): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-constants): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-constants): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-constants): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-constants): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-eas-client): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-eas-client): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-eas-client): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-eas-client): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-eas-client): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-file-system): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-file-system): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-file-system): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-file-system): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-file-system): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-font): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-font): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-font): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-font): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-font): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-json-utils): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-json-utils): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-json-utils): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-json-utils): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-json-utils): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-keep-awake): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-keep-awake): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-keep-awake): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-keep-awake): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-keep-awake): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-manifests): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-manifests): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-manifests): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-manifests): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-manifests): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-modules-core): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-modules-core): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-modules-core): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-modules-core): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-modules-core): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-structured-headers): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-structured-headers): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-structured-headers): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-structured-headers): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-structured-headers): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-updates): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-updates): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-updates): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates-interface): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-updates-interface): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-updates-interface): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates-interface): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-updates-interface): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:): Google -> https://dl.google.com/dl/android/maven2/
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:): Google -> https://dl.google.com/dl/android/maven2/
```

You can see how repository fallbacks get configured.

---

Codex also said `repositories` does different things depending on where is it being used — settings/plugins repositories get replaced and project/buildscript get appended. It would explain the outages — settings/plugins are resolved before projects, so a single failing repository there breaks the build either way. It feels like the best course of action now is… test this live?

A reason why Gradle Scan shows something different than the Gradle script might be that Gradle script tries to print stuff as it is configured as soon as possible (`beforeSettings` and stuff). I can imagine `build.gradle`s of projects being able to add repositories to the list considered (which Gradle Scan would show).

One more thing to consider here is `RepositoriesMode`. Default value seems to be [`PREFER_PROJECT`](https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api.initialization.resolve/-repositories-mode/-p-r-e-f-e-r_-p-r-o-j-e-c-t/index.html) which should be causing project to use repositories declared by the project, ignoring those declared in settings. So, theoretically, all projects should ignore our Maven cache, because `build.gradle` always includes a few repositories for dependencies? The other setting is [`PREFER_SETTINGS`](https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api.initialization.resolve/-repositories-mode/-p-r-e-f-e-r_-s-e-t-t-i-n-g-s/index.html) which **ignores** project repositories which I think we definitely don't want.

I even wonder now if with `PREFER_PROJECT` we even use our Maven cache for build dependencies if build comes with `repositories`… May we be using our Maven cache only for buildscript/plugins dependencies which the project does _not_ change?

# Test Plan

Planning to enable Maven cache in staging and merge this. Maven cache in staging doesn't work, which means if the fallbacks work, the build will pass either way.

We should also see no decrease in Maven cache usage in Datadog.
2026-01-22 16:10:31 +01:00
Stanisław Chmiela 65413d1640 [worker] Upgrade @expo/build-tools (#3330)
https://github.com/expo/eas-build/pull/666
2026-01-22 13:12:29 +01:00
Stanisław Chmiela 71ee9469aa [worker] Bump @expo/build-tools (#3326)
https://github.com/expo/eas-build/pull/665
2026-01-21 15:25:53 +01:00
Mustafa Abban 7223a19fc8 [worker] bump build-tools 1.0.267 (#3324) 2026-01-20 17:35:36 -05:00
Mustafa Abban 3f4e826315 [worker] bump build-tools (#3321)
* [worker] bump build-tools

* rem 1.7
2026-01-19 12:57:22 -05:00
Ash Wu ea7cc6b1ea [worker] bump build-tools, eas-build-job & steps to 1.0.265 (#3315)
Signed-off-by: Ash Wu <[email protected]>
2026-01-15 20:01:10 +08:00
Mustafa Abban b5281d64d1 [worker] bump build-tools (#3313)
* [worker] bump build-tools

* [worker] bump build-tools
2026-01-14 09:33:22 -05:00
Mustafa Abban e51fe11aca [worker] bump build-tools (#3306) 2026-01-12 15:43:11 -05:00
Evan Bacon 16767f3feb feat(cli): add app clip registration for multi-target (#3300)
* bump apple utils

* Add App Clip support to iOS bundle ID registration

Detects App Clips via entitlements and registers App Clip bundle identifiers with parent app relationships. Updates related interfaces and logic to handle App Clip-specific requirements during bundle ID creation.

* Update CHANGELOG.md

* Update ensureAppExists.ts
2026-01-12 12:39:11 -08:00
Stanisław ChmielaandClaude c42b1a004c [gha] Merge setup, build, and deploy into single job (#3303)
## Why

The build-and-deploy-worker workflow had three separate jobs (setup, build-workers, deploy) with complex inter-job dependencies and redundant conditional logic. I think if we put it all in one job it should make stuff faster.

## How

Consolidated all three jobs into a single `build-and-deploy` job with conditional steps:

1. **Check**: Verify if tarballs for the current SHA exist in GCS (when deploying)
2. **Build**: Execute EAS workflow if needed (PR testing or cache miss)
3. **Upload**: Upload tarballs to GCS with SHA-based naming
4. **Deploy**: Copy tarballs to environment-specific names (when deploying)

Key simplifications:
- **Assumption**: Main branch always specifies an environment (staging/production), so `environment == ''` only occurs on PR builds
- **SHA-based caching**: Check cache by SHA regardless of branch - if the code is identical (same SHA), reuse existing tarballs
- **Clearer conditions**: Each step has a single clear purpose without overlapping branch-specific logic

I also added back building Linux tarball on Linux not to mix CPU architectures accidentally.

## Test Plan

[Build on PR](https://github.com/expo/eas-cli/actions/runs/20920297113/job/60104039095?pr=3303). [Deployment from a branch](https://github.com/expo/eas-cli/actions/runs/20921082109/job/60106516235).

Co-authored-by: Claude <[email protected]>
2026-01-12 21:21:38 +01:00
Stanisław Chmiela 8a4bf6f068 [worker] Coalesce worker builds in EAS (#3296)
# Why
- Build Android and iOS worker tarballs together in a single EAS workflow run
- Avoid split builds across different machines and simplify uploads

# How
- Update the EAS workflow to build and upload both iOS and Android tarballs
- Update the GitHub Actions workflow to run the EAS workflow once and upload both artifacts
- Cancel the EAS workflow run when the GitHub Actions job is canceled

# Test plan
- Verified auto-cancel behavior: https://github.com/expo/eas-cli/actions/runs/20849238272/job/59900072125
2026-01-12 13:26:04 +01:00
Stanisław Chmiela 03e1c18d34 [infra] Cancel EAS runs when system-tests workflow is canceled (#3298)
# Why

- Canceling the GitHub Actions workflow should cancel the underlying EAS build/workflow runs to avoid wasting resources.

# How

- Start the iOS build and Android workflow with `--no-wait` and capture their IDs from JSON.
- Poll the EAS build/workflow statuses to determine success/failure.
- Persist IDs to `GITHUB_ENV` and cancel them in a `if: cancelled()` step.

# Test Plan

https://github.com/expo/eas-cli/actions/runs/20859556010/job/59935355509
2026-01-12 12:24:21 +01:00
Ash Wu 2bf8822b1d [worker] add yarn-link script for worker/eas-build local dev (#3302)
Signed-off-by: Ash Wu <[email protected]>
2026-01-12 18:54:40 +08:00
Evan Bacon 460f98da76 fix(metadata): Handle missing Apple metadata gracefully in tasks (#3299)
* Handle missing Apple metadata gracefully in tasks

Updated Apple metadata tasks to skip operations when app version or info is not available, instead of throwing errors. This improves support for cases where the app or version has not been set up yet, particularly for metadata:pull. Tests have been updated to reflect the new behavior.

* Update CHANGELOG.md
2026-01-10 12:47:43 -08:00
Stanisław Chmiela 3a7182bb82 [infra] Add system tests to worker deployment pipeline (#3286)
This PR adds system tests that run after staging deployment and gate production deployment.

## Changes
- Add `system-tests.yml` workflow (can be dispatched manually or called from worker.yml)
- Add EAS workflow fixture for Android build
- Gate production deployment on system tests passing
- Run iOS build (`eas build`) and Android build (`eas workflow:run`) concurrently

## Testing
Once merged, the workflow can be manually dispatched to test, or it will run automatically on the next push to main that touches worker files.
2026-01-09 11:46:38 +00:00
Stanisław Chmiela f963dc711b [worker] Upgrade koa to v3 in worker (#3294)
Closes https://github.com/expo/eas-cli/pull/3293
2026-01-09 11:31:21 +01:00
Stanisław Chmiela a059852bd9 [worker] Build worker in production (#3295)
<!-- 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

Gives us more concurrencies for building worker (which is not a huge problem, but may be nice). Also, IF we botched worker staging, we won't break the whole pipeline (system tests won't let worker deploy to production so worker in production will still work and let us rebuild worker).

# How

Changed app ID and token to expo.dev ones.

# Test Plan

CI ran ok.
2026-01-09 11:17:26 +01:00
Stanisław Chmiela 33e0b0901c Bump TypeScript to 5.5.4 and @types/node to 20.14.8 (#3291)
## Summary

Upgrades TypeScript and `@types/node` across the monorepo to align versions and prepare for future package consolidation.

### Changes

| Package | TypeScript | @types/node |
|---------|-----------|-------------|
| Root | 5.3.3 → 5.5.4 | 20.11.0 → 20.14.8 |
| eas-build-cache-provider | 5.3.3 → 5.5.4 | - |
| eas-cli | 5.3.3 → 5.5.4 | - |
| eas-json | 5.3.3 → 5.5.4 | - |
| worker | 5.4.5 → 5.5.4 | (unchanged at 20.14.8) |

### TypeScript 5.5 Highlights

- Inferred type predicates for cleaner code
- Better control flow narrowing for indexed accesses
- JSDoc `@import` tag support

### Breaking Changes Reviewed

- ✅ No enum members using `Infinity`, `-Infinity`, or `NaN`
- ✅ No issues with mapped type behavior changes
- ✅ No issues with stricter `lib.d.ts` definitions

### Testing

- ✅ `yarn install` - passes
- ✅ `yarn build` - all packages build successfully
- ✅ `yarn test` - all 1221 tests pass
2026-01-09 10:41:55 +01:00
Stanisław Chmiela 8ba877ab81 [worker] Simplify tests (#3285)
# Why

The worker integration tests were slow and required platform-specific CI runners (macOS for iOS, Linux for Android) because they attempted to build real React Native apps. This made the CI pipeline complex, slow, and resource-intensive.

# How

- **Replace full-build tests with fast mocked tests**: Instead of running actual Android/iOS builds, we now mock `@expo/build-tools` at the library boundary. This lets us test the worker's WebSocket communication, job dispatch, state sync, abort handling, and hanging worker detection without the overhead of real builds.

- **Add new `jobExecution.test.ts`**: A new integration test that verifies the worker accepts jobs, executes them (via mocked builders), and returns success/error responses correctly.

- **Update existing tests**: `abortBuild.test.ts`, `stateSync.test.ts`, and `possiblyHangingWorker.test.ts` now mock `@expo/build-tools` instead of the worker's internal `build.ts` module, allowing more of the real code path to execute.

- **Simplify CI**: Consolidated the separate `worker-android-checks` and `worker-ios-checks` jobs into a single `worker-checks` job running on `ubuntu-latest`. Removed dependencies on `direnv`, `setup-gcloud`, and platform-specific scripts.

- **Cleanup**: Deleted unused files including `__mocks__/build.ts`, macOS-specific Jest config, CI shell scripts, and the `direnv` internal action.

The build-build part is going to be tested in system tests running in CI on `main`.

# Test Plan

cd packages/worker
yarn test:integrationAll 19 tests pass in ~6 seconds (down from minutes with real builds).

```bash
cd packages/worker
yarn test:integration
```

All 19 tests pass in ~6 seconds (down from minutes with real builds).
2026-01-08 16:11:06 +01:00
Stanisław Chmiela 9f6e387888 [worker] Bump packages (#3289)
Lands https://github.com/expo/turtle-v2/commit/df16c54eb74583fd2ac6effbe0ccec9010ae6525.
2026-01-08 16:10:46 +01:00
Stanisław Chmiela c10691c339 [infra] Build worker on EAS instead of GHA's macos-latest (#3287)
## Why

The `worker.yml` GitHub Actions workflow was recently moved from a private repository to this public one. This means we lost access to private GitHub Actions runners and now have to wait in long queues for public `macos-latest` runners when building the iOS worker tarball.

## How

Instead of building the iOS worker tarball on GitHub Actions `macos-latest`, we now:

1. **Trigger an EAS Workflow** from GitHub Actions that builds the tarball on EAS's macOS runners
2. **Upload the tarball as an EAS artifact** using `eas/upload_artifact`
3. **Uploads the artifact to tarball bucket** from GitHub Actions

### Changes

- Added `packages/worker/app.json` - minimal Expo app config required for EAS
- Added `packages/worker/eas.json` - EAS configuration
- Added `packages/worker/.eas/workflows/build-worker.yml` - EAS Workflow that builds the iOS worker tarball and uploads it as an artifact
- Updated `.github/workflows/build-and-deploy-worker.yml`:
  - Split the matrix build into separate `build-android` and `build-ios` jobs
  - `build-ios` now runs on `ubuntu-latest` and triggers the EAS Workflow using `eas workflow:run --ref --wait --json`
  - Extracts the artifact download URL from the workflow output and streams it directly to GCS

## Test Plan

https://github.com/expo/eas-cli/actions/runs/20796579975 Don't mind the CI failure on the PR, I dispatched the workflow manually later.
2026-01-08 11:16:17 +01:00
Stanisław Chmiela 155b65c8fc [chore\ Add basic CLAUDE.mds (#3284)
Used Claude Code to generate CLAUDE.mds. I hope they help with AI "startup".
2026-01-07 15:15:40 +01:00
Stanisław Chmiela cdbc95295c [worker] Add worker from turtle-v2 (#3268)
<!-- 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 streamline DX of the worker part of EAS. Part of it is colocating all code in one repository.

# How

Copied code over to `eas-cli`. Adjusted code — each commit represents a single piece of work towards adjusting code to this repository. The main changes are around removing dependency on `turtle-v2` internal libraries.

One thing to figure out still is deployment process. I've added `build-and-deploy-worker` which is capable of deploying to staging and production. The question is how to automate it. In `turtle-v2` we have system tests. Should we just copy them over to here? (Don't like that.) Should we streamline system tests now and instead of having this weird `turtle-v2-example` repo, create project on the go? Should we deploy worker manually for now?

I think I'd rather deploy manually for now?

# Test Plan

Deployed worker to staging. Ran system tests. They succeeded!! https://github.com/expo/turtle-v2/actions/runs/20042956545/job/57535011098
2026-01-07 11:44:26 +01:00
Quinlan Jung 🦥 414ccb92c0 [eas-cli] delete channel in background (#3278)
* [eas-cli] delete channel in background

* Temporary Commit at 1/5/2026, 3:02:00 PM
2026-01-05 15:09:32 -08:00
Douglas Lowder aabf519a9d eas init should fix and validate project name/slug before creating a project on the server (#3277)
# Why

The CLI does not validate the project name and slug before creating the project on the server, leading to unnecessary GraphQL errors.

# How

Copy the server's self-contained validation code to the CLI. Eventually, this code should move to `eas-build` repo for use by both CLI and server. Add code to run the same validation checks that the server GraphQL resolver uses, and add a method to strip invalid characters from the project name before suggesting it at the prompt.

# Test Plan

The server's validation unit tests have been added here as well.
2025-12-18 10:50:21 -08:00
Douglas Lowder 88d9627286 Allow exit from credentials command (#3269)
# Why

The command `eas credentials` does not provide a way to exit other than control-C, if no platform flag is passed in.

# How

Add an "Exit" choice to the platform prompt.

# Test Plan

Tested locally.
2025-12-03 14:44:21 -08:00
Will Schurman 🦦 59ce6d0b65 [ci] Test with node 24 (#3270)
<!-- 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

Node 24 is in LTS, it's time to officially support it and test for it.

# How

Add 24 to CI test matrix.

# Test Plan

CI
2025-12-02 14:48:13 -08:00
HarelSultan 8e64a9c73e feat: add --runtime-version and --platform filters to eas update:list (#3261) 2025-12-02 11:08:18 -08:00
Expo CI 4476c87962 update CHANGELOG.md 2025-11-20 19:58:01 +00:00
Expo CI 18ecd0844a v16.28.0 2025-11-20 19:39:20 +00:00
Colin MacKenzie cc3e7cdbe4 remove package json changes (#3263) 2025-11-20 13:36:15 -06:00
Colin MacKenzie aa7fecb9fe [eas-cli] Fix usage warning bug in eas:build (#3260)
* fix overages bug

* stub changelog

* update pr number

* fix test
2025-11-20 13:24:00 -06:00
Douglas Lowder c73a3e3a1e Update GraphQL from server, fix type error in test (#3262)
# Why

GraphQL test started failing today after a recent server change in updates GraphQL queries. Failed test run at https://github.com/expo/eas-cli/actions/runs/19529909252

# How

- Updated GraphQL generated code
- Fixed a TS type error in one test

# Test Plan

- CI should pass
2025-11-20 11:18:21 -08:00
Quinlan Jung 🦥 37ca2ebd55 [ENG-17987][eas-cli] delete branch in background (#3258)
* [eas-cli] delete branch in background

* Temporary Commit at 11/18/2025, 4:28:38 PM
2025-11-18 16:33:40 -08:00
Colin MacKenzie dead11964e [eas-cli] Sort organization accounts first in eas:new (#3256)
* sort accounts

* update changelog

* fix failing test
2025-11-17 13:44:50 -06:00
Expo CI b66d31d7d7 update CHANGELOG.md 2025-11-13 02:21:00 +00:00
Expo CI ed8170047b v16.27.0 2025-11-13 02:14:02 +00:00
Quinlan Jung 🦥 0e4056ba82 [eas-cli] kick off update patch generation if enabled (#3250)
* [eas-cli] kick off update patch generation if enabled

* Temporary Commit at 11/5/2025, 3:09:27 PM

* Temporary Commit at 11/5/2025, 3:19:31 PM

* Temporary Commit at 11/5/2025, 3:25:14 PM

* Temporary Commit at 11/11/2025, 2:06:29 PM

* Temporary Commit at 11/11/2025, 2:51:21 PM

* Temporary Commit at 11/11/2025, 2:53:51 PM

* Temporary Commit at 11/11/2025, 4:27:57 PM

* Temporary Commit at 11/12/2025, 5:31:14 PM
2025-11-12 18:05:53 -08:00
Kudo Chien 6182ea5a1d [eas-cli] remove plugins evaluation from workflow:run (#3254) 2025-11-12 01:46:16 +08:00
Douglas Lowder c9e7b649ad Fix workflow:status when run ID passed in (#3253)
# Why

In `workflow:status`, the argument name for the passed-in workflow run ID is incorrect. Instead of showing the status of that run, it shows the dialog to select from recent runs.

# How

Fixed the argument name.

# Test Plan

Works when testing locally.
2025-11-10 01:55:11 -08:00
Quinlan Jung 🦥 7a6b796242 [eas-cli] fix yarn watch (#3251)
* [eas-cli] fix yarn watch

* Temporary Commit at 11/6/2025, 12:22:09 PM
2025-11-06 12:37:12 -08:00
Kadi Kraman 804eb9a326 [eas-cli] Fix updating file env vars with env:update --non-interactive (#3249) 2025-11-05 12:12:40 +00:00
Expo CI 8da14f0101 update CHANGELOG.md 2025-10-29 19:26:44 +00:00
Expo CI dc62d22816 v16.26.0 2025-10-29 19:17:21 +00:00
Colin MacKenzie 258c625611 [eas-cli] eas:new - remove hidden tag; update output and project README (#3243)
* fix typo in the readme, change output formatting

* remove hidden flag

* update readme

* remove dev code
2025-10-29 14:10:30 -05:00
Douglas Lowder e84340774d New command workflow:status (#3242)
# Why

Shows the status of a workflow run that has already started.

If the run is in progress, and the `--wait` flag is set, the command will show periodic updates on running jobs, the current job steps, and recent log lines, similarly to `eas workflow:run --wait`.

Otherwise, the command immediately returns with information on the run result, exactly as `eas workflow:run --wait` will show at the end of a run. If the run failed, the failed jobs, failed steps, and last few log lines are shown.

```bash
USAGE
  $ eas workflow:status [WORKFLOW_RUN_ID] [--non-interactive] [--wait] [--json]

ARGUMENTS
  WORKFLOW_RUN_ID  A workflow run ID.

FLAGS
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.
  --[no-]wait        Wait for workflow run to complete. Defaults to false.

DESCRIPTION
  show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent
  workflow runs for the current project.

FLAG DESCRIPTIONS
  --[no-]wait  Wait for workflow run to complete. Defaults to false.

    Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```

# How

Leverages utility code already written for `eas workflow:run` and `eas workflow:logs`.

# Test Plan

- Tested on running and completed workflow runs.
- CI should pass.
2025-10-29 12:00:07 -07:00
Expo CI fbc23f7a7d update CHANGELOG.md 2025-10-28 15:05:45 +00:00
Expo CI 97255ec90d v16.25.1 2025-10-28 13:14:43 +00:00
Phil Pluckthun cfccc87b82 [eas-cli] Remove preview log output from EAS Hosting commands (#3241)
* Remove preview labels from EAS Hosting commands

* Add CHANGELOG entry
2025-10-27 22:25:44 +00:00
Expo CI 09b4f8367e update CHANGELOG.md 2025-10-27 16:55:04 +00:00
Expo CI 45481f9758 v16.25.0 2025-10-27 15:57:32 +00:00
Colin MacKenzie 1119008317 [eas-cli] Display usage warnings on eas:build (#3236)
* first pass

* cleanup

* clean up query

* show warning when builds are close to threshold

* cleaned up query

* tweak test

* more test tweaks
2025-10-27 10:34:40 -05:00
Douglas Lowder 123a0c1b7e Remove outdated update-local-plugin from release workflow (#3240)
# Why

The manual release workflow is broken after #3237, since the local plugin version is no longer hardcoded, but tied to the `@expo/eas-build-job` dependency.

# How

Remove this step from the workflow.

# Test Plan

Workflow should succeed in dry run mode.
2025-10-27 05:51:52 -07:00
Douglas Lowder 2f21fa62b1 New workflow to move latest-eas-build tag (#3233)
# Why

Provide a way to move the "latest-eas-build" version tag added in the EAS CLI release process. After https://github.com/expo/universe/pull/22530, this is the EAS CLI version that will be used by EAS workflows.

# Test Plan

Test with different version inputs, ensure failures are handled correctly.
2025-10-27 04:32:34 -07:00
Colin MacKenzieandDouglas Lowder 8ad449c6b7 [eas-cli] New command - incorporate more user feedback (#3235)
* incorporated feedback

* more feedback

* add changelog entry

* prompt for name

* update agents.md

* fix test

* add PR to changelog

* Update CHANGELOG.md

Co-authored-by: Douglas Lowder <[email protected]>

* remove dev warning

---------

Co-authored-by: Douglas Lowder <[email protected]>
2025-10-24 10:08:43 -05:00
Douglas Lowder fe8ec50806 Add missing env for local builds with latest plugin (#3237)
# Why

Currently, the CLI is using an old version of `eas-cli-local-build-plugin` for builds with `eas build --local`. This is not ideal, as this means that local builds are not using the most recent `build-tools` and other packages.

# How

- Modify `local.ts` so that the version of the plugin matches the version of `@expo/eas-build-job` used by the CLI
- Add some missing environment variables that are required in the most recent releases of `@expo/build-tools`

# Test Plan

- Testing with local builds on my dev machine
- CI should pass
2025-10-23 14:08:22 -07:00
Douglas Lowder 86cefeb259 workflow:run can use --wait and --json together (#3239)
# Why

Customers would like to wait for a workflow run to complete, and get JSON output.

# How

Remove the restriction on --wait and --json flags together, and ensure that when --json is enabled, all messages other than the final JSON result will go to stderr.

# Test Plan

- Tested locally by running workflows
- CI should pass
2025-10-23 12:00:41 -07:00
Kadi Kraman 034e0a1e7f [eas-json] Add default values for android track and releaseStatus (#3229) 2025-10-20 17:55:27 +01:00
Expo CI a6b776915f update CHANGELOG.md 2025-10-19 21:51:50 +00:00
Expo CI 4ae52d0e3d v16.24.1 2025-10-19 21:45:41 +00:00
Douglas Lowder f62720ef5a fix (regression): revert ora dependency change (#3231) 2025-10-19 14:42:20 -07:00
Expo CI af09aaedb3 update CHANGELOG.md 2025-10-19 20:05:21 +00:00
191 changed files with 26636 additions and 2187 deletions

No files matched your search

@@ -0,0 +1,17 @@
name: build-android
on:
workflow_dispatch: {}
jobs:
build:
name: Build Android
type: build
runs_on: linux-large
params:
platform: android
profile: system-test
custom_job:
name: Custom Job
steps:
- name: Echo
run: echo "This is a custom job step"
@@ -0,0 +1,16 @@
name: setup-gcloud
runs:
using: "composite"
steps:
- name: Auth gcloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
with:
version: 548.0.0
project_id: exponentjs
@@ -0,0 +1,205 @@
name: build-and-deploy-worker
# Assumes main branch always specifies an environment (staging/production).
# If environment is empty: PR testing build (no upload, no deploy)
# If environment is set: Build (unless cached on main), upload, and deploy
on:
workflow_dispatch:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
workflow_call:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
# Check if tarballs for this SHA already exist
# If they do, we can skip building regardless of branch
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
- name: Start EAS Workflow to build worker tarballs
id: eas_workflow
# Build if: PR testing (no env), or deploying but tarballs don't exist
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
# Only upload artifacts from EAS if GHA will upload them to GCS
# This avoids unnecessary artifact uploads for PR builds
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--no-wait \
--json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
echo "Error: Could not determine workflow run ID"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for EAS workflow and capture artifacts
id: eas_artifacts
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: Missing workflow run ID"
exit 1
fi
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
case "$STATUS" in
SUCCESS)
break
;;
FAILURE)
echo "EAS workflow failed."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
CANCELED)
echo "EAS workflow canceled."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
ACTION_REQUIRED)
echo "EAS workflow requires action."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
NEW|IN_PROGRESS)
sleep 7
;;
*)
echo "Unexpected workflow status: $STATUS"
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
esac
done
# Skip artifact extraction if we didn't request uploads
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled for this run"
exit 0
fi
# Extract artifact download URLs from workflow output
# Artifacts are in jobs[].artifacts[] for custom jobs
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-android-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
echo "Error: Could not find iOS artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
echo "Error: Could not find Android artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
# Mask the download URLs
echo "::add-mask::$DOWNLOAD_IOS_URL"
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
- name: Cancel EAS workflow on GitHub cancellation
if: cancelled()
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
# Upload when deploying if we built something new
# Don't upload PR builds (no environment) or when using cached tarballs
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
done
+64
View File
@@ -0,0 +1,64 @@
on:
workflow_dispatch:
inputs:
staging_only:
type: boolean
required: true
default: false
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
version:
type: string
required: true
description: "Version number to move the tag to"
dry_run:
type: boolean
required: true
default: true
description: "Run the workflow in dry run mode, without making any NPM changes"
name: Move NPM tags used by EAS builds and workflows
jobs:
move-tag:
name: Move NPM tags used by EAS builds and workflows
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
node-version: 22
- name: Resolve version (fails if the version does not exist)
run: |
set -o pipefail
npm pack --dry-run eas-cli@$INPUT_VERSION
- name: Add latest-eas-build-staging tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
- name: Add latest-eas-build tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
echo "Only moving the staging tag, skipping the production tag"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
+24 -14
View File
@@ -9,6 +9,8 @@ jobs:
release:
name: Create a release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
@@ -24,9 +26,11 @@ jobs:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: yarn install --frozen-lockfile --check-files
@@ -69,9 +73,11 @@ jobs:
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: brew install python-setuptools
@@ -112,9 +118,11 @@ jobs:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: yarn install --frozen-lockfile --check-files
@@ -152,9 +160,11 @@ jobs:
name: Publish to npm
runs-on: ubuntu-latest
needs: [build-linux, build-mac, build-windows]
permissions:
id-token: write
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
@@ -165,31 +175,31 @@ jobs:
run: yarn build
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build-staging tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
- name: Add latest-eas-build tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20', '22']
node: ['20', '22', '24']
include:
- node: '20'
coverage: true
+160
View File
@@ -0,0 +1,160 @@
name: system-tests
on:
workflow_dispatch: {}
workflow_call: {}
jobs:
system-tests:
runs-on: ubuntu-latest
env:
EXPO_TOKEN: ${{ secrets.STAGING_EXPO_DEV_EXPO_SERVICES_GITHUB_ROBOT_ACCESS_TOKEN }}
EXPO_STAGING: "1"
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- name: Create test app
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
- name: Link to EAS project
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
- name: Add bundle identifiers to app.json
working-directory: ${{ runner.temp }}/test-app
run: |
jq '.expo.ios.bundleIdentifier = "dev.expo.workersystemtests" | .expo.android.package = "dev.expo.workersystemtests"' app.json > app.json.tmp
mv app.json.tmp app.json
- name: Configure EAS Build
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest build:configure --platform all
- name: Add system-test profile to eas.json
working-directory: ${{ runner.temp }}/test-app
run: |
jq '.build["system-test"] = { "ios": { "simulator": true }, "android": { "withoutCredentials": true } }' eas.json > eas.json.tmp
mv eas.json.tmp eas.json
- name: Setup EAS workflow
working-directory: ${{ runner.temp }}/test-app
run: |
mkdir -p .eas/workflows
cp ${{ github.workspace }}/.github/fixtures/eas-workflow-build-android.yml .eas/workflows/build-android.yml
- name: Run iOS build and Android workflow concurrently
working-directory: ${{ runner.temp }}/test-app
run: |
set -euo pipefail
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
if [ -z "$BUILD_ID" ]; then
echo "Error: could not determine build ID"
echo "$BUILD_JSON" | jq .
exit 1
fi
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
echo "Resolved BUILD_ID=$BUILD_ID"
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: could not determine workflow run ID"
echo "$WORKFLOW_JSON" | jq .
exit 1
fi
echo "WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID" >> "$GITHUB_ENV"
echo "Resolved WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID"
is_terminal_build_status() {
case "$1" in
FINISHED|ERRORED|CANCELED) return 0 ;;
*) return 1 ;;
esac
}
is_terminal_workflow_status() {
case "$1" in
SUCCESS|FAILURE|CANCELED|ACTION_REQUIRED) return 0 ;;
*) return 1 ;;
esac
}
BUILD_STATUS=""
WORKFLOW_STATUS=""
TIMEOUT_SECONDS=1800
START_TIME=$(date +%s)
while true; do
ELAPSED=$(( $(date +%s) - START_TIME ))
if [ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ]; then
echo "Timeout: polling exceeded 30 minutes"
exit 1
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
echo "iOS build status: $BUILD_STATUS"
echo "Android workflow status: $WORKFLOW_STATUS"
if is_terminal_build_status "$BUILD_STATUS" && is_terminal_workflow_status "$WORKFLOW_STATUS"; then
break
fi
sleep 7
done
EXIT_CODE=0
if [ "$BUILD_STATUS" != "FINISHED" ]; then
echo "Build did not finish successfully: $BUILD_STATUS"
EXIT_CODE=1
fi
if [ "$WORKFLOW_STATUS" != "SUCCESS" ]; then
echo "Workflow did not finish successfully: $WORKFLOW_STATUS"
EXIT_CODE=1
fi
exit "$EXIT_CODE"
- name: Cancel EAS runs on workflow cancellation
if: always() && (cancelled() || failure())
working-directory: ${{ runner.temp }}/test-app
run: |
set -euo pipefail
if [ -n "${BUILD_ID:-}" ]; then
echo "Canceling EAS build: $BUILD_ID"
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
echo "$BUILD_CANCEL_OUTPUT"
else
echo "build:cancel produced no output"
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$BUILD_STATUS" ]; then
echo "Post-cancel build status: $BUILD_STATUS"
else
echo "Post-cancel build status unavailable"
fi
fi
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
echo "$WORKFLOW_CANCEL_OUTPUT"
else
echo "workflow:cancel produced no output"
fi
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$WORKFLOW_STATUS" ]; then
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
else
echo "Post-cancel workflow status unavailable"
fi
fi
+75
View File
@@ -0,0 +1,75 @@
name: worker
on:
push:
branches: [main]
paths:
- "packages/worker/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
pull_request:
paths:
- "packages/worker/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
concurrency:
group: worker-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
jobs:
worker-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- run: yarn install --frozen-lockfile --check-files
- name: Run unit tests
working-directory: packages/worker
run: yarn test:unit
- name: Run integration tests
working-directory: packages/worker
run: yarn test:integration
worker-build:
needs:
- worker-checks
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: ${{ github.ref == 'refs/heads/main' && 'staging' || '' }}
system-tests-staging:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- worker-build
- worker-checks
uses: ./.github/workflows/worker-system-tests.yml
secrets: inherit
deploy-worker-production:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- system-tests-staging
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: production
notify-slack:
runs-on: ubuntu-latest
needs:
- deploy-worker-production
if: github.ref == 'refs/heads/main' && always() && !cancelled()
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
channel: turtle-bots
slack_webhook: ${{ secrets.SLACK_EAS_WORKFLOWS_WEBHOOK }}
+90 -2
View File
@@ -8,14 +8,102 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Hidden beta version of new `eas account:usage` command for viewing account usage and billing estimates. ([#3334](https://github.com/expo/eas-cli/pull/3334) by [@douglowder](https://github.com/douglowder), [@EvanBacon](https://github.com/EvanBacon))
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
- Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon))
- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
### 🐛 Bug fixes
- Fix `--source-maps` flag compatibility with older SDKs that only support it as a boolean flag. ([#3341](https://github.com/expo/eas-cli/pull/3341) by [@brentvatne](https://github.com/brentvatne))
- Use `--dump-sourcemaps` as fallback when `--source-maps` is not provided to `eas update`, for backwards compatibility. ([8cc324e1](https://github.com/expo/eas-cli/commit/8cc324e1) by [@brentvatne](https://github.com/brentvatne))
- Fix `metadata:pull` failing for apps with only a live version by falling back to live app version and info. ([#3299](https://github.com/expo/eas-cli/pull/3299) by [@EvanBacon](https://github.com/EvanBacon))
- eas init should fix and validate project name and slug. ([#3277](https://github.com/expo/eas-cli/pull/3277) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Test with node 24. ([#3270](https://github.com/expo/eas-cli/pull/3270) by [@wschurman](https://github.com/wschurman))
- Delete channel in background. ([#3278](https://github.com/expo/eas-cli/pull/3278) by [@quinlanj](https://github.com/quinlanj))
- Add warning to channel:delete. ([#3335](https://github.com/expo/eas-cli/pull/3335) by [@quinlanj](https://github.com/quinlanj))
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
### 🎉 New features
- `eas:new` sorts organization accounts first in account selection. ([#3256](https://github.com/expo/eas-cli/pull/3256) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
- Fix `eas:build` bug where overages are checked before config is ready. ([#3260](https://github.com/expo/eas-cli/pull/3260) by [@mackenco](https://github.com/mackenco))
### 🧹 Chores
- Delete branch in background. ([#3258](https://github.com/expo/eas-cli/pull/3258) by [@quinlanj](https://github.com/quinlanj))
## [16.27.0](https://github.com/expo/eas-cli/releases/tag/v16.27.0) - 2025-11-13
### 🎉 New features
- Kick off update patch generation if enabled - this is feature is in preview, don't use it for prod yet please. ([#3250](https://github.com/expo/eas-cli/pull/3250) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Fix workflow:status when run ID passed in. ([#3253](https://github.com/expo/eas-cli/pull/3253) by [@douglowder](https://github.com/douglowder))
- Fix updating file env vars with `env:update --non-interactive`. ([#3249](https://github.com/expo/eas-cli/pull/3249) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- Fix yarn watch. ([#3251](https://github.com/expo/eas-cli/pull/3251) by [@quinlanj](https://github.com/quinlanj))
- Remove the **node_modules** installation requirement for `workflow:run` command. ([#3254](https://github.com/expo/eas-cli/pull/3254) by [@kudo](https://github.com/kudo))
## [16.26.0](https://github.com/expo/eas-cli/releases/tag/v16.26.0) - 2025-10-29
### 🎉 New features
- New command `workflow:status`. ([#3242](https://github.com/expo/eas-cli/pull/3242) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- `eas:new` cleanup (remove `hidden` tag, fix README error, output cleanup). ([#3243](https://github.com/expo/eas-cli/pull/3243) by [@mackenco](https://github.com/mackenco))
## [16.25.1](https://github.com/expo/eas-cli/releases/tag/v16.25.1) - 2025-10-28
### 🧹 Chores
- Remove preview label from EAS Hosting commands ([#3241](https://github.com/expo/eas-cli/pull/3241) by [@kitten](https://github.com/kitten))
## [16.25.0](https://github.com/expo/eas-cli/releases/tag/v16.25.0) - 2025-10-27
### 🎉 New features
- `eas:new` command changes (project name prompt, output cleanup, additional project files, additional prompts). ([#3235](https://github.com/expo/eas-cli/pull/3235) by [@mackenco](https://github.com/mackenco))
- `eas:build` command displays a warning when account is near it's build limit. ([#3236](https://github.com/expo/eas-cli/pull/3236) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
- workflow:run can use --wait and --json together. ([#3239](https://github.com/expo/eas-cli/pull/3239) by [@douglowder](https://github.com/douglowder))
- Add missing env for local builds with latest plugin. ([#3237](https://github.com/expo/eas-cli/pull/3237) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Document the default values for `track` and `releaseStatus`. ([#3229](https://github.com/expo/eas-cli/pull/3229) by [@kadikraman](https://github.com/kadikraman))
## [16.24.1](https://github.com/expo/eas-cli/releases/tag/v16.24.1) - 2025-10-19
### 🐛 Bug fixes
- Revert change in ora version. ([#3231](https://github.com/expo/eas-cli/pull/3231) by [@douglowder](https://github.com/douglowder))
## [16.24.0](https://github.com/expo/eas-cli/releases/tag/v16.24.0) - 2025-10-19
### 🎉 New features
- workflow:run --wait shows periodic job and step status updates. ([#3227](https://github.com/expo/eas-cli/pull/3227) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- workflow:logs should show logs from build and repack jobs. ([#3226](https://github.com/expo/eas-cli/pull/3226) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
## [16.23.1](https://github.com/expo/eas-cli/releases/tag/v16.23.1) - 2025-10-16
### 🐛 Bug fixes
+170
View File
@@ -0,0 +1,170 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Monorepo Structure
This is a **Lerna-based monorepo** with four packages:
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
- Built on oclif (Open CLI Framework)
- Entry point: `packages/eas-cli/bin/run`
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
- Validates `eas.json` configuration files using Joi schemas
- Manages build, submit, and deploy profiles
- Supports JSON5 via golden-fleece
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
- Optimizes build caching for Expo CLI
4. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
- Runs on EAS build VMs/pods
- WebSocket server for communication with Turtle Launcher
- Wraps `@expo/build-tools` to execute actual React Native builds
> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them.
## Common Development Commands
### Setup
```bash
yarn install # Install all dependencies
yarn build # Build all packages in the monorepo
```
### Development
```bash
yarn start # Watch mode with parallel builds across all packages
yarn watch # Alias for start
# Development with relaxed TypeScript rules (allows unused variables)
yarn start-allow-unused
```
### Testing
```bash
yarn test # Run all tests across packages (Jest multi-project)
yarn test --watch # Run tests in watch mode
# Run tests for specific package
cd packages/eas-cli && yarn test
cd packages/eas-json && yarn test
cd packages/worker && yarn test
```
### Type Checking & Linting
```bash
yarn typecheck # TypeScript type check all packages
yarn lint # ESLint across all packages
```
### Running EAS CLI Locally
```bash
# From repository root:
yarn eas <command> # Run via root package script
# Or use the binary directly:
packages/eas-cli/bin/run <command>
# Recommended: Create an alias for development
alias easd="$(pwd)/packages/eas-cli/bin/run"
easd build --help
```
## Build System
Each package has independent TypeScript compilation:
- `eas-cli`: `src/` → `build/`
- `eas-json`: `src/` → `build/`
- `eas-build-cache-provider`: `src/` → `build/`
- `worker`: `src/` → `dist/`
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node18)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
## Testing Architecture
- **Framework**: Jest with multi-project configuration
- **Root config**: `jest.config.ts` (combines eas-cli, eas-json, worker)
- **Test locations**: `__tests__/` directories alongside source files
- **Mocking**:
- File system: `memfs`
- HTTP: `nock`
- Utilities: `ts-mockito`, `mockdate`
Run a single test file:
```bash
yarn test <path-to-test-file>
# Example:
yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts
```
## Environment Variables
```bash
# API Configuration
export EXPO_STAGING=1 # Use staging API (https://staging-api.expo.dev)
export EXPO_LOCAL=1 # Use local API (http://127.0.0.1:3000)
# Development
export EAS_NO_VCS=1 # Disable version control checks
export EXPO_DEBUG=1 # Enable debug logging
```
## Code Style & Conventions
### Validation
All changes should be validated with TypeScript and the linter before committing:
```bash
yarn typecheck # Validate TypeScript types
yarn lint # Run ESLint
```
### Logging
Use the `Log` module from `@expo/logger`:
```typescript
import { Log } from '@expo/logger';
Log.log('Info message');
Log.warn('Warning message');
Log.error('Error message');
```
### Pull Request conventions
When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.github/PULL_REQUEST_TEMPLATE).
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
4. Notifications sent to Slack #eas-cli channel
## Important Notes
- **Node Version**: Requires Node.js >= 18.0.0 (managed via Volta)
- **Package Manager**: Uses Yarn 1.22.21
- **Compilation Target**: CommonJS with Node resolution
+3 -1
View File
@@ -4,9 +4,11 @@ import type { Config } from 'jest';
import easCliConfig from './packages/eas-cli/jest.config';
// eslint-disable-next-line import/no-relative-packages
import easJsonConfig from './packages/eas-json/jest.config';
// eslint-disable-next-line import/no-relative-packages
import workerConfig from './packages/worker/jest.config.unit';
const config: Config = {
projects: [easCliConfig, easJsonConfig],
projects: [easCliConfig, easJsonConfig, workerConfig],
testPathIgnorePatterns: ['.*'],
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.24.0",
"version": "16.32.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -4
View File
@@ -21,21 +21,21 @@
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.11.0",
"@types/node": "20.14.8",
"eslint": "8.56.0",
"eslint-config-universe": "13.0.0",
"eslint-plugin-async-protect": "3.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "8.2.3",
"lerna": "9.0.3",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"ts-jest": "29.1.1",
"typescript": "5.3.3"
"typescript": "5.5.4"
},
"volta": {
"node": "20.11.0",
"node": "20.19.4",
"yarn": "1.22.21"
}
}
@@ -0,0 +1,60 @@
# CLAUDE.md - eas-build-cache-provider
This file provides guidance for working with the **eas-build-cache-provider** package.
## Overview
A build cache provider plugin for Expo CLI. Published as `eas-build-cache-provider` on npm.
- Provides remote caching for Expo builds
- Optimizes build times by caching intermediate build artifacts
- Integrates with EAS infrastructure
## Development Commands
```bash
yarn build # TypeScript compilation (src → build)
yarn watch # Watch mode
yarn typecheck # Type check
yarn test # Run tests
```
## Usage
### Installation
Install as a dev dependency in your Expo project:
```bash
npm install --save-dev eas-build-cache-provider
# or
yarn add --dev eas-build-cache-provider
```
### Configuration
Update your **app.json** to enable the EAS build cache provider:
```json
{
"expo": {
"experiments": {
"buildCacheProvider": "eas"
}
}
}
```
## How It Works
The build cache provider:
1. Hooks into Expo CLI's build process
2. Uploads build cache artifacts to EAS infrastructure
3. Downloads cached artifacts on subsequent builds
4. Improves build times by reusing cached dependencies and build outputs
## Important Notes
- **Experimental**: This is an experimental feature
- **EAS Integration**: Requires EAS account and project setup
- **Cache Scope**: Cache is scoped per project and platform
@@ -1,7 +1,7 @@
{
"name": "eas-build-cache-provider",
"description": "A build cache provider plugin for the Expo CLI",
"version": "16.4.2",
"version": "16.30.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
@@ -24,7 +24,7 @@
"@types/getenv": "^1.0.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.3.3"
"typescript": "5.5.4"
},
"engines": {
"node": ">=18.0.0"
@@ -33,7 +33,11 @@
"license": "MIT",
"main": "build/index.js",
"types": "build/index.d.ts",
"repository": "expo/eas-cli",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-build-cache-provider"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
@@ -50,9 +54,5 @@
],
"publishConfig": {
"access": "public"
},
"volta": {
"node": "20.11.0",
"yarn": "1.22.21"
}
}
+289
View File
@@ -0,0 +1,289 @@
# CLAUDE.md - eas-cli
This file provides guidance for working with the **eas-cli** package.
## Overview
The main CLI tool for Expo Application Services (EAS). Published as `eas-cli` on npm.
- **95+ commands** organized by domain
- Built on **oclif** (Open CLI Framework)
- Entry point: `bin/run`
## Development Commands
### Running Locally
```bash
# From repository root:
yarn eas <command>
# Or use the binary directly:
packages/eas-cli/bin/run <command>
# Recommended alias for development:
alias easd="$(pwd)/packages/eas-cli/bin/run"
easd build --help
```
### Build & Development
```bash
yarn build # TypeScript compilation (src → build)
yarn watch # Watch mode
yarn watch-allow-unused # Watch mode with relaxed TS rules
yarn typecheck # Type check without building
yarn typecheck-for-build # Type check for production build
```
### Testing
```bash
yarn test # Run all tests
yarn test <path-to-file> # Run specific test file
```
### GraphQL Code Generation
```bash
yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
```
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
### oclif Commands
```bash
yarn oclif readme # Auto-generate README.md from command metadata
```
## Command Architecture
### Base Command Class
All commands extend `EasCommand` (in `src/commandUtils/EasCommand.ts`).
### Context System
Commands declare required context via `ContextOptions`. This is a powerful system that provides commands with pre-initialized resources based on their needs.
Available context types:
- **LoggedIn**: Requires authentication
- Provides: `actor` (user info), `graphqlClient` (authenticated GraphQL client)
- **MaybeLoggedIn**: Optional authentication
- Provides: `actor` (may be undefined), `graphqlClient`
- **ProjectConfig**: Requires project with app.json/app.config
- Provides: `projectId`, `exp` (Expo config), project directory
- **ProjectDir**: Project directory context
- Provides: `projectDir`, `projectRoot`
- **VcsClient**: Version control system integration
- Provides: `vcsClient` (Git operations)
- **ServerSideEnvironmentVariables**: Environment variable management
- Provides: methods to interact with server-side env vars
Example command structure:
```typescript
import EasCommand from '../../commandUtils/EasCommand';
export default class Build extends EasCommand {
static override description = 'start a build';
static override ContextOptions = {
...EasCommand.ContextOptions.LoggedIn, // Requires auth
...EasCommand.ContextOptions.ProjectConfig, // Requires project
};
async runAsync(): Promise<void> {
// Context is automatically initialized
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
nonInteractive: false,
});
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
nonInteractive: false,
});
// Your command logic here
}
}
```
### Command Discovery
- **Location**: Commands are auto-discovered from `build/commands/` by oclif
- **Manifest**: `oclif.manifest.json` is generated during `prepack`
- **Topics**: Organized into topics (account, build, channel, update, etc.)
## API Architecture
### GraphQL API (Primary)
**Location**: `src/graphql/`
**Client**: `ExpoGraphqlClient` (urql-based wrapper)
- Automatic retry logic with exponential backoff
- Cache management (cache-first vs network-only)
- Transient error handling
- Authentication via Bearer token or session secret
**Structure**:
```
src/graphql/
├── generated.ts # Generated TypeScript types
├── queries/ # 24+ query modules
├── mutations/ # 17+ mutation modules
└── types/ # GraphQL fragments
```
**Making GraphQL Requests**:
```typescript
// In a command with LoggedIn context:
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
nonInteractive: false,
});
const result = await graphqlClient
.query(MyQuery, { variables: { id: projectId } })
.toPromise();
if (result.error) {
throw new Error(result.error.message);
}
const data = result.data;
```
### REST API v2 (Legacy)
**Client**: `ApiV2Client` in `src/api.ts`
**Base URLs**:
- Production: `https://api.expo.dev`
- Staging: `https://staging-api.expo.dev` (set `EXPO_STAGING=1`)
- Local: `http://127.0.0.1:3000` (set `EXPO_LOCAL=1`)
**Methods**: GET, POST, PUT, DELETE
**Error Handling**: `ApiV2Error` wrapper
## Directory Structure
```
src/
├── commands/ # CLI command implementations (organized by domain)
│ ├── account/ # Login, logout, account management
│ ├── build/ # Build orchestration commands
│ ├── branch/ # Update branch management
│ ├── channel/ # Update channel management
│ ├── credentials/ # iOS/Android credential management
│ ├── deploy/ # Deployment commands
│ ├── device/ # Apple device management
│ ├── env/ # Environment variable management
│ ├── fingerprint/ # Fingerprint comparison/generation
│ ├── metadata/ # App store metadata management
│ ├── project/ # Project initialization and info
│ ├── secret/ # Secret management (legacy)
│ ├── submit/ # App store submission
│ ├── update/ # EAS Update management
│ ├── webhook/ # Webhook management
│ └── workflow/ # Workflow management
├── commandUtils/ # Base command classes and shared utilities
├── build/ # Build orchestration logic
├── credentials/ # Credential management system
├── project/ # Project configuration and publishing
├── update/ # EAS Update functionality
├── submit/ # App store submission logic
├── user/ # Authentication and user management
├── graphql/ # GraphQL queries, mutations, and types
├── analytics/ # Rudder Analytics integration
├── vcs/ # Version control abstraction (Git)
└── utils/ # Shared utilities
```
## Common Patterns
### Working with eas.json
```typescript
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
// Read eas.json
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJson = await EasJsonUtils.readAsync(projectDir);
// Access build profiles
const buildProfile = easJson.build?.[profileName];
```
### Project Context
```typescript
// In a command with ProjectConfig context:
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
nonInteractive: false,
});
// projectId: EAS project ID
// exp: Parsed Expo config (app.json/app.config.js)
```
### Authentication Flow
1. **SessionManager** handles login/logout (`src/user/SessionManager.ts`)
2. Authentication methods:
- Interactive login
- SSO via browser launcher
- Session secrets
- Access tokens
3. Credentials stored via `UserSettings`
### Feature Flags
- Server-side feature gating via `FeatureGating` class
- Environment variable overrides via `FeatureGateEnvOverrides`
- Retrieved from Actor during authentication
### Error Handling
Extend `CommandError` or use specific error classes in `src/commandUtils/errors.ts`:
```typescript
import { CommandError } from '../commandUtils/errors';
throw new CommandError('BUILD_FAILED', 'The build failed due to...');
```
### Analytics
- **Provider**: Rudder Analytics (`@expo/rudder-sdk-node`)
- Events tracked for builds, submissions, commands
- User can opt-out via settings
## Testing
- **Location**: `__tests__/` directories alongside source files
- **Mocking**:
- File system: `memfs`
- HTTP: `nock`
- Utilities: `ts-mockito`, `mockdate`
Example test:
```typescript
import { vol } from 'memfs';
import nock from 'nock';
describe('MyCommand', () => {
beforeEach(() => {
vol.reset();
nock.cleanAll();
});
it('should do something', async () => {
// Test implementation
});
});
```
## Important Notes
- **oclif**: Commands auto-discovered from `build/commands/`
- **README**: Auto-generated from oclif - update via `yarn oclif readme`
- **Manifest**: Generated during `prepack` step
- **Templates**: `src/commandUtils/new/templates` are copied to `build/` during build
- **Node Version**: >= 18.0.0
+195 -94
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)
@@ -120,10 +121,12 @@ eas --help COMMAND
* [`eas metadata:lint`](#eas-metadatalint)
* [`eas metadata:pull`](#eas-metadatapull)
* [`eas metadata:push`](#eas-metadatapush)
* [`eas new [PATH]`](#eas-new-path)
* [`eas onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-onboarding-target_project_directory)
* [`eas open`](#eas-open)
* [`eas project:info`](#eas-projectinfo)
* [`eas project:init`](#eas-projectinit)
* [`eas project:new [PATH]`](#eas-projectnew-path)
* [`eas project:onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-projectonboarding-target_project_directory)
* [`eas submit`](#eas-submit)
* [`eas update`](#eas-update)
@@ -152,6 +155,7 @@ eas --help COMMAND
* [`eas workflow:logs [ID]`](#eas-workflowlogs-id)
* [`eas workflow:run [FILE]`](#eas-workflowrun-file)
* [`eas workflow:runs`](#eas-workflowruns)
* [`eas workflow:status [WORKFLOW_RUN_ID]`](#eas-workflowstatus-workflow_run_id)
* [`eas workflow:validate PATH`](#eas-workflowvalidate-path)
* [`eas workflow:view [ID]`](#eas-workflowview-id)
@@ -173,7 +177,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -190,7 +194,29 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -207,7 +233,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.24.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]`
@@ -221,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.24.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]`
@@ -273,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.24.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]`
@@ -294,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.24.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`
@@ -314,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.24.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`
@@ -334,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.24.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]`
@@ -357,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.24.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`
@@ -398,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.24.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]`
@@ -417,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.24.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`
@@ -434,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.24.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]`
@@ -453,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.24.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`
@@ -473,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.24.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`
@@ -494,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.24.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`
@@ -529,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.24.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`
@@ -581,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.24.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`
@@ -610,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.24.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`
@@ -636,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.24.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`
@@ -690,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.24.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`
@@ -709,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.24.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`
@@ -728,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.24.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]`
@@ -745,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.24.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]`
@@ -766,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.24.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]`
@@ -787,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.24.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]`
@@ -809,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.24.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`
@@ -829,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.24.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]`
@@ -851,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.24.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]`
@@ -873,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.24.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]`
@@ -906,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.24.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]`
@@ -929,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.24.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`
@@ -950,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.24.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`
@@ -967,7 +993,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -985,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.24.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]`
@@ -1013,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.24.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`
@@ -1038,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.24.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]`
@@ -1059,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.24.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]`
@@ -1080,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.24.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`
@@ -1117,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.24.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`
@@ -1137,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.24.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`
@@ -1158,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.24.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`
@@ -1179,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.24.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]`
@@ -1193,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.24.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`
@@ -1207,7 +1233,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.24.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]`
@@ -1237,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.24.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]`
@@ -1262,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.24.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`
@@ -1284,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.24.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]`
@@ -1309,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.24.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]`
@@ -1335,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.24.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]`
@@ -1357,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.24.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]`
@@ -1379,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.24.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]`
@@ -1411,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.24.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]`
@@ -1455,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.24.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`
@@ -1485,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.24.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]`
@@ -1593,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.24.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`
@@ -1610,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.24.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`
@@ -1627,7 +1653,28 @@ 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.24.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]`
Create a new project configured with Expo Application Services (EAS)
```
USAGE
$ eas new [PATH] [-p bun|npm|pnpm|yarn]
ARGUMENTS
PATH Path to create the project (defaults to current directory)
FLAGS
-p, --package-manager=(bun|npm|pnpm|yarn) [default: npm] Package manager to use for installing dependencies
DESCRIPTION
Create a new project configured with Expo Application Services (EAS)
ALIASES
$ eas new
```
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1657,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.24.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`
@@ -1671,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.24.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`
@@ -1694,7 +1741,30 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.24.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]`
Create a new project configured with Expo Application Services (EAS)
```
USAGE
$ eas project:new [PATH] [-p bun|npm|pnpm|yarn]
ARGUMENTS
PATH Path to create the project (defaults to current directory)
FLAGS
-p, --package-manager=(bun|npm|pnpm|yarn) [default: npm] Package manager to use for installing dependencies
DESCRIPTION
Create a new project configured with Expo Application Services (EAS)
ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1712,7 +1782,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -1747,7 +1817,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -1787,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.24.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`
@@ -1807,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.24.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`
@@ -1828,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.24.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]`
@@ -1852,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.24.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`
@@ -1860,21 +1930,24 @@ view the recent updates
```
USAGE
$ eas update:list [--branch <value> | --all] [--offset <value>] [--limit <value>] [--json --non-interactive]
$ eas update:list [--branch <value> | --all] [-p android|ios|all] [--runtime-version <value>] [--offset
<value>] [--limit <value>] [--json --non-interactive]
FLAGS
--all List updates on all branches
--branch=<value> List updates only on this branch
--json Enable JSON output, non-JSON messages will be printed to stderr.
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
--non-interactive Run the command in non-interactive mode.
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
-p, --platform=(android|ios|all) Filter updates by platform
--all List updates on all branches
--branch=<value> List updates only on this branch
--json Enable JSON output, non-JSON messages will be printed to stderr.
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
--non-interactive Run the command in non-interactive mode.
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
--runtime-version=<value> Filter updates by runtime version
DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -1910,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.24.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`
@@ -1937,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.24.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`
@@ -1965,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.24.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`
@@ -1986,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.24.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`
@@ -2006,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.24.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`
@@ -2027,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.24.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`
@@ -2048,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.24.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]`
@@ -2068,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.24.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`
@@ -2086,7 +2159,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.24.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`
@@ -2108,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.24.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`
@@ -2125,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.24.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`
@@ -2245,7 +2318,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.24.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]`
@@ -2265,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.24.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]`
@@ -2289,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.24.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]`
@@ -2307,7 +2380,7 @@ FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--ref=<value> Git reference to run the workflow on
--[no-]wait Wait for workflow run to complete. Defaults to false. Cannot be used with the --json flag.
--[no-]wait Wait for workflow run to complete. Defaults to false.
DESCRIPTION
run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow
@@ -2324,12 +2397,12 @@ FLAG DESCRIPTIONS
When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the
project at the chosen reference.
--[no-]wait Wait for workflow run to complete. Defaults to false. Cannot be used with the --json flag.
--[no-]wait Wait for workflow run to complete. Defaults to false.
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.24.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`
@@ -2354,7 +2427,35 @@ 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.24.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]`
show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.
```
USAGE
$ eas workflow:status [WORKFLOW_RUN_ID] [--non-interactive] [--wait] [--json]
ARGUMENTS
WORKFLOW_RUN_ID A workflow run ID.
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--[no-]wait Wait for workflow run to complete. Defaults to false.
DESCRIPTION
show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent
workflow runs for the current project.
FLAG DESCRIPTIONS
--[no-]wait Wait for workflow run to complete. Defaults to false.
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.32.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2374,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.24.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]`
@@ -2396,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.24.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 -->
+8957 -566
View File
File diff suppressed because it is too large. Load diff
+17 -14
View File
@@ -1,25 +1,25 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.24.0",
"version": "16.32.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "2.1.12",
"@expo/apple-utils": "2.1.13",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.239",
"@expo/eas-json": "16.23.0",
"@expo/eas-build-job": "1.0.243",
"@expo/eas-json": "16.30.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.221",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.7.0",
"@expo/package-manager": "1.9.10",
"@expo/pkcs12": "0.1.3",
"@expo/plist": "0.2.0",
"@expo/plugin-help": "5.1.23",
@@ -58,6 +58,7 @@
"https-proxy-agent": "5.0.1",
"ignore": "5.3.0",
"indent-string": "4.0.0",
"invariant": "^2.2.2",
"jks-js": "1.1.0",
"joi": "17.11.0",
"jsonwebtoken": "9.0.0",
@@ -71,7 +72,7 @@
"node-forge": "1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"ora": "9.0.0",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"pngjs": "7.0.0",
"promise-limit": "2.7.0",
@@ -107,6 +108,7 @@
"@types/express": "4.17.15",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"@types/invariant": "^2.2.37",
"@types/jsonwebtoken": "8.5.1",
"@types/mime": "3.0.1",
"@types/node-fetch": "2.6.12",
@@ -129,7 +131,7 @@
"rimraf": "3.0.2",
"ts-mockito": "2.6.1",
"ts-node": "10.9.2",
"typescript": "5.3.3"
"typescript": "5.5.4"
},
"engines": {
"node": ">=18.0.0"
@@ -220,26 +222,27 @@
"-v"
]
},
"repository": "expo/eas-cli",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-cli"
},
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build && yarn build",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "tsc --project tsconfig.build.json && yarn copy-new-templates",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"volta": {
"node": "20.11.0",
"yarn": "1.22.21"
}
}
+39
View File
@@ -0,0 +1,39 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
BackgroundJobReceiptDataFragment,
ScheduleBranchDeletionMutation,
ScheduleBranchDeletionMutationVariables,
} from '../graphql/generated';
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
export async function scheduleBranchDeletionAsync(
graphqlClient: ExpoGraphqlClient,
{
branchId,
}: {
branchId: string;
}
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleBranchDeletionMutation, ScheduleBranchDeletionMutationVariables>(
gql`
mutation ScheduleBranchDeletion($branchId: ID!) {
updateBranch {
scheduleUpdateBranchDeletion(branchId: $branchId) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ branchId }
)
.toPromise()
);
return result.updateBranch.scheduleUpdateBranchDeletion;
}
+1
View File
@@ -33,6 +33,7 @@ export interface IosBuildContext {
}
export interface BuildContext<T extends Platform> {
accountId: string;
accountName: string;
easJsonCliConfig: EasJson['cli'];
buildProfile: BuildProfile<T>;
@@ -125,6 +125,7 @@ export async function createBuildContextAsync<T extends Platform>({
);
const commonContext: CommonContext<T> = {
accountId,
accountName: account.name,
buildProfile,
buildProfileName,
+4 -2
View File
@@ -1,13 +1,14 @@
import { Job, Metadata } from '@expo/eas-build-job';
import { Job, Metadata, version } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process';
import semver from 'semver';
import { getExpoApiBaseUrl } from '../api';
import Log from '../log';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.171';
const PLUGIN_PACKAGE_VERSION = version; // should match version of @expo/eas-build-job
export enum LocalBuildMode {
/**
@@ -60,6 +61,7 @@ export async function runLocalBuildAsync(
...env,
...process.env,
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
__API_SERVER_URL: getExpoApiBaseUrl(),
...(options.skipCleanup || options.skipNativeBuild
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
: {}),
@@ -78,10 +78,12 @@ import { downloadAndMaybeExtractAppAsync } from '../utils/download';
import { truthy } from '../utils/expodash/filter';
import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles';
import { maybeWarnAboutUsageOveragesAsync } from '../utils/usage/checkForOverages';
import { Client } from '../vcs/vcs';
let metroConfigValidated = false;
let sdkVersionChecked = false;
let hasWarnedAboutUsageOverages = false;
export interface BuildFlags {
requestedPlatform: RequestedPlatform;
@@ -409,6 +411,15 @@ async function prepareAndStartBuildAsync({
env,
});
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
hasWarnedAboutUsageOverages = true;
Log.newLine();
await maybeWarnAboutUsageOveragesAsync({ graphqlClient, accountId: buildCtx.accountId });
if (!moreBuilds) {
Log.newLine();
}
}
if (moreBuilds) {
Log.newLine();
const appPlatform = toAppPlatform(buildProfile.platform);
+39
View File
@@ -0,0 +1,39 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
BackgroundJobReceiptDataFragment,
ScheduleChannelDeletionMutation,
ScheduleChannelDeletionMutationVariables,
} from '../graphql/generated';
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
export async function scheduleChannelDeletionAsync(
graphqlClient: ExpoGraphqlClient,
{
channelId,
}: {
channelId: string;
}
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleChannelDeletionMutation, ScheduleChannelDeletionMutationVariables>(
gql`
mutation ScheduleChannelDeletion($channelId: ID!) {
updateChannel {
scheduleUpdateChannelDeletion(channelId: $channelId) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ channelId }
)
.toPromise()
);
return result.updateChannel.scheduleUpdateChannelDeletion;
}
@@ -0,0 +1,41 @@
import {
experienceToAccountName,
fullNameToSlug,
isValidExperienceName,
isValidSlugStrict,
} from '../experienceParser';
describe(isValidExperienceName, () => {
it.each([
['wat', false],
['@wat', false],
['@wat/', false],
['@wat/hello', true],
])('case %p', (candidate, shouldBeValid) => {
expect(isValidExperienceName(candidate)).toEqual(shouldBeValid);
});
});
describe(experienceToAccountName, () => {
it('parses account name', () => {
expect(experienceToAccountName('@wat/huh')).toEqual('wat');
});
});
describe(fullNameToSlug, () => {
it('parses slug', () => {
expect(fullNameToSlug('@wat/huh')).toEqual('huh');
});
});
describe(isValidSlugStrict, () => {
it.each([
['wat', true],
['wat123456-hello-what123456', true],
['@wat', false],
['@wat/', false],
['@wat/hello', false],
])('case %p', (candidate, shouldBeValid) => {
expect(isValidSlugStrict(candidate)).toEqual(shouldBeValid);
});
});
@@ -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,40 @@
/**
* TODO: Move to eas-build repo
*/
import invariant from 'invariant';
const EXPERIENCE_NAME_REGEX = /^@(.*?)\/(.*)/; // @<captureGroup1>/<captureGroup2>
const SLUG_REGEX_STRICT = /^[a-zA-Z0-9_\\-]+$/; // from xdl-schemas slug
export const isValidExperienceName = (experienceName: string): boolean => {
const matches = experienceName.match(EXPERIENCE_NAME_REGEX);
return matches !== null && matches[1].length > 0 && matches[2].length > 0;
};
export const experienceToAccountName = (experienceName: string): string => {
const matches = experienceName.match(EXPERIENCE_NAME_REGEX);
const accountName = matches ? matches[1] : null;
invariant(accountName, `The experience name "${experienceName}" is malformed`);
return accountName;
};
export const fullNameToSlug = (fullName: string): string | null => {
const match = fullName.match(EXPERIENCE_NAME_REGEX);
return match ? match[2] : null;
};
export const isValidSlugStrict = (slug: string): boolean => SLUG_REGEX_STRICT.test(slug);
// keep this list in sync with our project creation CLI https://github.com/expo/expo/blob/main/packages/create-expo/src/Template.ts
export const FORBIDDEN_NAMES = [
'react-native',
'react',
'react-dom',
'react-native-web',
'expo',
'expo-router',
];
export function isSlugForbidden(slug: string): boolean {
return FORBIDDEN_NAMES.includes(slug);
}
@@ -1,4 +1,3 @@
import { LogSpy } from './testUtils';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
@@ -11,23 +10,14 @@ import {
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
jest.mock('../../../ora');
jest.mock('fs-extra');
describe('commands', () => {
let logSpy: LogSpy;
beforeAll(() => {
logSpy = new LogSpy('log');
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
logSpy.restore();
});
describe('cloneTemplateAsync', () => {
it('should clone the template project with ssh', async () => {
const targetProjectDir = '/test/target-project';
@@ -40,13 +30,12 @@ describe('commands', () => {
const result = await cloneTemplateAsync(targetProjectDir);
logSpy.expectLogToContain(`📂 Cloning the project to ${targetProjectDir}`);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'ssh',
showOutput: false,
});
expect(result).toBe(finalTargetProjectDir);
@@ -63,13 +52,12 @@ describe('commands', () => {
const result = await cloneTemplateAsync(targetProjectDir);
logSpy.expectLogToContain(`📂 Cloning the project to ${targetProjectDir}`);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'https',
showOutput: false,
});
expect(result).toBe(finalTargetProjectDir);
@@ -96,6 +84,7 @@ describe('commands', () => {
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
@@ -103,6 +92,7 @@ describe('commands', () => {
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: false,
showSpinner: false,
});
});
});
@@ -118,6 +108,7 @@ describe('commands', () => {
args: ['init'],
cwd: projectDir,
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
@@ -125,6 +116,7 @@ describe('commands', () => {
args: ['add', '.'],
cwd: projectDir,
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
@@ -132,6 +124,7 @@ describe('commands', () => {
args: ['commit', '-m', 'Initial commit'],
cwd: projectDir,
showOutput: false,
showSpinner: false,
});
});
});
@@ -8,7 +8,6 @@ import { promptAsync } from '../../../prompts';
import {
findAvailableProjectNameAsync,
generateProjectConfigAsync,
generateProjectNameVariations,
getAccountChoices,
promptForProjectAccountAsync,
} from '../configs';
@@ -17,6 +16,9 @@ jest.mock('../../../prompts');
jest.mock('../../../log');
jest.mock('fs-extra');
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../utils', () => ({
printDirectory: jest.fn((dir: string) => dir),
}));
describe('configs', () => {
let logSpy: LogSpy;
@@ -53,49 +55,53 @@ describe('configs', () => {
};
describe('when no path is provided', () => {
it('should generate name and directory when base name is available', async () => {
it('should prompt for project name with default value', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
mockUserInput({ name: 'expo-project' });
const result = await generateProjectConfigAsync(jester, undefined, mockOptions);
const result = await generateProjectConfigAsync(undefined, mockOptions);
expect(result).toEqual({
projectName: 'new-expo-project',
projectDirectory: expect.stringContaining('/new-expo-project'),
projectName: 'expo-project',
projectDirectory: expect.stringContaining('/expo-project'),
});
logSpy.expectLogToContain('Using project name: new-expo-project');
expectPromptToHaveMessage('What would you like to name your project?');
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
initial: 'expo-project',
})
);
logSpy.expectLogToContain('Using project name: expo-project');
});
it('should use username-date when base name exists', async () => {
(fs.pathExists as jest.Mock)
.mockResolvedValueOnce(true) // base name exists
.mockResolvedValueOnce(false); // username-date doesn't exist
it('should prompt and use custom name entered by user', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
mockUserInput({ name: 'user-custom-name' });
const result = await generateProjectConfigAsync(jester, undefined, mockOptions);
const result = await generateProjectConfigAsync(undefined, mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^new-expo-project-jester-\d{4}-\d{2}-\d{2}$/),
projectDirectory: expect.stringContaining(result.projectName),
projectName: 'user-custom-name',
projectDirectory: expect.stringContaining('/user-custom-name'),
});
logSpy.expectLogToContain('Using alternate project name:');
expectPromptToHaveMessage('What would you like to name your project?');
logSpy.expectLogToContain('Using project name: user-custom-name');
});
it('should use username-date-shortID when base name and username-date exist', async () => {
(fs.pathExists as jest.Mock)
.mockResolvedValueOnce(true) // base name exists
.mockResolvedValueOnce(true); // username-date exists
it('should use shortid when prompted name exists', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true); // base name exists
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
mockUserInput({ name: 'existing-project' });
const result = await generateProjectConfigAsync(jester, undefined, mockOptions);
const result = await generateProjectConfigAsync(undefined, mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(
/^new-expo-project-jester-\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_-]{6}$/
),
projectName: expect.stringMatching(/^existing-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining(result.projectName),
});
logSpy.expectLogToContain('Using alternate project name:');
logSpy.expectLogToContain('Using project name:');
});
});
@@ -104,12 +110,13 @@ describe('configs', () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const absolutePath = '/absolute/path/to/my-project';
const result = await generateProjectConfigAsync(jester, absolutePath, mockOptions);
const result = await generateProjectConfigAsync(absolutePath, mockOptions);
expect(result).toEqual({
projectName: 'my-project',
projectDirectory: absolutePath,
});
expect(promptAsync).not.toHaveBeenCalled();
logSpy.expectLogToContain('Using project directory:');
});
@@ -117,12 +124,13 @@ describe('configs', () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const relativePath = 'some/relative/my-app';
const result = await generateProjectConfigAsync(jester, relativePath, mockOptions);
const result = await generateProjectConfigAsync(relativePath, mockOptions);
expect(result).toEqual({
projectName: 'my-app',
projectDirectory: expect.stringContaining('some/relative/my-app'),
});
expect(promptAsync).not.toHaveBeenCalled();
logSpy.expectLogToContain('Using project directory:');
});
});
@@ -150,10 +158,6 @@ describe('configs', () => {
const result = getAccountChoices(jester);
expect(result).toEqual([
{
title: 'jester (personal account)',
value: { name: 'jester' },
},
{
title: 'other',
value: { name: 'other' },
@@ -161,19 +165,10 @@ describe('configs', () => {
description:
'You do not have the required permissions to create projects on this account.',
},
]);
});
});
describe('generateProjectNameVariations', () => {
it('should generate name variations', () => {
const baseName = 'my-project';
const result = generateProjectNameVariations(jester, baseName);
expect(result).toEqual([
'my-project',
expect.stringMatching(/^my-project-jester-\d{4}-\d{2}-\d{2}$/),
expect.stringMatching(/^my-project-jester-\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_-]{6}$/),
{
title: 'jester',
value: { name: 'jester' },
},
]);
});
});
@@ -189,12 +184,7 @@ describe('configs', () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await findAvailableProjectNameAsync(
jester,
'test-project',
'/base/path',
mockOptions
);
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: 'test-project',
@@ -208,63 +198,48 @@ describe('configs', () => {
);
});
it('should try alternatives when first name exists locally', async () => {
(fs.pathExists as jest.Mock)
.mockResolvedValueOnce(true) // First exists
.mockResolvedValueOnce(false); // Second available
it('should use shortid variant when base name exists locally', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true); // Base name exists locally
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await findAvailableProjectNameAsync(
jester,
'test-project',
'/base/path',
mockOptions
);
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-jester-\d{4}-\d{2}-\d{2}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-jester-'),
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-'),
});
expect(fs.pathExists).toHaveBeenCalledTimes(2);
expect(fs.pathExists).toHaveBeenCalledTimes(1);
expect(fs.pathExists).toHaveBeenCalledWith('/base/path/test-project');
});
it('should skip name if local is available but remote exists', async () => {
it('should use shortid variant when base name exists remotely', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
.mockResolvedValueOnce('project-id') // First name exists remotely
.mockResolvedValueOnce(null); // Second available remotely
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id'); // Base name exists remotely
const result = await findAvailableProjectNameAsync(
jester,
'test-project',
'/base/path',
mockOptions
);
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-jester-\d{4}-\d{2}-\d{2}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-jester-'),
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-'),
});
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(2);
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(1);
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester',
'test-project'
);
});
it('should throw error when all variations taken', async () => {
it('should use shortid variant when base name is taken both locally and remotely', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id');
await expect(
findAvailableProjectNameAsync(jester, 'test-project', '/base/path', mockOptions)
).rejects.toThrow('Unable to find a unique project name');
});
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
it('should log when using alternative name', async () => {
(fs.pathExists as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
await findAvailableProjectNameAsync(jester, 'test-project', '/base/path', mockOptions);
logSpy.expectLogToContain('Using alternate project name:');
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-'),
});
});
});
});
@@ -1,7 +1,6 @@
import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { LogSpy } from './testUtils';
import { getEASUpdateURL } from '../../../api';
import { AppFragment } from '../../../graphql/generated';
import { easCliVersion } from '../../../utils/easCli';
@@ -18,12 +17,6 @@ jest.mock('../../../api');
jest.mock('fs-extra');
describe('projectFiles', () => {
let logSpy: LogSpy;
beforeAll(() => {
logSpy = new LogSpy('withTick');
});
beforeEach(() => {
jest.clearAllMocks();
@@ -33,10 +26,7 @@ describe('projectFiles', () => {
jest.mocked(fs.copy).mockImplementation(() => Promise.resolve());
jest.mocked(fs.remove).mockImplementation(() => Promise.resolve());
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
});
afterAll(() => {
logSpy.restore();
jest.mocked(fs.symlink).mockImplementation(() => Promise.resolve());
});
describe('cleanAndPrefix', () => {
@@ -128,8 +118,6 @@ describe('projectFiles', () => {
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/app.json`, expectedConfig, {
spaces: 2,
});
logSpy.expectLogToContain('Generated app.json');
});
it('should handle invalid characters in the bundle identifier', async () => {
@@ -247,8 +235,6 @@ describe('projectFiles', () => {
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/eas.json`, expectedEasConfig, {
spaces: 2,
});
logSpy.expectLogToContain('Generated eas.json');
});
});
@@ -265,7 +251,7 @@ describe('projectFiles', () => {
name: 'test-app',
version: '1.0.0',
scripts: {
preview: 'npx eas-cli@latest workflow:run publish-preview-update.yml',
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
deploy: 'npx eas-cli@latest workflow:run deploy-to-production.yml',
},
@@ -274,8 +260,6 @@ describe('projectFiles', () => {
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/package.json`, expectedPackageJson, {
spaces: 2,
});
logSpy.expectLogToContain('Updated package.json with scripts');
});
});
@@ -286,12 +270,14 @@ describe('projectFiles', () => {
await copyProjectTemplatesAsync(projectDir);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('templates/.eas/workflows'),
expect.stringContaining('.eas/workflows'),
{ errorOnExist: false, overwrite: true }
expect.stringContaining('templates'),
projectDir,
expect.objectContaining({
errorOnExist: false,
overwrite: true,
filter: expect.any(Function),
})
);
logSpy.expectLogToContain('Created EAS workflow files');
});
});
@@ -342,8 +328,6 @@ Install the dependencies first.`;
projectReadmePath,
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
);
logSpy.expectLogToContain('Updated README.md with EAS configuration details');
});
it('should replace npm run with package manager specific commands', async () => {
@@ -0,0 +1,85 @@
import path from 'path';
import { printDirectory } from '../utils';
describe('utils', () => {
describe('printDirectory', () => {
let cwdSpy: jest.SpyInstance;
beforeEach(() => {
// Mock process.cwd() to return a predictable value
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/Users/test/projects');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should return "." for the current directory', () => {
const result = printDirectory('/Users/test/projects');
expect(result).toBe('.');
});
it('should return relative path with "./" prefix for subdirectory', () => {
const result = printDirectory('/Users/test/projects/my-app');
expect(result).toBe('./my-app');
});
it('should return relative path with "./" prefix for nested subdirectory', () => {
const result = printDirectory('/Users/test/projects/nested/my-app');
expect(result).toBe('./nested/my-app');
});
it('should return absolute path for directory outside cwd', () => {
const result = printDirectory('/Users/other/projects/my-app');
expect(result).toBe('/Users/other/projects/my-app');
});
it('should return absolute path for parent directory', () => {
const result = printDirectory('/Users/test');
expect(result).toBe('/Users/test');
});
it('should handle relative paths and convert them correctly', () => {
// Relative path resolves to absolute first, then converts back to relative
const result = printDirectory('my-app');
expect(result).toBe('./my-app');
});
it('should handle relative paths with "./" already', () => {
const result = printDirectory('./my-app');
expect(result).toBe('./my-app');
});
it('should handle relative paths with "../" (goes outside cwd)', () => {
const result = printDirectory('../other-project');
// This goes outside cwd, so should return absolute path
expect(result).toBe('/Users/test/other-project');
});
it('should handle complex relative paths', () => {
const result = printDirectory('./nested/deep/project');
expect(result).toBe('./nested/deep/project');
});
it('should normalize paths correctly', () => {
const result = printDirectory('/Users/test/projects/./my-app/../my-app');
expect(result).toBe('./my-app');
});
it('should handle paths in real cwd', () => {
// Restore mock to test with real cwd
cwdSpy.mockRestore();
const realCwd = process.cwd();
// Test with current directory
const result1 = printDirectory(realCwd);
expect(result1).toBe('.');
// Test with subdirectory
const subDir = path.join(realCwd, 'test-dir');
const result2 = printDirectory(subDir);
expect(result2).toBe('./test-dir');
});
});
});
@@ -1,17 +1,20 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import Log from '../../log';
import { printDirectory } from './utils';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
Log.log(`📂 Cloning the project to ${targetProjectDir}`);
Log.newLine();
const spinner = ora(
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
).start();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
@@ -25,8 +28,11 @@ export async function cloneTemplateAsync(targetProjectDir: string): Promise<stri
githubRepositoryName,
targetProjectDir,
cloneMethod,
showOutput: false,
});
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
return finalTargetProjectDirectory;
}
@@ -34,6 +40,7 @@ export async function installProjectDependenciesAsync(
projectDir: string,
packageManager: PackageManager
): Promise<void> {
const spinner = ora(`${chalk.bold('Installing project dependencies')}`).start();
await installDependenciesAsync({
outputLevel: 'none',
projectDir,
@@ -42,16 +49,20 @@ export async function installProjectDependenciesAsync(
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
spinner.text = `Installing ${chalk.bold(dependency)}`;
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
showSpinner: false,
});
}
spinner.succeed(`Installed project dependencies`);
}
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
const spinner = ora(`${chalk.bold('Initializing Git repository')}`).start();
await fs.remove(path.join(projectDir, '.git'));
const commands = [['init'], ['add', '.'], ['commit', '-m', 'Initial commit']];
@@ -62,7 +73,8 @@ export async function initializeGitRepositoryAsync(projectDir: string): Promise<
command: 'git',
args,
showOutput: false,
showSpinner: false,
});
Log.log();
}
spinner.succeed(`Initialized Git repository`);
}
@@ -2,11 +2,12 @@ import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import path from 'path';
import { printDirectory } from './utils';
import { Role } from '../../graphql/generated';
import Log from '../../log';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, promptAsync } from '../../prompts';
import { Actor, getActorUsername } from '../../user/User';
import { Actor } from '../../user/User';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
function validateProjectPath(resolvedPath: string): void {
@@ -31,7 +32,6 @@ function validateProjectPath(resolvedPath: string): void {
}
export async function generateProjectConfigAsync(
actor: Actor,
pathArg: string | undefined,
options: {
graphqlClient: ExpoGraphqlClient;
@@ -41,7 +41,7 @@ export async function generateProjectConfigAsync(
projectName: string;
projectDirectory: string;
}> {
let baseName = 'new-expo-project';
let baseName = 'expo-project';
let parentDirectory = process.cwd();
if (pathArg) {
@@ -49,17 +49,26 @@ export async function generateProjectConfigAsync(
validateProjectPath(resolvedPath);
baseName = path.basename(resolvedPath);
parentDirectory = path.dirname(resolvedPath);
} else {
baseName = (
await promptAsync({
type: 'text',
name: 'name',
message: 'What would you like to name your project?',
initial: 'expo-project',
})
).name;
}
// Find an available name checking both local filesystem and remote server
const { projectName, projectDirectory } = await findAvailableProjectNameAsync(
actor,
baseName,
parentDirectory,
options
);
Log.withInfo(`Using project directory: ${projectDirectory}`);
Log.withInfo(`Using project name: ${projectName}`);
Log.withInfo(`Using project directory: ${printDirectory(projectDirectory)}`);
return {
projectName,
@@ -108,15 +117,18 @@ export async function promptForProjectAccountAsync(actor: Actor): Promise<string
export function getAccountChoices(actor: Actor, permissionsMap?: Map<string, boolean>): Choice[] {
const permissions = permissionsMap ?? getAccountPermissionsMap(actor);
const sortedAccounts = [...actor.accounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
const sortedAccounts = [...actor.accounts].sort((a, _b) => (a.ownerUserActor ? 1 : -1));
return sortedAccounts.map(account => {
const isPersonalAccount = actor.__typename === 'User' && account.name === actor.username;
const isPersonalAccount = !!account.ownerUserActor && account.ownerUserActor.id === actor.id;
const isTeamAccount = !!account.ownerUserActor && account.ownerUserActor.id !== actor.id;
const accountDisplayName = isPersonalAccount
? `${account.name} (personal account)`
: account.name;
? `${account.name} (Limited - Personal Account)`
: isTeamAccount
? `${account.name} (Limited - Team Account)`
: account.name;
const disabled = !permissions.get(account.name);
return {
@@ -130,17 +142,6 @@ export function getAccountChoices(actor: Actor, permissionsMap?: Map<string, boo
});
}
export function generateProjectNameVariations(actor: Actor, baseName: string): string[] {
const username = getActorUsername(actor);
const date = new Date().toISOString().split('T')[0];
return [
baseName,
`${baseName}-${username}-${date}`,
`${baseName}-${username}-${date}-${nanoid(6)}`,
];
}
async function verifyProjectDoesNotExistAsync(
graphqlClient: ExpoGraphqlClient,
accountName: string,
@@ -167,7 +168,6 @@ async function verifyProjectDoesNotExistAsync(
* Remote server (project already exists on Expo)
*/
export async function findAvailableProjectNameAsync(
actor: Actor,
baseName: string,
parentDirectory: string,
{
@@ -178,37 +178,24 @@ export async function findAvailableProjectNameAsync(
projectAccount: string;
}
): Promise<{ projectName: string; projectDirectory: string }> {
const nameVariations = generateProjectNameVariations(actor, baseName);
let projectName = baseName;
let projectDirectory = path.join(parentDirectory, projectName);
for (let i = 0; i < nameVariations.length; i++) {
const nameVariation = nameVariations[i];
const proposedDirectory = path.join(parentDirectory, nameVariation);
const usingVariant = i !== 0;
const localExists = await fs.pathExists(projectDirectory);
const localExists = await fs.pathExists(proposedDirectory);
if (localExists) {
continue;
}
const remoteAvailable = await verifyProjectDoesNotExistAsync(
graphqlClient,
projectAccount,
projectName
);
const remoteAvailable = await verifyProjectDoesNotExistAsync(
graphqlClient,
projectAccount,
nameVariation,
{ silent: usingVariant }
);
if (!remoteAvailable) {
continue;
}
Log.withInfo(`Using ${usingVariant ? 'alternate ' : ''}project name: ${nameVariation}`);
return {
projectName: nameVariation,
projectDirectory: proposedDirectory,
};
if (localExists || !remoteAvailable) {
projectName = `${baseName}-${nanoid(6)}`;
projectDirectory = path.join(parentDirectory, projectName);
}
throw new Error(
`Unable to find a unique project name for "${baseName}". All generated variations already exist.`
);
return {
projectName,
projectDirectory,
};
}
@@ -1,13 +1,11 @@
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../api';
import { AppFragment } from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import { PackageManager } from '../../onboarding/installDependencies';
import { easCliVersion } from '../../utils/easCli';
@@ -63,12 +61,6 @@ export async function generateAppConfigAsync(projectDir: string, app: AppFragmen
const appJsonPath = path.join(projectDir, 'app.json');
await fs.writeJson(appJsonPath, { expo: mergedConfig }, { spaces: 2 });
Log.withTick(
`Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.log();
}
export async function generateEasConfigAsync(projectDir: string): Promise<void> {
@@ -116,12 +108,6 @@ export async function generateEasConfigAsync(projectDir: string): Promise<void>
const easJsonPath = path.join(projectDir, 'eas.json');
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
Log.withTick(
`Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
Log.log();
}
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
@@ -131,27 +117,25 @@ export async function updatePackageJsonAsync(projectDir: string): Promise<void>
if (!packageJson.scripts) {
packageJson.scripts = {};
}
packageJson.scripts.preview = 'npx eas-cli@latest workflow:run publish-preview-update.yml';
packageJson.scripts.draft = 'npx eas-cli@latest workflow:run create-draft.yml';
packageJson.scripts['development-builds'] =
'npx eas-cli@latest workflow:run create-development-builds.yml';
packageJson.scripts.deploy = 'npx eas-cli@latest workflow:run deploy-to-production.yml';
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
Log.withTick('Updated package.json with scripts');
Log.log();
}
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
const templatesSourceDir = path.join(__dirname, 'templates', '.eas', 'workflows');
const easWorkflowsTargetDir = path.join(projectDir, '.eas', 'workflows');
const templatesSourceDir = path.join(__dirname, 'templates');
await fs.copy(templatesSourceDir, easWorkflowsTargetDir, {
// Copy everything from templates to projectDir, skipping readme-additions.md
await fs.copy(templatesSourceDir, projectDir, {
overwrite: true,
errorOnExist: false,
filter: (src: string) => {
return !src.endsWith('readme-additions.md');
},
});
Log.withTick('Created EAS workflow files');
Log.log();
}
export async function updateReadmeAsync(
@@ -189,7 +173,4 @@ export async function updateReadmeAsync(
mergedReadme = mergedReadme.replaceAll('npm run', `${packageManager} run`);
await fs.writeFile(projectReadmePath, mergedReadme);
Log.withTick('Updated README.md with EAS configuration details');
Log.log();
}
@@ -1,4 +1,4 @@
name: Publish preview update
name: Create draft
on:
push:
@@ -10,3 +10,6 @@ jobs:
type: update
params:
branch: ${{ github.ref_name || 'test' }}
deploy_website:
name: Deploy website
type: deploy
@@ -0,0 +1,163 @@
# AGENTS.md
## Project Overview
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
## Documentation Resources
When working on this project, **always consult the official Expo documentation** available at:
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
- Expo APIs and best practices
- Expo Router navigation patterns
- EAS Build, Submit, and Update workflows
- Expo SDK modules and their usage
- Development and deployment processes
## Project Structure
```
/
├── app/ # Expo Router file-based routing
│ ├── (tabs)/ # Tab-based navigation screens
│ │ ├── index.tsx # Home screen
│ │ ├── explore.tsx # Explore screen
│ │ └── _layout.tsx # Tabs layout
│ ├── _layout.tsx # Root layout with theme provider
│ └── modal.tsx # Modal screen example
├── components/ # Reusable React components
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
│ └── ... # Feature components (themed, haptic, parallax)
├── constants/ # App-wide constants (theme, colors)
├── hooks/ # Custom React hooks (color scheme, theme)
├── assets/ # Static assets (images, fonts)
├── scripts/ # Utility scripts (reset-project)
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
├── app.json # Expo configuration
├── eas.json # EAS Build/Submit configuration
└── package.json # Dependencies and scripts
```
## Essential Commands
### Development
```bash
npx expo start # Start dev server
npx expo start --clear # Clear cache and start dev server
npx expo install <package> # Install packages with compatible versions
npx expo install --check # Check which installed packages need to be updated
npx expo install --fix # Automatically update any invalid package versions
npm run development-builds # Create development builds (workflow)
npm run reset-project # Reset to blank template
```
### Building & Testing
```bash
npx expo doctor # Check project health and dependencies
npx expo lint # Run ESLint
npm run draft # Publish preview update and website (workflow)
```
### Production
```bash
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
npm run deploy # Deploy to production (workflow)
```
## Development Guidelines
### Code Style & Standards
- **TypeScript First**: Use TypeScript for all new code with strict type checking
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
- **React 19 Patterns**: Follow modern React patterns including:
- Function components with hooks
- Enable React Compiler
- Proper dependency arrays in useEffect
- Memoization when appropriate (useMemo, useCallback)
- Error boundaries for better error handling
### Navigation & Routing
- Use **Expo Router** for all navigation
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
- Docs: https://docs.expo.dev/router/introduction/
### Recommended Libraries
- **Navigation**: `expo-router` for navigation
- **Images**: `expo-image` for optimized image handling and caching
- **Animations**: `react-native-reanimated` for performant animations on native thread
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
## Debugging & Development Tools
### DevTools Integration
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
- **Network Inspection**: Monitor API calls and network requests in DevTools
- **Element Inspector**: Debug component hierarchy and styles
- **Performance Profiler**: Identify performance bottlenecks
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
### Testing & Quality Assurance
#### Automated Testing with MCP Tools
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
- **Component Testing**: Add `testID` props to components for automation
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
## EAS Workflows CI/CD
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
When working with EAS Workflows, **always refer to**:
- https://docs.expo.dev/eas/workflows/ for workflow examples
- The `.eas/workflows/` directory for existing workflow configurations
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
### Build Profiles (eas.json)
- **development**: Development builds with dev client
- **development-simulator**: Development builds for iOS simulator
- **preview**: Internal distribution preview builds
- **production**: Production builds with auto-increment
## Troubleshooting
### Expo Go Errors & Development Builds
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
## AI Agent Instructions
When working on this project:
1. **Always start by consulting the appropriate documentation**:
- For general Expo questions: https://docs.expo.dev/llms-full.txt
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
2. **Understand before implementing**: Read the relevant docs section before writing code
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
@@ -0,0 +1,9 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Note**: This project uses `AGENTS.md` files for detailed guidance.
## Primary Reference
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
@@ -21,7 +21,7 @@ This project is configured to use [EAS Workflows](https://docs.expo.dev/eas/work
### Previews
Run `npm run preview` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
Run `npm run draft` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
### Development Builds
@@ -0,0 +1,20 @@
import path from 'path';
/**
* Formats a directory path for display.
* If the directory is within the current working directory, returns a relative path.
* Otherwise, returns the absolute path.
*/
export function printDirectory(directory: string): string {
const cwd = process.cwd();
const absoluteDir = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory);
const relativePath = path.relative(cwd, absoluteDir);
// If the relative path doesn't start with '..' it means it's within or at the cwd
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
return relativePath !== '' ? `./${relativePath}` : '.';
}
return absoluteDir;
}
@@ -0,0 +1,33 @@
import { isSlugForbidden, isValidExperienceName, isValidSlugStrict } from './experienceParser';
/**
* Perform the same validation as the server, to avoid GraphQL errors.
*/
export function validateFullNameAndSlug(fullName: string, slug: string): void {
if (!isValidExperienceName(fullName)) {
throw new Error(`Invalid project name: ${fullName}`);
}
if (!isValidSlugStrict(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
if (isSlugForbidden(slug)) {
throw new Error(
`Cannot create an app named "${slug}" because it would conflict with a dependency of the same name.`
);
}
if (fullName.length > 255) {
throw new Error(`Project full name (${fullName}) can not be longer than 255 characters.`);
}
}
/**
* Attempt to derive a valid slug name from the one passed in
*/
export function validSlugName(slug: string): string {
const chars = slug.split('');
const validChars = chars.filter(char => isValidSlugStrict(char));
return validChars.join('');
}
@@ -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,
};
}
@@ -1,3 +1,4 @@
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
@@ -11,14 +12,18 @@ import {
import {
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdQuery,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { sleepAsync } from '../../utils/promise';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
@@ -218,10 +223,12 @@ export async function infoForActiveWorkflowRunAsync(
export async function infoForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
maxLogLines: number = -1 // -1 means no limit
): Promise<string> {
const statusLines = [];
const statusValues = [];
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
for (const job of workflowRun.jobs) {
if (job.status !== WorkflowJobStatus.Failure) {
continue;
@@ -233,7 +240,7 @@ export async function infoForFailedWorkflowRunAsync(
if (steps.length > 0) {
const failedStep = steps.find(step => step.status === 'fail');
if (failedStep) {
const logs = failedStep.logLines?.map(line => line.msg) ?? [];
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
statusValues.push({ label: ' Failed step', value: failedStep.name });
statusValues.push({
label: ' Logs for failed step',
@@ -283,3 +290,84 @@ export async function maybeReadStdinAsync(): Promise<string | null> {
});
});
}
export async function showWorkflowStatusAsync(
graphqlClient: ExpoGraphqlClient,
{
workflowRunId,
spinnerUsesStdErr,
waitForCompletion = true,
}: { workflowRunId: string; spinnerUsesStdErr: boolean; waitForCompletion?: boolean }
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const spinner = ora({
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
text: '',
}).start();
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
break;
}
case WorkflowRunStatus.ActionRequired:
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
break;
case WorkflowRunStatus.Canceled:
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
spinner.fail(failedInfo);
return workflowRun;
}
case WorkflowRunStatus.Success:
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
spinner.text = '';
spinner.succeed('');
return workflowRun;
}
if (!waitForCompletion) {
if (spinner.isSpinning) {
spinner.stopAndPersist();
}
return workflowRun;
}
} catch {
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(workflowRunExitCodes.WAIT_ABORTED);
}
}
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
}
export const workflowRunExitCodes = {
WORKFLOW_FAILED: 11,
WORKFLOW_CANCELED: 12,
WAIT_ABORTED: 13,
};
@@ -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;
}
}
}
+7 -36
View File
@@ -1,23 +1,19 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import { scheduleBranchDeletionAsync } from '../../branch/delete';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
DeleteUpdateBranchMutationVariables,
DeleteUpdateBranchResult,
GetBranchInfoQuery,
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
async function getBranchInfoAsync(
graphqlClient: ExpoGraphqlClient,
@@ -50,31 +46,6 @@ async function getBranchInfoAsync(
return data;
}
async function deleteBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ branchId }: DeleteUpdateBranchMutationVariables
): Promise<DeleteUpdateBranchResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
gql`
mutation DeleteUpdateBranch($branchId: ID!) {
updateBranch {
deleteUpdateBranch(branchId: $branchId) {
id
}
}
}
`,
{
branchId,
}
)
.toPromise()
);
return data.updateBranch.deleteUpdateBranch;
}
export default class BranchDelete extends EasCommand {
static override description = 'delete a branch';
@@ -146,12 +117,12 @@ export default class BranchDelete extends EasCommand {
}
}
const deletionResult = await deleteBranchOnAppAsync(graphqlClient, {
branchId,
});
const receipt = await scheduleBranchDeletionAsync(graphqlClient, { branchId });
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
Log.debug('Deletion result', { successfulReceipt });
if (jsonFlag) {
printJsonOnlyOutput(deletionResult);
printJsonOnlyOutput({ id: branchId });
} else {
Log.withTick(
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
@@ -1,19 +1,12 @@
import gql from 'graphql-tag';
import { scheduleChannelDeletionAsync } from '../../channel/delete';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateChannelMutation,
DeleteUpdateChannelMutationVariables,
DeleteUpdateChannelResult,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
@@ -77,7 +70,7 @@ export default class ChannelDelete extends EasCommand {
if (!nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
`You are about to permanently delete channel: "${channelName}". Deleting this channel will also permanently remove all associated builds from your account. \nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
@@ -87,39 +80,14 @@ export default class ChannelDelete extends EasCommand {
}
}
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
channelId,
});
const receipt = await scheduleChannelDeletionAsync(graphqlClient, { channelId });
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
Log.debug('Deletion result', { successfulReceipt });
if (jsonFlag) {
printJsonOnlyOutput(deletionResult);
printJsonOnlyOutput({ id: channelId });
} else {
Log.withTick(`️Deleted channel "${channelName}".`);
}
}
}
async function deleteChannelOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: DeleteUpdateChannelMutationVariables
): Promise<DeleteUpdateChannelResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateChannelMutation, DeleteUpdateChannelMutationVariables>(
gql`
mutation DeleteUpdateChannel($channelId: ID!) {
updateChannel {
deleteUpdateChannel(channelId: $channelId) {
id
}
}
}
`,
{
channelId,
}
)
.toPromise()
);
return data.updateChannel.deleteUpdateChannel;
}
@@ -44,8 +44,6 @@ export default class WorkerAliasDelete extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -73,8 +73,6 @@ export default class WorkerAlias extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -45,8 +45,6 @@ export default class WorkerDelete extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -120,8 +120,6 @@ export default class WorkerDeploy extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -1,5 +1,6 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
@@ -26,6 +27,7 @@ jest.mock('../../../prompts');
jest.mock('../../../utils/prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
jest.mock('fs-extra');
describe(EnvUpdate, () => {
const projectId = 'test-project-id';
@@ -251,4 +253,251 @@ describe(EnvUpdate, () => {
filterNames: ['TEST_VAR_1'],
});
});
it('processes file type variable in non-interactive mode with --type file and --value', async () => {
const testFilePath = '/path/to/test-file.json';
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
const testFileName = 'test-file.json';
const mockVariable = {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.String,
};
const mockUpdatedVariable = {
...mockVariable,
type: EnvironmentSecretType.FileBase64,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
// Mock fs.pathExists to return true
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
// Mock fs.readFile to return base64 encoded content
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--type',
'file',
'--value',
testFilePath,
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
// Verify file was checked for existence
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
// Verify file was read as base64
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
// Verify update was called with FileBase64 type and base64-encoded value
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
value: testFileBase64,
environments: [DefaultEnvironment.Production],
type: EnvironmentSecretType.FileBase64,
fileName: testFileName,
});
});
it('throws error when updating FILE type variable with value that is not a valid file path', async () => {
const plainStringValue = 'some-new-value';
const mockVariable = {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.FileBase64,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
// Mock fs.pathExists to return false (file doesn't exist)
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--value',
plainStringValue,
'--environment',
'production',
// Note: --type is NOT specified
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
// Should throw error when FILE type variable is updated with non-file value
await expect(command.runAsync()).rejects.toThrow(
`Variable "TEST_VARIABLE" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
);
});
it('throws error when --type file is explicitly specified but file does not exist', async () => {
const nonExistentFilePath = '/path/to/nonexistent-file.json';
const mockVariable = {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.String,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
// Mock fs.pathExists to return false (file doesn't exist)
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--type',
'file',
'--value',
nonExistentFilePath,
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
// Should throw error when --type file is explicitly specified
await expect(command.runAsync()).rejects.toThrow(
`File "${nonExistentFilePath}" does not exist`
);
});
it('preserves existing STRING type when updating value without --type flag', async () => {
const stringValue = 'my-best-file.json';
const mockVariable = {
id: variableId,
name: 'MY_VAR',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.String,
value: 'old-value',
visibility: EnvironmentVariableVisibility.Public,
};
const mockUpdatedVariable = {
...mockVariable,
value: stringValue,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
const command = new EnvUpdate(
[
'--variable-name',
'MY_VAR',
'--variable-environment',
'development',
'--non-interactive',
'--value',
stringValue,
// Note: --type is NOT specified
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
// Verify update was called with preserved STRING type
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
value: stringValue,
type: EnvironmentSecretType.String,
});
});
it('preserves existing FILE type when updating value without --type flag', async () => {
const testFilePath = '/path/to/test-file.json';
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
const testFileName = 'test-file.json';
const mockVariable = {
id: variableId,
name: 'MY_FILE_VAR',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.FileBase64,
value: 'old-base64-value',
visibility: EnvironmentVariableVisibility.Public,
};
const mockUpdatedVariable = {
...mockVariable,
value: testFileBase64,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
// Mock fs.pathExists to return true
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
// Mock fs.readFile to return base64 encoded content
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
const command = new EnvUpdate(
[
'--variable-name',
'MY_FILE_VAR',
'--variable-environment',
'development',
'--non-interactive',
'--value',
testFilePath,
// Note: --type is NOT specified
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
// Verify file was checked for existence
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
// Verify file was read as base64
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
// Verify update was called with preserved FILE type
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
value: testFileBase64,
type: EnvironmentSecretType.FileBase64,
fileName: testFileName,
});
});
});
+1 -1
View File
@@ -107,7 +107,7 @@ export default class EnvGet extends EasCommand {
let variable;
if (variables.length > 1) {
const variableInEnvironment = variables.find(v => v.environments?.includes(environment!));
const variableInEnvironment = variables.find(v => v.environments?.includes(environment));
if (!variableInEnvironment) {
throw new Error(
`Variable with name "${name}" not found in environment "${environment.toLocaleLowerCase()}"`
+20 -12
View File
@@ -299,18 +299,6 @@ export default class EnvUpdate extends EasCommand {
}
}
let environmentFilePath: string | undefined;
if ((newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64 && value) {
environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
throw new Error(`File "${value}" does not exist`);
}
fileName = path.basename(environmentFilePath);
}
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
if (!environments || environments.length === 0) {
environments = await promptVariableEnvironmentAsync({
nonInteractive,
@@ -342,6 +330,26 @@ export default class EnvUpdate extends EasCommand {
}
}
// If value is provided but type is not explicitly set, preserve the existing type
if (value && !newType) {
newType = selectedVariable.type;
}
if (newType === EnvironmentSecretType.FileBase64 && value) {
const environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
if (type === 'file') {
throw new Error(`File "${value}" does not exist`);
} else {
throw new Error(
`Variable "${selectedVariable.name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
);
}
}
fileName = path.basename(environmentFilePath);
value = await fs.readFile(environmentFilePath, 'base64');
}
if (visibility) {
newVisibility = parseVisibility(visibility);
}
@@ -51,7 +51,15 @@ jest.mock('../../../project/expoConfig', () => ({
jest.mock('../../../prompts', () => ({
...jest.requireActual('../../../prompts'),
promptAsync: jest.fn().mockResolvedValue({ account: { name: 'jester' } }),
promptAsync: jest.fn().mockImplementation(async (options: any) => {
if (options.name === 'account') {
return { account: { name: 'jester' } };
}
if (options.name === 'name') {
return { name: options.initial || 'expo-project' };
}
return {};
}),
}));
describe(New.name, () => {
@@ -62,8 +70,8 @@ describe(New.name, () => {
expect(result).toEqual({
projectAccount: 'jester',
projectDirectory: expect.stringContaining('/new-expo-project'),
projectName: 'new-expo-project',
projectDirectory: expect.stringContaining('/expo-project'),
projectName: 'expo-project',
});
});
@@ -8,6 +8,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { validSlugName, validateFullNameAndSlug } from '../../commandUtils/projectNameValidation';
import { Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -276,8 +277,9 @@ export default class ProjectInit extends EasCommand {
throw new Error('No account selected for project. Canceling.');
}
const projectName = exp.slug;
const projectName = validSlugName(exp.slug); // This filters out invalid characters
const projectFullName = `@${accountName}/${projectName}`;
validateFullNameAndSlug(projectFullName, projectName);
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
+22 -13
View File
@@ -21,6 +21,7 @@ import {
updatePackageJsonAsync,
updateReadmeAsync,
} from '../../commandUtils/new/projectFiles';
import { printDirectory } from '../../commandUtils/new/utils';
import { AppFragment } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -40,7 +41,7 @@ export async function generateConfigsAsync(
projectAccount: string;
}> {
const projectAccount = await promptForProjectAccountAsync(actor);
const { projectName, projectDirectory } = await generateProjectConfigAsync(actor, args.path, {
const { projectName, projectDirectory } = await generateProjectConfigAsync(args.path, {
graphqlClient,
projectAccount,
});
@@ -92,7 +93,7 @@ export async function createProjectAsync({
},
{ skipSDKVersionRequirement: true }
);
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
Log.withInfo(`Project successfully linked (ID: ${chalk.bold(projectId)})`);
return projectId;
}
@@ -102,6 +103,7 @@ export async function generateProjectFilesAsync(
app: AppFragment,
packageManager: PackageManager
): Promise<void> {
const spinner = ora(`Generating project files`).start();
await generateAppConfigAsync(projectDir, app);
await generateEasConfigAsync(projectDir);
@@ -111,6 +113,17 @@ export async function generateProjectFilesAsync(
await copyProjectTemplatesAsync(projectDir);
await updateReadmeAsync(projectDir, packageManager);
spinner.succeed(`Generated project files`);
Log.withInfo(
`Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.withInfo(
`Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
}
export default class New extends EasCommand {
@@ -136,8 +149,6 @@ export default class New extends EasCommand {
}),
};
static override hidden = true;
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
@@ -155,11 +166,7 @@ export default class New extends EasCommand {
);
}
Log.warn(
'This command is not yet implemented. It will create a new project, but it may not be fully configured.'
);
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
Log.newLine();
const {
projectName,
@@ -186,19 +193,21 @@ export default class New extends EasCommand {
await initializeGitRepositoryAsync(projectDirectory);
Log.log('🎉 We finished creating your new project.');
Log.newLine();
Log.log('Next steps:');
Log.withInfo(`Run \`cd ${projectDirectory}\` to navigate to your project.`);
Log.withInfo(
`Run \`${packageManager} run preview\` to create a preview build on EAS. ${learnMore(
`Run ${chalk.bold(`cd ${printDirectory(projectDirectory)}`)} to navigate to your project.`
);
Log.withInfo(
`Run ${chalk.bold(`${packageManager} run draft`)} to create a preview on EAS. ${learnMore(
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
)}`
);
Log.withInfo(
`Run \`${packageManager} run start\` to start developing locally. ${learnMore(
`Run ${chalk.bold(`${packageManager} run start`)} to start developing locally. ${learnMore(
'https://docs.expo.dev/get-started/start-developing/'
)}`
);
Log.withInfo(`See the README.md for more information about your project.`);
Log.newLine();
Log.withInfo(`See the ${chalk.bold('README.md')} for more information about your project.`);
}
}
@@ -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);
}
}
@@ -5,7 +5,7 @@ import nullthrows from 'nullthrows';
import path from 'path';
import { instance, mock } from 'ts-mockito';
import UpdatePublish from '..';
import UpdatePublish, { preprocessSourceMapsArg } from '..';
import { ensureBranchExistsAsync } from '../../../branch/queries';
import {
DynamicPrivateProjectConfigContextField,
@@ -328,3 +328,34 @@ function mockTestExport({
return { inputDir: exportDir, platforms, runtimeVersion };
}
describe(preprocessSourceMapsArg, () => {
it('passes through when --source-maps has a value', () => {
const argv = ['--source-maps', 'inline', '--message', 'test'];
expect(preprocessSourceMapsArg(argv)).toEqual(argv);
});
it('inserts "true" when --source-maps has no value', () => {
// At end of args
expect(preprocessSourceMapsArg(['--branch', 'main', '--source-maps'])).toEqual([
'--branch',
'main',
'--source-maps',
'true',
]);
// Followed by another flag
expect(preprocessSourceMapsArg(['--source-maps', '--message', 'test'])).toEqual([
'--source-maps',
'true',
'--message',
'test',
]);
});
});
describe('hidden flags', () => {
it('has --no-bytecode and --source-maps as hidden flags', () => {
expect(UpdatePublish.flags['no-bytecode'].hidden).toBe(true);
expect(UpdatePublish.flags['source-maps'].hidden).toBe(true);
});
});
+1 -1
View File
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
return await PublishMutation.setRolloutPercentageAsync(
graphqlClient,
u.updateId,
rolloutPercentageToSet!
rolloutPercentageToSet
);
})
);
+55 -61
View File
@@ -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';
@@ -31,12 +29,10 @@ import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprin
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import {
RawAsset,
UpdatePublishPlatform,
buildBundlesAsync,
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
filterCollectedAssetsByRequestedPlatforms,
// findCompatibleBuildsAsync,
generateEasMetadataAsync,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
@@ -51,7 +47,12 @@ import {
} from '../../project/publish';
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
import {
UpdatePublishPlatform,
getUpdateJsonInfosForUpdates,
isBundleDiffingEnabled,
prewarmDiffingAsync,
} from '../../update/utils';
import {
checkManifestBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
@@ -64,6 +65,28 @@ import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
/**
* Preprocess argv to handle --source-maps with optional value.
* If --source-maps is followed by another flag (starts with -) or end of args,
* insert 'true' as the default value.
*/
export function preprocessSourceMapsArg(argv: string[]): string[] {
const result: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
result.push(arg);
if (arg === '--source-maps') {
const nextArg = argv[i + 1];
// If no next arg or next arg is a flag, insert 'true' as the value
if (nextArg === undefined || nextArg.startsWith('-')) {
result.push('true');
}
}
}
return result;
}
type RawUpdateFlags = {
auto: boolean;
branch?: string;
@@ -73,6 +96,8 @@ type RawUpdateFlags = {
'input-dir': string;
'skip-bundler': boolean;
'clear-cache': boolean;
'no-bytecode': boolean;
'source-maps'?: string;
'private-key-path'?: string;
'emit-metadata': boolean;
'rollout-percentage'?: number;
@@ -90,6 +115,8 @@ type UpdateFlags = {
inputDir: string;
skipBundler: boolean;
clearCache: boolean;
noBytecode: boolean;
sourceMaps?: string;
privateKeyPath?: string;
emitMetadata: boolean;
rolloutPercentage?: number;
@@ -128,6 +155,16 @@ export default class UpdatePublish extends EasCommand {
description: `Clear the bundler cache before publishing`,
default: false,
}),
'no-bytecode': Flags.boolean({
description: `Skip generating Hermes bytecode (output plain JavaScript instead)`,
default: false,
hidden: true,
}),
'source-maps': Flags.string({
description: `Emit source maps. Options: true (default), inline, false`,
default: 'true',
hidden: true,
}),
'emit-metadata': Flags.boolean({
description: `Emit "eas-update-metadata.json" in the bundle folder with detailed information about the generated updates`,
default: false,
@@ -165,7 +202,9 @@ export default class UpdatePublish extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(UpdatePublish);
// Preprocess argv to handle --source-maps with optional value
const preprocessedArgv = preprocessSourceMapsArg(this.argv);
const { flags: rawFlags } = await this.parse(UpdatePublish, preprocessedArgv);
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
const {
auto: autoFlag,
@@ -175,6 +214,8 @@ export default class UpdatePublish extends EasCommand {
inputDir,
skipBundler,
clearCache,
noBytecode,
sourceMaps,
privateKeyPath,
json: jsonFlag,
nonInteractive,
@@ -260,6 +301,8 @@ export default class UpdatePublish extends EasCommand {
exp,
platformFlag: requestedPlatform,
clearCache,
noBytecode,
sourceMaps,
extraEnv: maybeServerEnv,
});
bundleSpinner.succeed('Exported bundle(s)');
@@ -571,6 +614,10 @@ export default class UpdatePublish extends EasCommand {
throw e;
}
if (isBundleDiffingEnabled(exp)) {
await prewarmDiffingAsync(graphqlClient, projectId, newUpdates);
}
if (!skipBundler && emitMetadata) {
Log.log('Generating eas-update-metadata.json');
await generateEasMetadataAsync(distRoot, getUpdateJsonInfosForUpdates(newUpdates));
@@ -589,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
@@ -666,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;
@@ -747,6 +739,8 @@ export default class UpdatePublish extends EasCommand {
inputDir: flags['input-dir'],
skipBundler,
clearCache: flags['clear-cache'] ? true : !!flags['environment'],
noBytecode: flags['no-bytecode'] ?? false,
sourceMaps: flags['source-maps'],
platform: flags.platform,
privateKeyPath: flags['private-key-path'],
rolloutPercentage: flags['rollout-percentage'],
+36 -1
View File
@@ -8,6 +8,8 @@ import {
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { AppPlatform } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import {
listAndRenderUpdateGroupsOnAppAsync,
listAndRenderUpdateGroupsOnBranchAsync,
@@ -27,6 +29,14 @@ export default class UpdateList extends EasCommand {
exclusive: ['branch'],
default: false,
}),
platform: Flags.enum<RequestedPlatform>({
options: Object.values(RequestedPlatform),
char: 'p',
description: 'Filter updates by platform',
}),
'runtime-version': Flags.string({
description: 'Filter updates by runtime version',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 25, limit: 50 }),
...EasNonInteractiveAndJsonFlags,
@@ -39,7 +49,13 @@ export default class UpdateList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateList);
const { branch: branchFlag, all, json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
branch: branchFlag,
all,
json: jsonFlag,
'non-interactive': nonInteractive,
platform: requestedPlatform,
} = flags;
const {
projectId,
loggedIn: { graphqlClient },
@@ -52,9 +68,16 @@ export default class UpdateList extends EasCommand {
enableJsonOutput();
}
// Build filter object
const filter = {
platform: toAppPlatform(requestedPlatform),
runtimeVersions: flags['runtime-version'] ? [flags['runtime-version']] : undefined,
};
if (all) {
await listAndRenderUpdateGroupsOnAppAsync(graphqlClient, {
projectId,
filter,
paginatedQueryOptions,
});
} else {
@@ -62,6 +85,7 @@ export default class UpdateList extends EasCommand {
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
projectId,
branchName: branchFlag,
filter,
paginatedQueryOptions,
});
} else {
@@ -87,9 +111,20 @@ export default class UpdateList extends EasCommand {
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
projectId,
branchName: selectedBranch.name,
filter,
paginatedQueryOptions,
});
}
}
}
}
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
return undefined;
} else if (requestedPlatform === RequestedPlatform.Android) {
return AppPlatform.Android;
} else {
return AppPlatform.Ios;
}
};
@@ -7,7 +7,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log, { learnMore } from '../../log';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
import { getUpdateMessageForCommandAsync } from '../../project/publish';
import { confirmAsync, promptAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import {
@@ -18,6 +18,7 @@ import {
republishAsync,
} from '../../update/republish';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { UpdatePublishPlatform } from '../../update/utils';
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
@@ -9,7 +9,6 @@ import { StatuspageServiceName } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import {
UpdatePublishPlatform,
defaultPublishPlatforms,
getBranchNameForCommandAsync,
getUpdateMessageForCommandAsync,
@@ -17,6 +16,7 @@ import {
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { selectRuntimeOnBranchAsync } from '../../update/queries';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { UpdatePublishPlatform } from '../../update/utils';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
+13 -84
View File
@@ -44,12 +44,12 @@ import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { boolish } from 'getenv';
import * as path from 'node:path';
import slash from 'slash';
import { getWorkflowRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
maybePromptForMissingInputsAsync,
@@ -59,34 +59,25 @@ import {
} from '../../commandUtils/workflow/inputs';
import {
fileExistsAsync,
infoForActiveWorkflowRunAsync,
infoForFailedWorkflowRunAsync,
maybeReadStdinAsync,
showWorkflowStatusAsync,
workflowRunExitCodes,
} from '../../commandUtils/workflow/utils';
import {
WorkflowProjectSourceType,
WorkflowRevision,
WorkflowRunByIdQuery,
WorkflowRunStatus,
} from '../../graphql/generated';
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import { WorkflowFile } from '../../utils/workflowFile';
const EXIT_CODES = {
WORKFLOW_FAILED: 11,
WORKFLOW_CANCELED: 12,
WAIT_ABORTED: 13,
};
export default class WorkflowRun extends EasCommand {
static override description =
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
@@ -99,8 +90,7 @@ export default class WorkflowRun extends EasCommand {
default: false,
allowNo: true,
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
summary:
'Wait for workflow run to complete. Defaults to false. Cannot be used with the --json flag.',
summary: 'Wait for workflow run to complete. Defaults to false.',
}),
input: Flags.string({
char: 'F',
@@ -129,9 +119,6 @@ export default class WorkflowRun extends EasCommand {
const { flags, args } = await this.parse(WorkflowRun);
if (flags.json) {
if (flags.wait) {
throw new Error('Cannot use --json and --wait flags simultaneously.');
}
enableJsonOutput();
}
@@ -148,7 +135,9 @@ export default class WorkflowRun extends EasCommand {
const {
projectId,
exp: { slug: projectName },
} = await getDynamicPrivateProjectConfigAsync();
} = await getDynamicPrivateProjectConfigAsync({
skipPlugins: true,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let yamlConfig: string;
@@ -370,9 +359,11 @@ export default class WorkflowRun extends EasCommand {
process.exit(0);
}
Log.newLine();
const { status } = await waitForWorkflowRunToEndAsync(graphqlClient, {
const spinnerUsesStdErr = boolish('CI', false) || flags.json;
const { status } = await showWorkflowStatusAsync(graphqlClient, {
workflowRunId,
spinnerUsesStdErr,
});
if (flags.json) {
@@ -387,71 +378,9 @@ export default class WorkflowRun extends EasCommand {
}
if (status === WorkflowRunStatus.Failure) {
process.exit(EXIT_CODES.WORKFLOW_FAILED);
process.exit(workflowRunExitCodes.WORKFLOW_FAILED);
} else if (status === WorkflowRunStatus.Canceled) {
process.exit(EXIT_CODES.WORKFLOW_CANCELED);
process.exit(workflowRunExitCodes.WORKFLOW_CANCELED);
}
}
}
async function waitForWorkflowRunToEndAsync(
graphqlClient: ExpoGraphqlClient,
{ workflowRunId }: { workflowRunId: string }
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const spinner = ora('').start();
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
break;
}
case WorkflowRunStatus.ActionRequired:
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
break;
case WorkflowRunStatus.Canceled:
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun);
spinner.fail(failedInfo);
return workflowRun;
}
case WorkflowRunStatus.Success:
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
spinner.succeed('');
return workflowRun;
}
} catch {
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(EXIT_CODES.WAIT_ABORTED);
}
}
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
}
@@ -0,0 +1,138 @@
/**
* EAS Workflow Status Command
*
* This command shows the status of an existing workflow run.
*
* If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.
*
* If the selected run is still in progress, the command will show the progress of the run, with an option
* to show periodic status updates while waiting for completion (similar to `eas workflow:run --wait`).
*
*/
import { Flags } from '@oclif/core';
import { boolish } from 'getenv';
import { getWorkflowRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
choiceFromWorkflowRun,
processWorkflowRuns,
showWorkflowStatusAsync,
workflowRunExitCodes,
} from '../../commandUtils/workflow/utils';
import { WorkflowRunStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log, { link } from '../../log';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowStatus extends EasCommand {
static override description =
'show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
static override args = [
{
name: 'WORKFLOW_RUN_ID',
description: 'A workflow run ID.',
},
];
static override flags = {
...EASNonInteractiveFlag,
wait: Flags.boolean({
default: false,
allowNo: true,
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
summary: 'Wait for workflow run to complete. Defaults to false.',
}),
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.Vcs,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(WorkflowStatus);
if (flags.json) {
enableJsonOutput();
}
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowStatus, {
nonInteractive: flags['non-interactive'],
withServerSideEnvironment: null,
});
const {
projectId,
exp: { slug: projectName },
} = await getDynamicPrivateProjectConfigAsync();
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let workflowRunId = args.WORKFLOW_RUN_ID;
if (!workflowRunId && flags['non-interactive']) {
throw new Error('Workflow run ID is required in non-interactive mode');
}
if (!workflowRunId) {
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
undefined,
50
);
const runs = processWorkflowRuns(queryResult);
if (runs.length === 0) {
Log.warn('No workflow runs to show');
return;
}
const answers = await promptAsync({
type: 'select',
name: 'selectedRun',
message: 'Select a workflow run:',
choices: runs.map(run => choiceFromWorkflowRun(run)),
});
workflowRunId = answers.selectedRun;
}
Log.addNewLineIfNone();
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
Log.addNewLineIfNone();
const spinnerUsesStdErr = boolish('CI', false) || flags.json;
await showWorkflowStatusAsync(graphqlClient, {
workflowRunId,
spinnerUsesStdErr,
waitForCompletion: flags.wait,
});
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
const status = workflowRun.status;
if (flags.json) {
printJsonOnlyOutput({
...workflowRun,
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
});
}
if (status === WorkflowRunStatus.Failure) {
process.exit(workflowRunExitCodes.WORKFLOW_FAILED);
} else if (status === WorkflowRunStatus.Canceled) {
process.exit(workflowRunExitCodes.WORKFLOW_CANCELED);
}
}
}
@@ -24,7 +24,7 @@ export class SetUpTargetBuildCredentials {
constructor(private readonly options: Options) {}
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
const { app, entitlements } = this.options;
const { app, entitlements, target } = this.options;
await ctx.bestEffortAppStoreAuthenticateAsync();
@@ -35,7 +35,11 @@ export class SetUpTargetBuildCredentials {
bundleIdentifier: app.bundleIdentifier,
projectName: app.projectName,
},
{ entitlements, usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications }
{
entitlements,
usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications,
parentBundleIdentifier: target.parentBundleIdentifier,
}
);
}
try {
@@ -11,9 +11,22 @@ import { assertContractMessagesAsync } from './contractMessages';
import Log from '../../../log';
import { ora } from '../../../ora';
/**
* The entitlement key that indicates a target is an App Clip.
* When present, the target requires special bundle ID registration with a parent app relationship.
*/
const APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT =
'com.apple.developer.parent-application-identifiers';
export interface IosCapabilitiesOptions {
entitlements: JSONObject;
usesBroadcastPushNotifications: boolean;
/**
* The bundle identifier of the parent app. Required for App Clips.
* App Clips are detected by the presence of the
* `com.apple.developer.parent-application-identifiers` entitlement.
*/
parentBundleIdentifier?: string;
}
export interface AppLookupParams {
@@ -43,7 +56,13 @@ export async function ensureBundleIdExistsWithNameAsync(
options?: IosCapabilitiesOptions
): Promise<void> {
const context = getRequestContext(authCtx);
const spinner = ora(`Linking bundle identifier ${chalk.dim(bundleIdentifier)}`).start();
// Detect App Clips by the presence of the parent-application-identifiers entitlement
const isAppClip = Boolean(
options?.entitlements?.[APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT]
);
const typeLabel = isAppClip ? 'App Clip bundle identifier' : 'bundle identifier';
const spinner = ora(`Linking ${typeLabel} ${chalk.dim(bundleIdentifier)}`).start();
let bundleId: BundleId | null;
try {
@@ -51,23 +70,37 @@ export async function ensureBundleIdExistsWithNameAsync(
bundleId = await BundleId.findAsync(context, { identifier: bundleIdentifier });
if (!bundleId) {
spinner.text = `Registering bundle identifier ${chalk.dim(bundleIdentifier)}`;
// If it doesn't exist, create it
bundleId = await BundleId.createAsync(context, {
name,
identifier: bundleIdentifier,
});
spinner.text = `Registering ${typeLabel} ${chalk.dim(bundleIdentifier)}`;
if (isAppClip && options?.parentBundleIdentifier) {
// App Clips require the parent bundle ID's opaque ID
bundleId = await createAppClipBundleIdAsync(context, {
name,
bundleIdentifier,
parentBundleIdentifier: options.parentBundleIdentifier,
});
} else {
// If it doesn't exist, create it
bundleId = await BundleId.createAsync(context, {
name,
identifier: bundleIdentifier,
});
}
}
spinner.succeed(`Bundle identifier registered ${chalk.dim(bundleIdentifier)}`);
spinner.succeed(
`${isAppClip ? 'App Clip bundle' : 'Bundle'} identifier registered ${chalk.dim(
bundleIdentifier
)}`
);
} catch (err: any) {
if (err.message.match(/An App ID with Identifier '(.*)' is not available/)) {
spinner.fail(
`The bundle identifier ${chalk.bold(bundleIdentifier)} is not available to team "${
`The ${typeLabel} ${chalk.bold(bundleIdentifier)} is not available to team "${
authCtx.team.name
}" (${authCtx.team.id}), change it in your app config and try again.`
);
} else {
spinner.fail(`Failed to register bundle identifier ${chalk.dim(bundleIdentifier)}`);
spinner.fail(`Failed to register ${typeLabel} ${chalk.dim(bundleIdentifier)}`);
// Assert contract errors for easier resolution when the user has an expired developer account.
if (
@@ -93,6 +126,42 @@ export async function ensureBundleIdExistsWithNameAsync(
}
}
/**
* Creates an App Clip bundle identifier.
* App Clips require the parent bundle ID's opaque ID for registration.
*/
async function createAppClipBundleIdAsync(
context: RequestContext,
{
name,
bundleIdentifier,
parentBundleIdentifier,
}: {
name: string;
bundleIdentifier: string;
parentBundleIdentifier: string;
}
): Promise<BundleId> {
// First, find the parent bundle ID to get its opaque ID
const parentBundleId = await BundleId.findAsync(context, {
identifier: parentBundleIdentifier,
});
if (!parentBundleId) {
throw new Error(
`Cannot create App Clip bundle identifier "${bundleIdentifier}" because the parent bundle identifier "${parentBundleIdentifier}" is not registered. ` +
`Please ensure the parent app's bundle identifier is registered first.`
);
}
// Create the App Clip bundle ID with the parent relationship
return await BundleId.createAppClipAsync(context, {
name,
identifier: bundleIdentifier,
parentBundleIdId: parentBundleId.id,
});
}
export async function syncCapabilitiesAsync(
bundleId: BundleId,
{ entitlements, ...rest }: IosCapabilitiesOptions
@@ -3,7 +3,7 @@ import { ManageIos } from './ManageIos';
import { Analytics } from '../../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectPlatformAsync } from '../../platform';
import { selectPlatformWithExitOptionAsync } from '../../platform';
import { Actor } from '../../user/User';
import { Client } from '../../vcs/vcs';
import { CredentialsContextProjectInfo } from '../context';
@@ -20,7 +20,7 @@ export class SelectPlatform {
) {}
async runAsync(): Promise<void> {
const platform = await selectPlatformAsync(this.flagPlatform);
const platform = await selectPlatformWithExitOptionAsync(this.flagPlatform);
if (platform === 'ios') {
await new ManageIos(this, process.cwd()).runAsync();
+1329 -111
View File
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),
};
},
};
@@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AssetSignedUrlResult,
GetAssetSignedUrlsQuery,
GetAssetSignedUrlsQueryVariables,
} from '../generated';
export const AssetQuery = {
async getSignedUrlsAsync(
graphqlClient: ExpoGraphqlClient,
updateId: string,
storageKeys: string[]
): Promise<AssetSignedUrlResult[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetAssetSignedUrlsQuery, GetAssetSignedUrlsQueryVariables>(
gql`
query GetAssetSignedUrls($updateId: ID!, $storageKeys: [String!]!) {
asset {
signedUrls(updateId: $updateId, storageKeys: $storageKeys) {
storageKey
url
headers
}
}
}
`,
{
updateId,
storageKeys,
},
{ additionalTypenames: [] }
)
.toPromise()
);
return data.asset.signedUrls;
},
};
@@ -63,15 +63,24 @@ export const BranchQuery = {
}
return updateBranchByName;
},
async getLatestUpdateIdOnBranchAsync(
async getUpdateIdsOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
platform,
runtimeVersion,
}: { appId: string; branchName: string; platform: AppPlatform; runtimeVersion: string }
): Promise<string | null> {
offset = 0,
limit = 1,
}: {
appId: string;
branchName: string;
platform: AppPlatform;
runtimeVersion: string;
offset?: number;
limit?: number;
}
): Promise<string[]> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<ViewLatestUpdateOnBranchQuery, ViewLatestUpdateOnBranchQueryVariables>(
@@ -81,6 +90,8 @@ export const BranchQuery = {
$branchName: String!
$platform: AppPlatform!
$runtimeVersion: String!
$offset: Int!
$limit: Int!
) {
app {
byId(appId: $appId) {
@@ -88,8 +99,8 @@ export const BranchQuery = {
updateBranchByName(name: $branchName) {
id
updates(
offset: 0
limit: 1
offset: $offset
limit: $limit
filter: { platform: $platform, runtimeVersions: [$runtimeVersion] }
) {
id
@@ -104,6 +115,8 @@ export const BranchQuery = {
branchName,
platform,
runtimeVersion,
offset,
limit,
},
{ additionalTypenames: ['UpdateBranch'] }
)
@@ -113,11 +126,26 @@ export const BranchQuery = {
if (!updateBranchByName) {
throw new BranchNotFoundError(`Could not find a branch named "${branchName}".`);
}
const latestUpdate = updateBranchByName.updates[0];
if (!latestUpdate) {
return null;
}
return latestUpdate.id;
return updateBranchByName.updates.map(update => update.id);
},
async getLatestUpdateIdOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
platform,
runtimeVersion,
}: { appId: string; branchName: string; platform: AppPlatform; runtimeVersion: string }
): Promise<string | null> {
const updateIds = await this.getUpdateIdsOnBranchAsync(graphqlClient, {
appId,
branchName,
platform,
runtimeVersion,
offset: 0,
limit: 1,
});
return updateIds[0] ?? null;
},
async listBranchesOnAppAsync(
graphqlClient: ExpoGraphqlClient,
@@ -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
}
`;
@@ -12,12 +12,6 @@ jest.mock('../../config/writer');
describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AgeRatingTask().prepareAsync({ context: {} as any });
await expect(promise).rejects.toThrow('not prepared');
});
it('loads age rating from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
@@ -35,14 +35,16 @@ describe(AppInfoTask, () => {
});
describe('downloadAsync', () => {
it('aborts when app info is not loaded', async () => {
it('skips when app info is not loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const promise = new AppInfoTask().downloadAsync({
await new AppInfoTask().downloadAsync({
config: writer,
context: { info: undefined, infoLocales: [] } as any,
});
await expect(promise).rejects.toThrow('info not initialized');
// Should not call any setter methods when info is not available
expect(writer.setCategories).not.toBeCalled();
});
it('sets categories when info is loaded', async () => {
@@ -11,12 +11,6 @@ jest.mock('../../config/writer');
describe(AppReviewDetailTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().prepareAsync({ context: {} as any });
await expect(promise).rejects.toThrow('version not init');
});
it('loads review details from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/appStoreReviewDetail`)
@@ -254,13 +254,16 @@ describe(AppVersionTask, () => {
});
describe('downloadAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AppVersionTask().downloadAsync({
config: new AppleConfigWriter(),
it('skips when version is not loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppVersionTask().downloadAsync({
config: writer,
context: { version: undefined } as any,
});
await expect(promise).rejects.toThrow('not initialized');
// Should not call any setter methods when version is not available
expect(writer.setVersion).not.toBeCalled();
});
it('sets version when loaded', async () => {
@@ -15,8 +15,7 @@ export class AgeRatingTask extends AppleTask {
public name = (): string => 'age rating declarations';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
assert(context.version, `App version information is not prepared, can't update age rating`);
context.ageRating = (await context.version.getAgeRatingDeclarationAsync()) ?? undefined;
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -18,19 +18,32 @@ export class AppInfoTask extends AppleTask {
public name = (): string => 'app information';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
const info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
assert(info, 'Could not resolve the editable app info to update');
let info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
// Fallback to live app info if no editable info is found (for metadata:pull)
if (!info) {
info = await context.app.getLiveAppInfoAsync();
}
if (!info) {
// No app info available - this is OK for metadata:pull
// when the app hasn't been set up yet
return;
}
context.info = info;
context.infoLocales = await info.getLocalizationsAsync();
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
assert(context.info, `App info not initialized, can't download info`);
if (!context.info) {
// No app info available to download - skip silently
return;
}
config.setCategories(context.info.attributes);
for (const locale of context.infoLocales) {
for (const locale of context.infoLocales ?? []) {
config.setInfoLocale(locale.attributes);
}
}
@@ -16,8 +16,7 @@ export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
assert(context.version, `App version not initialized, can't download store review details`);
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -57,7 +57,11 @@ export class AppVersionTask extends AppleTask {
this.options
);
assert(version, 'Could not resolve a live or editable app version');
if (!version) {
// No live or editable version available - this is OK for metadata:pull
// when the app hasn't been submitted yet
return;
}
context.version = version;
context.versionIsFirst = versionIsFirst;
@@ -67,13 +71,16 @@ export class AppVersionTask extends AppleTask {
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
assert(context.version, `App version not initialized, can't download version`);
if (!context.version) {
// No version available to download - skip silently
return;
}
config.setVersion(context.version.attributes);
config.setVersionReleaseType(context.version.attributes);
config.setVersionReleasePhased(context.versionPhasedRelease?.attributes);
for (const locale of context.versionLocales) {
for (const locale of context.versionLocales ?? []) {
config.setVersionLocale(locale.attributes);
}
}
@@ -211,6 +218,12 @@ async function resolveVersionAsync(
version = await retryIfNullAsync(() => app.getEditAppStoreVersionAsync({ platform }));
}
// Fallback to live version if no editable version is found (for metadata:pull)
if (!version) {
version = await app.getLiveAppStoreVersionAsync({ platform });
versionIsLive = !!version;
}
const versions = await app.getAppStoreVersionsAsync({
query: { limit: 2, filter: { platform } },
});
+3
View File
@@ -9,11 +9,13 @@ export async function runGitCloneAsync({
githubRepositoryName,
githubUsername,
cloneMethod,
showOutput = true,
}: {
githubUsername: string;
githubRepositoryName: string;
targetProjectDir: string;
cloneMethod: 'ssh' | 'https';
showOutput?: boolean;
}): Promise<{
targetProjectDir: string;
}> {
@@ -29,6 +31,7 @@ export async function runGitCloneAsync({
return line.includes('Cloning into');
},
showSpinner: false,
showOutput,
});
return { targetProjectDir };
} catch (error: any) {
@@ -35,5 +35,6 @@ export async function installDependenciesAsync({
);
},
showOutput: outputLevel !== 'none',
showSpinner: outputLevel !== 'none',
});
}
@@ -21,7 +21,9 @@ export async function runCommandAsync({
showSpinner?: boolean;
showOutput?: boolean;
}): Promise<void> {
Log.log(`🏗️ Running ${chalk.bold(`${command} ${args.join(' ')}`)}...`);
if (showOutput) {
Log.log(`🏗️ Running ${chalk.bold(`${command} ${args.join(' ')}`)}...`);
}
let spinner: Ora | undefined;
if (showSpinner) {
spinner = ora(`${chalk.bold(`${command} ${args.join(' ')}`)}`).start();
@@ -71,6 +73,10 @@ export async function runCommandAsync({
throw error;
}
if (!showOutput) {
return;
}
if (showSpinner) {
spinner?.succeed(`${chalk.bold(`${command} ${args.join(' ')}`)} succeeded`);
} else {
+28 -6
View File
@@ -1,6 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import { AppPlatform } from './graphql/generated';
import Log from './log';
import { promptAsync } from './prompts';
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
@@ -47,21 +48,42 @@ export async function selectRequestedPlatformAsync(platform?: string): Promise<R
return requestedPlatform;
}
export async function selectPlatformWithExitOptionAsync(platform?: string): Promise<Platform> {
return await selectPlatformInternalAsync(platform, true);
}
export async function selectPlatformAsync(platform?: string): Promise<Platform> {
return await selectPlatformInternalAsync(platform, false);
}
async function selectPlatformInternalAsync(
platform?: string,
allowExit?: boolean
): Promise<Platform> {
if (platform && Object.values(Platform).includes(platform.toLowerCase() as Platform)) {
return platform.toLowerCase() as Platform;
}
const { resolvedPlatform } = await promptAsync({
const platformChoices: { title: string; value: Platform | 'Exit' }[] = [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
];
if (allowExit) {
platformChoices.push({ title: 'Exit', value: 'Exit' });
}
const result: any = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'resolvedPlatform',
choices: [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
],
choices: platformChoices,
});
return resolvedPlatform;
if (result.resolvedPlatform === 'Exit') {
Log.addNewLineIfNone();
Log.log('Exiting');
process.exit(0);
}
return result.resolvedPlatform;
}
export function toPlatforms(requestedPlatform: RequestedPlatform): Platform[] {
@@ -9,9 +9,11 @@ import { AssetMetadataStatus } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
import { RequestedPlatform } from '../../platform';
import { expoCommandAsync } from '../../utils/expoCli';
import {
MetadataJoi,
RawAsset,
buildBundlesAsync,
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
convertAssetToUpdateInfoGroupFormatAsync,
@@ -21,6 +23,7 @@ import {
getAssetHashFromPath,
getBase64URLEncoding,
getOriginalPathFromAssetMap,
getSourceMapExportCommandArgs,
getStorageKey,
getStorageKeyForAssetAsync,
guessContentTypeFromExtension,
@@ -30,6 +33,11 @@ import {
jest.mock('../../uploads');
jest.mock('fs');
jest.mock('../../utils/expoCli', () => ({
expoCommandAsync: jest.fn(),
shouldUseVersionedExpoCLI: jest.fn(() => true),
shouldUseVersionedExpoCLIWithExplicitPlatforms: jest.fn(() => true),
}));
const dummyFileBuffer = Buffer.from('dummy-file');
@@ -702,3 +710,142 @@ describe(uploadAssetsAsync, () => {
expect(onAssetUploadResultsChangedFn).toHaveBeenCalledTimes(3);
});
});
describe(getSourceMapExportCommandArgs, () => {
it('returns --dump-sourcemap when sourceMaps is undefined, empty array when "false"', () => {
expect(getSourceMapExportCommandArgs({ sourceMaps: undefined, sdkVersion: '50.0.0' })).toEqual([
'--dump-sourcemap',
]);
expect(getSourceMapExportCommandArgs({ sourceMaps: 'false', sdkVersion: '55.0.0' })).toEqual(
[]
);
});
it('returns --source-maps with value only for SDK >= 55', () => {
// SDK < 55: no value
expect(getSourceMapExportCommandArgs({ sourceMaps: 'inline', sdkVersion: '50.0.0' })).toEqual([
'--source-maps',
]);
// SDK >= 55: includes value
expect(getSourceMapExportCommandArgs({ sourceMaps: 'inline', sdkVersion: '55.0.0' })).toEqual([
'--source-maps',
'inline',
]);
// "true" never includes a value
expect(getSourceMapExportCommandArgs({ sourceMaps: 'true', sdkVersion: '55.0.0' })).toEqual([
'--source-maps',
]);
});
});
describe(buildBundlesAsync, () => {
const projectDir = '/test-project';
const inputDir = 'dist';
beforeEach(async () => {
jest.mocked(expoCommandAsync).mockClear();
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify({ name: 'test', version: '1.0.0' })
);
});
afterEach(async () => {
await fs.remove(projectDir);
});
it('uses --source-maps instead of --dump-sourcemap when provided', async () => {
// SDK < 55: --source-maps is a boolean flag, value not supported
// When sourceMaps is 'inline' but SDK < 55, pass --source-maps without the value
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
sourceMaps: 'inline',
});
let args = jest.mocked(expoCommandAsync).mock.calls[0][1];
expect(args).toContain('--source-maps');
expect(args).not.toContain('inline');
expect(args).not.toContain('--dump-sourcemap');
// SDK >= 55: --source-maps supports a value (e.g., 'inline')
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '55.0.0' },
platformFlag: 'all',
sourceMaps: 'inline',
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
expect.arrayContaining(['--source-maps', 'inline']),
expect.any(Object)
);
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap');
// When sourceMaps is 'true', pass --source-maps without a value (regardless of SDK version)
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '55.0.0' },
platformFlag: 'all',
sourceMaps: 'true',
});
args = jest.mocked(expoCommandAsync).mock.calls[0][1];
expect(args).toContain('--source-maps');
expect(args).not.toContain('true');
expect(args).not.toContain('--dump-sourcemap');
// When sourceMaps is "false", don't pass any source map flags
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
sourceMaps: 'false',
});
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap');
// When sourceMaps is undefined, fall back to --dump-sourcemap
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
});
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).toContain('--dump-sourcemap');
});
it('passes --no-bytecode only when true', async () => {
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
noBytecode: true,
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
expect.arrayContaining(['--no-bytecode']),
expect.any(Object)
);
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
noBytecode: false,
});
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--no-bytecode');
});
});
+46 -11
View File
@@ -11,6 +11,7 @@ import mime from 'mime';
import nullthrows from 'nullthrows';
import path from 'path';
import promiseLimit from 'promise-limit';
import semver from 'semver';
import { maybeUploadFingerprintAsync } from './maybeUploadFingerprintAsync';
import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync } from './projectUtils';
@@ -22,7 +23,6 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { FingerprintOptions, createFingerprintsByKeyAsync } from '../fingerprint/cli';
import {
AppPlatform,
AssetMetadataStatus,
BuildFragment,
FingerprintSourceInput,
@@ -39,8 +39,10 @@ import { promptAsync } from '../prompts';
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
import {
UpdateJsonInfo,
UpdatePublishPlatform,
formatUpdateMessage,
truncateString as truncateUpdateMessage,
updatePublishPlatformToAppPlatform,
} from '../update/utils';
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
import {
@@ -56,9 +58,6 @@ import mapMapAsync from '../utils/expodash/mapMapAsync';
import uniqBy from '../utils/expodash/uniqBy';
import { Client } from '../vcs/vcs';
// update publish does not currently support web
export type UpdatePublishPlatform = 'ios' | 'android';
type Metadata = {
version: number;
bundler: 'metro';
@@ -210,6 +209,8 @@ export async function buildBundlesAsync({
exp,
platformFlag,
clearCache,
noBytecode,
sourceMaps,
extraEnv,
}: {
projectDir: string;
@@ -217,6 +218,8 @@ export async function buildBundlesAsync({
exp: Pick<ExpoConfig, 'sdkVersion' | 'web'>;
platformFlag: ExpoCLIExportPlatformFlag;
clearCache?: boolean;
noBytecode?: boolean;
sourceMaps?: string;
extraEnv?: Record<string, string | undefined> | undefined;
}): Promise<void> {
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
@@ -254,16 +257,19 @@ export async function buildBundlesAsync({
? ['--platform', 'ios', '--platform', 'android']
: ['--platform', platformFlag];
const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion });
await expoCommandAsync(
projectDir,
[
'export',
'--output-dir',
inputDir,
'--dump-sourcemap',
...sourceMapArgs,
'--dump-assetmap',
...platformArgs,
...(clearCache ? ['--clear'] : []),
...(noBytecode ? ['--no-bytecode'] : []),
],
{
extraEnv,
@@ -282,16 +288,19 @@ export async function buildBundlesAsync({
);
}
const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion });
await expoCommandAsync(
projectDir,
[
'export',
'--output-dir',
inputDir,
'--dump-sourcemap',
...sourceMapArgs,
'--dump-assetmap',
`--platform=${platformFlag}`,
...(clearCache ? ['--clear'] : []),
...(noBytecode ? ['--no-bytecode'] : []),
],
{
extraEnv,
@@ -1049,11 +1058,6 @@ export const platformDisplayNames: Record<UpdatePublishPlatform, string> = {
ios: 'iOS',
};
export const updatePublishPlatformToAppPlatform: Record<UpdatePublishPlatform, AppPlatform> = {
android: AppPlatform.Android,
ios: AppPlatform.Ios,
};
export async function getRuntimeToUpdateRolloutInfoGroupMappingAsync(
graphqlClient: ExpoGraphqlClient,
{
@@ -1118,3 +1122,34 @@ export async function getUpdateRolloutInfoGroupAsync(
)
);
}
/**
* Get the command line arguments for source map generation in expo export.
*
* Uses --source-maps if provided, otherwise falls back to --dump-sourcemap.
* SDK 55+ supports --source-maps with a value (e.g., 'inline'), but older SDKs
* only support it as a boolean flag. Passing a value to older SDKs causes it
* to be parsed as the project root positional argument.
*/
export function getSourceMapExportCommandArgs({
sourceMaps,
sdkVersion,
}: {
sourceMaps: string | undefined;
sdkVersion: string | undefined;
}): string[] {
if (!sourceMaps) {
return ['--dump-sourcemap'];
}
if (sourceMaps === 'false') {
return [];
}
const supportsSourceMapModes = sdkVersion && semver.satisfies(sdkVersion, '>=55.0.0');
if (supportsSourceMapModes && sourceMaps !== 'true') {
return ['--source-maps', sourceMaps];
}
return ['--source-maps'];
}
@@ -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"
}
}
@@ -5,7 +5,6 @@ import {
App,
DeploymentResult,
Runtime,
UpdateBranch,
UpdateChannel,
UpdateChannelRuntimeInsights,
UpdateInsights,
@@ -126,7 +125,7 @@ function mockUpdateChannel({
};
}
function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
function mockUpdateBranches(branchNames: string[]): any[] {
return branchNames.map(branchName => ({
id: 'f9dc4dbb-663f-4a19-8cf2-a783b484d2db',
latestActivity: '2022-12-07T02:24:29.786Z',
+9 -2
View File
@@ -2,6 +2,7 @@ import assert from 'assert';
import chalk from 'chalk';
import {
UpdatePublishPlatform,
formatBranch,
formatUpdateGroup,
formatUpdateTitle,
@@ -19,7 +20,6 @@ import {
import { RuntimeQuery } from '../graphql/queries/RuntimeQuery';
import { UpdateQuery } from '../graphql/queries/UpdateQuery';
import Log from '../log';
import { UpdatePublishPlatform } from '../project/publish';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
import {
@@ -36,9 +36,11 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
filter,
paginatedQueryOptions,
}: {
projectId: string;
filter?: ViewUpdateGroupsOnAppQueryVariables['filter'];
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
@@ -47,6 +49,7 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
offset: paginatedQueryOptions.offset,
appId: projectId,
filter,
});
renderUpdateGroupsOnApp({ updateGroups, paginatedQueryOptions });
} else {
@@ -54,7 +57,7 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId, filter }),
promptOptions: {
title: 'Load more update groups?',
renderListItems: updateGroups => {
@@ -70,10 +73,12 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
{
projectId,
branchName,
filter,
paginatedQueryOptions,
}: {
projectId: string;
branchName: string;
filter?: ViewUpdateGroupsOnBranchQueryVariables['filter'];
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
@@ -83,6 +88,7 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
offset: paginatedQueryOptions.offset,
appId: projectId,
branchName,
filter,
});
renderUpdateGroupsOnBranch({ updateGroups, branchName, paginatedQueryOptions });
} else {
@@ -95,6 +101,7 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
offset,
appId: projectId,
branchName,
filter,
}),
promptOptions: {
title: 'Load more update groups?',
+2 -2
View File
@@ -7,7 +7,7 @@ import {
selectRuntimeAndGetLatestUpdateGroupForEachPublishPlatformOnBranchAsync,
selectUpdateGroupOnBranchAsync,
} from './queries';
import { truncateString as truncateUpdateMessage } from './utils';
import { UpdatePublishPlatform, truncateString as truncateUpdateMessage } from './utils';
import { selectBranchOnAppAsync } from '../branch/queries';
import { getUpdateGroupUrl } from '../build/utils/url';
import { selectChannelOnAppAsync } from '../channel/queries';
@@ -20,7 +20,7 @@ import { UpdateQuery } from '../graphql/queries/UpdateQuery';
import Log, { link } from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { UpdatePublishPlatform, getUpdateRolloutInfoGroupAsync } from '../project/publish';
import { getUpdateRolloutInfoGroupAsync } from '../project/publish';
import { promptAsync } from '../prompts';
import {
CodeSigningInfo,
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more