Compare commits

..
Author SHA1 Message Date
Jakub Tkacz 9dcc69d3d0 feat: remove build info from metrics 2026-02-25 10:05:05 +01:00
Jakub Tkacz a58c4817f5 refactor: move constants to command files 2026-02-24 17:44:18 +01:00
Jakub Tkacz abf3904bae feat: add --days-from-now to metrics command 2026-02-24 16:39:06 +01:00
Jakub Tkacz 4ebdae0ca2 fix: formatting 2026-02-24 16:36:56 +01:00
Jakub Tkacz 8c12d6512a tests: improve test coverage 2026-02-24 16:32:35 +01:00
Jakub Tkacz c71cf4df5b feat: add better description to metric flags 2026-02-24 16:32:35 +01:00
Jakub Tkacz f76152fef5 feat: add --offset filter to metrics 2026-02-24 16:32:35 +01:00
Jakub Tkacz f19801b668 feat: add --after filter to events 2026-02-24 16:32:35 +01:00
Jakub Tkacz 7e0fefa6b0 increase builds limit to 25 2026-02-24 16:31:58 +01:00
Jakub Tkacz 7f2749fc2d feat: add --stat argument to observe:metrics 2026-02-24 16:31:58 +01:00
Jakub Tkacz 0ec40f02b4 feat: add --days-from-now and --update-id filters to observe:events 2026-02-24 16:31:58 +01:00
Jakub Tkacz 5c23d16412 feat: add observe:events 2026-02-24 16:31:58 +01:00
Jakub Tkacz 39e014b188 feat(eas-cli): add observe metrics command 2026-02-24 16:31:58 +01:00
Douglas Lowder 0c32aa3a39 [release] Work around the removal of yarn --silent (#3424)
# Why

When yarn moved to version 4, they removed the `--silent` flag. Because of this change, any use of yarn to call scripts and get output will also get yarn debugging garbage, and there is no way to suppress it.

The release scripts are mostly ok with this, except for calling the `next-version` script. The output of this script has to be clean for it to be used as input for `lerna version`.

# How

Replaced the yarn call with a direct invocation of the node Typescript source, bypassing yarn.

# Test Plan

Since the `trigger-release.yml` workflow supports a dry run mode, it's safe to modify it to check out a test branch and run the workflow many times to debug the issue.

Once this change is merged, a dry run invocation of the workflow should succeed even if no version string is passed in.
2026-02-24 03:37:51 -08:00
Tomek Czajecki 59ac8ae77a [eas-cli] Add go command (#3376) 2026-02-23 15:23:22 -08:00
Douglas Lowder 3e13df470e [eas-cli] Require --environment flag for eas update when SDK >= 55 (#3418)
# Why

Problems can occur if an update intended for a development build is published with the environment defaulting to or set to production, and vice versa.

To prevent such problems in the future, this PR makes the `--environment` flag a required flag for projects using SDK 55 and greater.

# How

Add a new utility function to throw an error if sdkVersion does not meet the requirement, and use that function in `eas update:configure` and `eas update`.

Skip the check if EAS_BUILD env is set.

# Test Plan

Unit tests added.

Testing locally with different projects.
2026-02-23 13:48:35 -08:00
Expo CI 83877f6285 update CHANGELOG.md 2026-02-23 20:47:53 +00:00
Expo CI 56018338e7 v18.0.4 2026-02-23 20:45:25 +00:00
Byron Karlen 0fdf2dd44a Revert "Use auth code flow (#3398)" (#3422)
This reverts commit 1604b7bc20.
2026-02-23 12:30:47 -08:00
Expo CI 0dcd6d465a update CHANGELOG.md 2026-02-20 08:02:18 +00:00
Expo CI d5da4f4167 v18.0.3 2026-02-20 07:59:25 +00:00
Stanisław Chmiela d0b9281bb2 [chore] Fix Yarn v4 prepack commands (#3417)
## Why
Release publish failed in `Publish to npm` for `v18.0.2`.

- `packages/eas-cli/scripts/prepack.js` invoked `yarn oclif manifest`, which fails on Yarn v4 (`Couldn't find a script named \"oclif\"`).
- `packages/create-eas-build-function` `clean` script used an unquoted `*.tsbuildinfo` glob, which fails when no files match.

## How
- Update `packages/eas-cli/scripts/prepack.js` to run `yarn run -T oclif manifest`.
- Quote the tsbuildinfo glob in `packages/create-eas-build-function/package.json`:
  - `rimraf ./build/ "*.tsbuildinfo"`

## Test plan
- `cd packages/eas-cli && mise exec [email protected].4 -- node ./scripts/prepack.js`
- `cd packages/create-eas-build-function && mise exec [email protected].4 -- yarn run clean`
- CI
2026-02-20 08:48:21 +01:00
Expo CI 77e04a3bad v18.0.2 2026-02-19 12:34:59 +00:00
Stanisław Chmiela 92ff6a0da5 [eas-build-job] Add hooks schema (#3408)
## Summary
- introduce a shared `Hooks` type/schema with reusable Zod/Joi validation so any string key maps to step arrays
- wire the new hooks definition through the generic, Android, and iOS job schemas and exports
- expand existing tests to cover accepting arbitrary hook names, empty hook arrays, and invalid hook step payloads

Not sure if it makes sense to make this "smart". I think let's just roll this out manually? Like, have `eas/install_node_modules` steps be sort of manually wrapped with hooks? Or maybe we'll sprinkle them when parsing the steps. I'll see. What I think is right is _not_ to limit the keys to only a few known values.

## Test plan

CI should pass.
2026-02-19 12:07:07 +01:00
Stanisław Chmiela 25d945b63f [worker] Move Yarn version from Mise to Corepack (#3412)
## Why

In https://github.com/expo/eas-cli/pull/3406 EAS workflow building the worker was **not** updated to Yarn v4. This breaks building worker.

I tried adding `defaults.tools.yarn: 4.12.0`, but this unfortunately [is **not** the way to use Yarn v4 on EAS as I've learned](https://docs.expo.dev/more/create-expo/#yarn-2-modern). The right way is using Corepack.

However, previously we explicitly did **not** use Corepack, because `pnpm --version` check the worker does in the beginning of its run was failing because it was run without `cwd`, so it was running within worker package which was configured with Yarn.

Since we want to use Yarn v4, we need to figure out a way to use Corepack.

## How

- Enabled Corepack.
- Set up `yarn` executable like the one we have in `universe`.
- Changed `pnpm --version` to **not** run inside worker (so it does not error).
- Removed `setup-node` for specific Node versions in favor of a Mise override.

## Test plan
- Corepack + Yarn resolution checks (local):
  - `mise exec [email protected].4 -- bash -lc 'corepack enable; yarn --version'` => `4.12.0`
  - same check from `scripts/` => `4.12.0`
- Worker runtime unit test (local):
  - `mise exec [email protected].4 -- bash -lc 'corepack enable; yarn workspace @expo/worker test:unit -- runtimeEnvironment.test.ts'`
- GitHub workflow runs:
  - success on Corepack worker workflow update: https://github.com/expo/eas-cli/actions/runs/22133707462
  - latest run for full set of changes: https://github.com/expo/eas-cli/actions/runs/22133932635
2026-02-19 10:17:57 +01:00
Stanisław Chmiela 37165428d1 [chore] Upgrade repository to Yarn 4 with Mise (#3406)
## Why
- Repository maintenance: standardize on Yarn 4 managed via Mise.
- Prepare for introducing minimum package-age constraints in dependency policy.

## How
- Migrated repo tooling to Yarn 4 with Mise.
- Updated CI and scripts for Yarn 4 behavior:
  - Lerna invoked via `yarn run -T lerna`.
  - Coverage args forwarded correctly (`-- --coverage`).
  - Removed unsupported Yarn 4 `--silent` usage.
- Added missing package-local script dependencies surfaced by Yarn 4 (`jest`, `oxlint`, `rimraf`).
- Increased `EasCommand` coverage test timeout from 30s to 60s. (Was consistently failing in CI.)

## Test Plan
- CI only: `Run tests` matrix (including Node 20 coverage) and other touched workflows.
- Release behavior will be verified during the normal release run.
2026-02-18 09:50:28 +01:00
Stanisław Chmiela 50b9be975e [build-tools] Add IPA info reader build function (#3405)
# Why

We need a build function that can read metadata directly from an IPA so later steps (for example App Store Connect upload flows) can consume canonical bundle values without requiring manual input.

# How

- Added a new build function `eas/read_ipa_info` in `packages/build-tools/src/steps/functions/readIpaInfo.ts`.
- The function accepts `ipa_path` and outputs:
  - `bundle_identifier`
  - `bundle_short_version`
  - `bundle_version`
- IPA inspection reads `Payload/*.app/Info.plist` from the zip and supports both plist formats commonly seen in IPAs:
  - binary plist (`bplist00`) via `bplist-parser`
  - XML plist via `plist`
- Added `UserFacingError` handling for parsing failures/invalid binary plist.
- Added the function to the EAS function registry in `packages/build-tools/src/steps/easFunctions.ts`.
- Added fixture-based tests using `SmallestAppExample.ipa` under `packages/build-tools/src/steps/functions/__tests__/fixtures/`.
- Added `node-stream-zip` and pinned `bplist-parser` to `0.3.2`.

# Test Plan

- Automated tests:
  - `yarn --cwd packages/build-tools test src/steps/functions/__tests__/readIpaInfo.test.ts`
- Typecheck:
  - `yarn --cwd packages/build-tools typecheck`

Coverage in `readIpaInfo.test.ts`:
- reads bundle identifier/short version/version from the fixture IPA
- fails when `Info.plist` is missing
- fails when required plist keys are missing
- verifies build-function outputs are populated correctly
2026-02-17 16:24:45 +01:00
Stanisław Chmiela 2655482f74 [worker] Logs improvements (#3397)
<!-- 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

For the new logs experience we need to identify every log with a unique ID.

Also, in the new logs experience we're going to be handling logs in plaintext, uncompressed, so size starts to matter.

# How

Decorating every log with a unique ID is surprisingly difficult with bunyan. It only runs serializers on the object passed in, I tried adding a `log_id` serializer `serializers: { log_id: () => uuidv7() }` and a non-empty value to build logger, but it was getting serialized in the moment of logger creation.

Instead, I'm adding `log_id` in the `Transform` that also masks secrets. The overall architecture looks like this now:

<img width="1742" height="1126" alt="Bez tytułu-2026-02-04-1436-3" src="https://github.com/user-attachments/assets/956e1b58-75ca-41c5-92ba-8229dd981ff6" />

Also removes `service: 'worker'` and `hostname` from logs. `service` was only being used [here](https://github.com/expo/universe/blob/3246198a3f913bf6fd0fd82e8e3df402cb59e82e/server/website/common/logs-utils.ts#L218) which I don't think is a thing right now.

# Test Plan

Added unit test that ensures `Transform` adds `logId`.

<img width="1257" height="1102" alt="Zrzut ekranu 2026-02-12 o 16 02 32" src="https://github.com/user-attachments/assets/4cfbbf43-744f-4af0-9faf-de0cab54f178" />
2026-02-16 09:31:09 +01:00
Stanisław Chmiela dc103acf06 [build-tools] Increase ASC build processing wait timeout (#3403)
## Summary
- extend the `upload_to_asc` build status polling window from 10 minutes to 30 minutes so slowly processed builds have time to reach TestFlight
- keep the rest of the polling logic and `wait_for_processing` default untouched
2026-02-16 09:02:00 +01:00
Mustafa Abban b87bc5bf8f [worker] unknown error log std error/output (#3402)
* [worker] unknown error log std error/output

* [worker] unknown error log std error/output

* check token
2026-02-13 14:19:04 -05:00
Byron Karlen 1604b7bc20 Use auth code flow (#3398) 2026-02-12 17:24:19 -08:00
Stanisław Chmiela 13a6356fca [chore] Fix post-release lint (#3396)
<!-- 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

After a new release is cut, `lerna.json` and `README.md` want to be reformatted by Oxfmt.

# How

Excluded them from being formatted.

# Test Plan

`yarn fmt` changes no files.
2026-02-12 15:59:27 +01:00
Expo CI 0823a9d675 update CHANGELOG.md 2026-02-12 11:18:30 +00:00
91 changed files with 26982 additions and 17516 deletions

No files matched your search

+4 -4
View File
@@ -32,11 +32,11 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
run: yarn install --immutable
- name: Build
run: yarn build
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
@@ -64,12 +64,12 @@ jobs:
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Update changelog
working-directory: ./scripts
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
- name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
env:
+1 -1
View File
@@ -20,6 +20,6 @@ jobs:
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+1 -1
View File
@@ -24,5 +24,5 @@ jobs:
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: yarn install --immutable
- run: yarn typecheck
+18 -10
View File
@@ -22,11 +22,14 @@ jobs:
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install dependencies
run: yarn install --immutable
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- run: yarn typecheck
- run: |
git config --global user.email "[email protected]"
@@ -38,32 +41,37 @@ jobs:
- name: Run tests (affected)
# PRs: only run tests for changed packages + dependents.
if: ${{ !matrix.coverage && github.event_name == 'pull_request' }}
run: yarn lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
run: yarn run -T lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (all)
# Pushes to main: run all package tests.
if: ${{ !matrix.coverage && github.event_name == 'push' }}
run: yarn lerna run test
run: yarn run -T lerna run test
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (coverage)
# Coverage on PRs and main; per-package reports are uploaded to Codecov.
if: ${{ matrix.coverage }}
run: yarn lerna run test -- -- --coverage
run: yarn run -T lerna run test -- --coverage
env:
# Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint
- run: yarn fmt:check
- run: yarn install --frozen-lockfile --check-files
- name: Install scripts dependencies
if: ${{ matrix.coverage }}
run: yarn install --immutable
working-directory: ./scripts
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- run: yarn lint-changelog
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --frozen-lockfile --check-files
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: yarn install --immutable
- run: yarn typecheck
+1
View File
@@ -20,6 +20,7 @@ dist
# Node
node_modules
**/.yarn/install-state.gz
*-debug.log
*-error.log
+2
View File
@@ -8,10 +8,12 @@
"endOfLine": "lf",
"ignorePatterns": [
".github",
"lerna.json",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/build-tools/schema.graphql"
-1
View File
@@ -1 +0,0 @@
save-prefix ""
+2
View File
@@ -0,0 +1,2 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
+26
View File
@@ -6,6 +6,32 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
### 🎉 New features
- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax))
### 🐛 Bug fixes
### 🧹 Chores
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
- Revert authorization code flow with PKCE for browser-based login. ([#3422](https://github.com/expo/eas-cli/pull/3422) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.3](https://github.com/expo/eas-cli/releases/tag/v18.0.3) - 2026-02-20
### 🎉 New features
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.1](https://github.com/expo/eas-cli/releases/tag/v18.0.1) - 2026-02-12
### 🛠 Breaking changes
- Drop support for Node 18 and Node 19. Require Node 20+. ([#3343](https://github.com/expo/eas-cli/pull/3343) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
+3 -3
View File
@@ -98,8 +98,8 @@ yarn start-allow-unused
### Testing
```bash
yarn lerna run test # Run all tests across packages
yarn lerna run test -- --watch # Watch mode across packages (heavier)
yarn run -T lerna run test # Run all tests across packages
yarn run -T lerna run test -- --watch # Watch mode across packages (heavier)
#
# If you use `yarn test`, remember to pass args through two separators:
# yarn test -- -- --watch
@@ -349,5 +349,5 @@ See `LICENSE` (MIT) and `LICENSE-BUSL` (BUSL-1.1) for details.
## Important Notes
- **Node Version**: Requires Node.js (managed via Mise)
- **Package Manager**: Uses Yarn 1.22.21
- **Package Manager**: Uses Yarn 4.12.0
- **Compilation Target**: CommonJS with Node resolution
+1 -1
View File
@@ -69,7 +69,7 @@ See [`CLAUDE.md`](./CLAUDE.md) for how to set up your environment when making ch
## Testing
From the repository root, run `yarn lerna run test`. For a single package, run `yarn test` in that package directory.
From the repository root, run `yarn run -T lerna run test`. For a single package, run `yarn test` in that package directory.
## Releasing
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.0.1",
"version": "18.0.4",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -1
View File
@@ -1,3 +1,6 @@
[env]
_.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
node = "20.19.4"
yarn = "1.22.22"
+3 -2
View File
@@ -15,7 +15,7 @@
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild && ./scripts/bin/run release",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rm -rf node_modules coverage"
},
@@ -32,5 +32,6 @@
"oxlint-tsgolint": "0.11.1",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
}
},
"packageManager": "[email protected]"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.0.1",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,19 +38,20 @@
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.0.1",
"@expo/steps": "18.0.2",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
@@ -59,6 +60,7 @@
"lodash": "^4.17.21",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
"promise-limit": "^2.7.0",
@@ -17,7 +17,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
- changing the current version under key "cocoapods"
- switching to an image that supports that version under key "image"`,
'https://docs.expo.dev/build-reference/eas-json/'
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
@@ -30,7 +30,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
'https://docs.expo.dev/build-reference/eas-json/'
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
@@ -70,7 +70,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
@@ -85,7 +85,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
@@ -98,7 +98,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
@@ -234,13 +234,19 @@ You are seeing this error because either:
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: new UserFacingError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
),
},
{
@@ -19,6 +19,7 @@ import { createInstallNodeModulesBuildFunction } from './functions/installNodeMo
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
@@ -48,6 +49,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSetUpNpmrcBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createReadIpaInfoBuildFunction, readIpaInfoAsync } from '../readIpaInfo';
const NO_INFO_PLIST_IPA_BASE64 =
'UEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABwAUGF5bG9hZC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwgMDo2BgAAAAYAAAAeABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9SZWFkbWUudHh0VVQJAAPs85Jp7POSaXV4CwABBPUBAAAEFAAAAGhlbGxvClBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABgAAAAAAAAAEADtQQAAAABQYXlsb2FkL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQUIAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwgMDo2BgAAAAYAAAAeABgAAAAAAAEAAACkgZAAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1JlYWRtZS50eHRVVAUAA+zzkml1eAsAAQT1AQAABBQAAABQSwUGAAAAAAMAAwAMAQAA7gAAAAAA';
const MISSING_BUNDLE_VERSION_IPA_BASE64 =
'UEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABwAUGF5bG9hZC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBBQAAAAIAPRcUFz9BfqM2wAAAEQBAAAeABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9JbmZvLnBsaXN0VVQJAAPs85Jp7POSaXV4CwABBPUBAAAEFAAAAGWOUUvDMBSF3/srYt6b6/RFJMtw7QaFooV2go+jybZgm4Tkard/b7IKor7ec+73Hb46jwP5VD5oa5Z0wW4pUaa3Upvjku66bf5AVyLjN+VL0b01G+IGHZA0u3VdFYTmAE/ODQqg7ErS1FXbkcgA2DxTQk+I7hFgmia2Ty3W2zEVAzTeOuXxUkdYHh+YREmjZqb/mhOvUvcoMkL4u7qIYrv+MHJQlVQG9UErzyHdUx7Qx90iapg678ekRBUwyjl8Z38x7cl6fJ2F7bXyD7dgd+z+B8Bh3sPhulZkX1BLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABgAAAAAAAAAEADtQQAAAABQYXlsb2FkL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQUIAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAxQAAAAIAPRcUFz9BfqM2wAAAEQBAAAeABgAAAAAAAEAAACkgZAAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL0luZm8ucGxpc3RVVAUAA+zzkml1eAsAAQT1AQAABBQAAABQSwUGAAAAAAMAAwAMAQAAwwEAAAAA';
const IPA_FIXTURE_SOURCE_PATH = path.join(__dirname, 'fixtures/SmallestAppExample.ipa');
async function writeIpaFileAsync(filePath: string, base64Data: string): Promise<void> {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, base64Data, { encoding: 'base64' });
}
describe(readIpaInfoAsync, () => {
it('reads bundle identifier and versions from fixture ipa', async () => {
await expect(readIpaInfoAsync(IPA_FIXTURE_SOURCE_PATH)).resolves.toEqual({
bundleIdentifier: 'dev.expo.SmallestAppExample',
bundleShortVersion: '1.0',
bundleVersion: '1',
});
});
it('throws when Info.plist cannot be found in IPA', async () => {
const ipaPath = '/tmp/no-info.ipa';
await writeIpaFileAsync(ipaPath, NO_INFO_PLIST_IPA_BASE64);
await expect(readIpaInfoAsync(ipaPath)).rejects.toThrow(
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
);
});
it('throws when required plist fields are missing', async () => {
const ipaPath = '/tmp/missing-bundle-version.ipa';
await writeIpaFileAsync(ipaPath, MISSING_BUNDLE_VERSION_IPA_BASE64);
await expect(readIpaInfoAsync(ipaPath)).rejects.toThrow(
/Failed to read IPA info: .*CFBundleVersion/
);
});
});
describe(createReadIpaInfoBuildFunction, () => {
it('sets build step outputs', async () => {
const readIpaInfo = createReadIpaInfoBuildFunction();
const globalContext = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
const buildStep = readIpaInfo.createBuildStepFromFunctionCall(globalContext, {
callInputs: {
ipa_path: IPA_FIXTURE_SOURCE_PATH,
},
});
await buildStep.executeAsync();
expect(buildStep.outputById.bundle_identifier.value).toBe('dev.expo.SmallestAppExample');
expect(buildStep.outputById.bundle_short_version.value).toBe('1.0');
expect(buildStep.outputById.bundle_version.value).toBe('1');
});
});
@@ -0,0 +1,147 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import fs from 'fs-extra';
import path from 'node:path';
import StreamZip from 'node-stream-zip';
import { z } from 'zod';
import bplistParser from 'bplist-parser';
import plist from 'plist';
export type IpaInfo = {
bundleIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
};
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
export function createReadIpaInfoBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_ipa_info',
name: 'Read IPA Info',
__metricsId: 'eas/read_ipa_info',
inputProviders: [
BuildStepInput.createProvider({
id: 'ipa_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'bundle_identifier',
required: true,
}),
BuildStepOutput.createProvider({
id: 'bundle_short_version',
required: true,
}),
BuildStepOutput.createProvider({
id: 'bundle_version',
required: true,
}),
],
fn: async (stepCtx, { inputs, outputs }) => {
const ipaPathInput = z.string().parse(inputs.ipa_path.value);
const ipaPath = path.resolve(stepCtx.workingDirectory, ipaPathInput);
if (!(await fs.pathExists(ipaPath))) {
throw new UserFacingError(
'EAS_READ_IPA_INFO_FILE_NOT_FOUND',
`IPA file not found: ${ipaPath}`
);
}
const ipaInfo = await readIpaInfoAsync(ipaPath);
outputs.bundle_identifier.set(ipaInfo.bundleIdentifier);
outputs.bundle_short_version.set(ipaInfo.bundleShortVersion);
outputs.bundle_version.set(ipaInfo.bundleVersion);
},
});
}
export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
try {
const infoPlistBuffer = await readInfoPlistBufferFromIpaAsync(ipaPath);
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
const bundleIdentifier = infoPlist.CFBundleIdentifier;
if (typeof bundleIdentifier !== 'string') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist'
);
}
const bundleShortVersion = infoPlist.CFBundleShortVersionString;
if (typeof bundleShortVersion !== 'string') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist'
);
}
const bundleVersion = infoPlist.CFBundleVersion;
if (typeof bundleVersion !== 'string') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist'
);
}
return {
bundleIdentifier,
bundleShortVersion,
bundleVersion,
};
} catch (error) {
if (error instanceof UserFacingError) {
throw error;
}
throw new UserFacingError(
'EAS_READ_IPA_INFO_FAILED',
`Failed to read IPA info: ${(error as Error).message}`,
{ cause: error }
);
}
}
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
if (isBinaryPlist) {
const parsedBinaryPlists = bplistParser.parseBuffer(data);
const parsedBinaryPlist = parsedBinaryPlists[0];
if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_BINARY_PLIST',
'Invalid binary plist in IPA'
);
}
return parsedBinaryPlist as Record<string, unknown>;
}
return plist.parse(data.toString('utf8')) as Record<string, unknown>;
}
async function readInfoPlistBufferFromIpaAsync(ipaPath: string): Promise<Buffer> {
const zip = new StreamZip.async({ file: ipaPath });
try {
const entries = Object.values(await zip.entries());
const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
if (!infoPlistEntry) {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND',
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
);
}
return await zip.entryData(infoPlistEntry.name);
} finally {
await zip.close();
}
}
@@ -234,7 +234,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
while (Date.now() - waitingForBuildStartedAt < 10 * 60 * 1000 /* 10 minutes */) {
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.0.1",
"version": "18.0.3",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -15,9 +15,7 @@
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/create-eas-build-function"
},
"bin": {
"create-eas-build-function": "./build/index.js"
},
"bin": "./build/index.js",
"files": [
"build",
"templates"
@@ -34,10 +32,10 @@
"build:dev": "ncc build ./src/index.ts -o build/",
"build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register",
"typecheck": "tsc",
"clean": "rimraf ./build/ *.tsbuildinfo"
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.0.1"
"@expo/steps": "18.0.2"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
@@ -49,7 +47,9 @@
"chalk": "4.1.2",
"fs-extra": "^11.2.0",
"ora": "^6.3.1",
"oxlint": "1.43.0",
"prompts": "^2.4.2",
"rimraf": "3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"update-check": "^1.5.4"
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "18.0.1",
"version": "18.0.2",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -48,6 +48,7 @@
"@types/babel__code-frame": "7.0.3",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"jest": "29.7.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.0.1",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -340,6 +340,60 @@ describe('Android.JobSchema', () => {
expect(error).toBeFalsy();
});
test('can set hooks with arbitrary names', () => {
const job = {
mode: BuildMode.BUILD,
type: Workflow.GENERIC,
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://expo.dev/builds/123',
},
projectRootDirectory: '.',
hooks: {
before_install_node_modules: [
{
id: 'before-install',
run: 'echo before install',
shell: 'sh',
},
],
my_custom_hook: [],
},
secrets,
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Android.JobSchema.validate(job, joiOptions);
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('can set hooks in custom mode', () => {
const customBuildJob = {
mode: BuildMode.CUSTOM,
type: Workflow.UNKNOWN,
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://expo.dev/builds/123',
},
projectRootDirectory: '.',
customBuildConfig: {
path: 'production.android.yml',
},
hooks: {
before_install_node_modules: [],
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Android.JobSchema.validate(customBuildJob, joiOptions);
expect(value).toMatchObject(customBuildJob);
expect(error).toBeFalsy();
});
test('can set github trigger options', () => {
const job = {
mode: BuildMode.BUILD,
@@ -0,0 +1,30 @@
import { UserFacingError } from '../errors';
describe(UserFacingError, () => {
it('supports docsUrl in options', () => {
const error = new UserFacingError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBeUndefined();
});
it('supports cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', { cause });
expect(error.cause).toBe(cause);
});
it('supports docsUrl and cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
cause,
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBe(cause);
});
});
@@ -92,6 +92,107 @@ describe('Generic.JobZ', () => {
expect(Generic.JobZ.parse(job)).toEqual(job);
});
it('accepts hooks with arbitrary names', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
hooks: {
before_install_node_modules: [
{
id: 'before-install',
run: 'echo before install',
shell: 'sh',
},
],
my_custom_hook: [],
},
steps: [
{
id: 'step1',
name: 'Step 1',
run: 'echo Hello, world!',
shell: 'sh',
},
],
secrets: {
robotAccessToken: 'token',
environmentSecrets: [
{
name: 'secret-name',
value: 'secret-value',
type: EnvironmentSecretType.STRING,
},
],
},
expoDevUrl: 'https://expo.dev/accounts/name/builds/id',
builderEnvironment: {
image: 'macos-sonoma-14.5-xcode-15.4',
node: '20.15.1',
corepack: false,
env: {
KEY1: 'value1',
},
},
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
appId: randomUUID(),
initiatingUserId: randomUUID(),
};
expect(Generic.JobZ.parse(job)).toEqual(job);
});
it('errors when hook step definition is invalid', () => {
const job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
hooks: {
before_install_node_modules: [
{
id: 'missing-run-or-uses',
},
],
},
steps: [
{
id: 'step1',
name: 'Step 1',
run: 'echo Hello, world!',
shell: 'sh',
},
],
secrets: {
robotAccessToken: 'token',
environmentSecrets: [
{
name: 'secret-name',
value: 'secret-value',
type: EnvironmentSecretType.STRING,
},
],
},
expoDevUrl: 'https://expo.dev/accounts/name/builds/id',
builderEnvironment: {
image: 'macos-sonoma-14.5-xcode-15.4',
node: '20.15.1',
corepack: false,
env: {
KEY1: 'value1',
},
},
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
appId: randomUUID(),
initiatingUserId: randomUUID(),
};
expect(() => Generic.JobZ.parse(job)).toThrow(ZodError);
});
it('errors when steps are not provided', () => {
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
projectArchive: {
@@ -84,6 +84,66 @@ describe('Ios.JobSchema', () => {
expect(error).toBeFalsy();
});
test('valid generic job with hooks', () => {
const genericJob = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
hooks: {
before_install_node_modules: [
{
id: 'before-install',
run: 'echo before install',
shell: 'sh',
},
],
my_custom_hook: [],
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
expect(value).toMatchObject(genericJob);
expect(error).toBeFalsy();
});
test('valid resign job with hooks', () => {
const genericJob = {
mode: BuildMode.RESIGN,
secrets: {
buildCredentials,
},
type: Workflow.UNKNOWN,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.NONE,
},
resign: {
applicationArchiveSource: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000/a.ipa',
},
},
hooks: {
before_install_node_modules: [],
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
expect(value).toMatchObject(genericJob);
expect(error).toBeFalsy();
});
test('valid custom build job with metadataLocation', () => {
const customBuildJob = {
mode: BuildMode.CUSTOM,
+4
View File
@@ -13,6 +13,8 @@ import {
EnvSchema,
EnvironmentSecret,
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -106,6 +108,7 @@ export interface Job {
customBuildConfig?: {
path: string;
};
hooks?: Hooks;
steps?: Step[];
outputs?: Record<string, string>;
@@ -172,6 +175,7 @@ export const JobSchema = Joi.object({
buildType: Joi.string().valid(...Object.values(BuildType)),
username: Joi.string(),
hooks: HooksSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
+16 -1
View File
@@ -2,7 +2,7 @@ import Joi from 'joi';
import { z } from 'zod';
import { BuildPhase, BuildPhaseResult } from './logs';
import { validateSteps } from './step';
import { Step, StepZ, validateSteps } from './step';
export enum BuildMode {
BUILD = 'build',
@@ -142,6 +142,21 @@ export const EnvironmentSecretZ = z.object({
type: z.nativeEnum(EnvironmentSecretType),
});
export type Hooks = Record<string, Step[]>;
export const HooksSchema = Joi.object().pattern(
Joi.string(),
Joi.array()
.items(Joi.any())
.required()
.custom(steps => {
if (steps.length === 0) {
return steps;
}
return validateSteps(steps);
}, 'steps validation')
);
export const HooksZ = z.record(z.string(), z.array(StepZ));
export interface Cache {
disabled: boolean;
clear: boolean;
+5 -2
View File
@@ -54,12 +54,15 @@ export class BuildError extends Error {
}
export class UserFacingError extends Error {
public docsUrl?: string;
constructor(
public errorCode: string,
public message: string,
public docsUrl?: string
options?: { docsUrl?: string; cause?: unknown }
) {
super(message);
super(message, { cause: options?.cause });
this.docsUrl = options?.docsUrl;
}
}
+2
View File
@@ -5,6 +5,7 @@ import {
ArchiveSourceSchemaZ,
BuildTrigger,
EnvironmentSecretZ,
HooksZ,
StaticWorkflowInterpolationContextZ,
} from './common';
import { StepZ } from './step';
@@ -45,6 +46,7 @@ export namespace Generic {
initiatingUserId: z.string(),
appId: z.string(),
hooks: HooksZ.optional(),
steps: z.array(StepZ).min(1),
outputs: z.record(z.string(), z.string()).optional(),
});
+1
View File
@@ -11,6 +11,7 @@ export {
Env,
EnvironmentSecret,
EnvironmentSecretType,
Hooks,
Workflow,
Platform,
Cache,
+4
View File
@@ -13,6 +13,8 @@ import {
EnvSchema,
EnvironmentSecret,
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -120,6 +122,7 @@ export interface Job {
customBuildConfig?: {
path: string;
};
hooks?: Hooks;
steps?: Step[];
outputs?: Record<string, string>;
@@ -206,6 +209,7 @@ export const JobSchema = Joi.object({
applicationArchivePath: Joi.string(),
username: Joi.string(),
hooks: HooksSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
+88 -88
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,7 @@ 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/v18.0.1/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -425,7 +425,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -444,7 +444,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -461,7 +461,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -500,7 +500,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -521,7 +521,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -556,7 +556,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -608,7 +608,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -637,7 +637,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -663,7 +663,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -717,7 +717,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -736,7 +736,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -755,7 +755,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -772,7 +772,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -793,7 +793,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -814,7 +814,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -836,7 +836,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -856,7 +856,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -878,7 +878,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -900,7 +900,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -933,7 +933,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -956,7 +956,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -977,7 +977,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/v18.0.1/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -994,7 +994,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1012,7 +1012,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1040,7 +1040,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1065,7 +1065,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1086,7 +1086,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1107,7 +1107,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1144,7 +1144,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1164,7 +1164,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1185,7 +1185,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1206,7 +1206,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1220,7 +1220,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1234,7 +1234,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1264,7 +1264,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1289,7 +1289,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1311,7 +1311,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1336,7 +1336,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1362,7 +1362,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1384,7 +1384,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1406,7 +1406,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1438,7 +1438,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1482,7 +1482,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1512,7 +1512,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1621,7 +1621,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1638,7 +1638,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1655,7 +1655,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1706,7 +1706,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/v18.0.1/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1720,7 +1720,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1743,7 +1743,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1766,7 +1766,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.1/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1784,7 +1784,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1819,7 +1819,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1859,7 +1859,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1879,7 +1879,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1900,7 +1900,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1924,7 +1924,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1949,7 +1949,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1985,7 +1985,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2012,7 +2012,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2040,7 +2040,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2061,7 +2061,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2081,7 +2081,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2102,7 +2102,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/v18.0.1/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2123,7 +2123,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2143,7 +2143,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2161,7 +2161,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2183,7 +2183,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2200,7 +2200,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2320,7 +2320,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2340,7 +2340,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2364,7 +2364,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2404,7 +2404,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2429,7 +2429,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2457,7 +2457,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.0.1/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2477,7 +2477,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2499,5 +2499,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/v18.0.1/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/v18.0.4/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.0.1",
"version": "18.0.4",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -36,7 +36,7 @@
"typecheck": "tsc",
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"version": "yarn run -T 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 *.tsbuildinfo",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.0.1",
"@expo/eas-json": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/eas-json": "18.0.2",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.0.1",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.0.1",
"@expo/steps": "18.0.2",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
@@ -158,6 +158,7 @@
"axios": "1.11.0",
"eslint-plugin-graphql": "4.0.0",
"express": "4.20.0",
"jest": "29.7.0",
"memfs": "3.4.13",
"mockdate": "3.0.5",
"nock": "13.4.0",
@@ -202,6 +203,9 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
+1 -1
View File
@@ -2,6 +2,6 @@ const spawn = require('@expo/spawn-async');
(async () => {
if (!process.env.CLI_SIZE_CHECK) {
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
await spawn('yarn', ['run', '-T', 'oclif', 'manifest'], { stdio: 'inherit' });
}
})();
+1 -1
View File
@@ -27,7 +27,7 @@ if cmp -s graphql.schema.json graphql.schema.json.bak; then
else
echo "GraphQL schema has changed on the server."
echo "Building eas-cli and its dependencies"
yarn lerna run build --scope eas-cli --include-dependencies
yarn run -T lerna run build --scope eas-cli --include-dependencies
# If there are no errors, then the schema is backwards compatible
exit 0
fi
@@ -66,7 +66,7 @@ describe(EasCommand.name, () => {
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
expect(sessionManagerSpy).toBeCalledTimes(1);
}, 30_000);
}, 60_000);
it('initializes analytics', async () => {
const TestEasCommand = createTestEasCommand();
+1 -1
View File
@@ -67,7 +67,7 @@ export const EasJsonOnlyFlag = {
export const EasUpdateEnvironmentFlag = {
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development"',
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development". Required for projects using Expo SDK 55 or greater.',
required: false,
default: undefined,
}),
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
description?: string;
}): OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
+610
View File
@@ -0,0 +1,610 @@
import { App, User, UserRole } from '@expo/apple-utils';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { Analytics } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { CredentialsContext } from '../credentials/context';
import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils';
import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils';
import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey';
import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials';
import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey';
import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists';
import { Target } from '../credentials/ios/types';
import {
WorkflowJobStatus,
WorkflowProjectSourceType,
WorkflowRunStatus,
} from '../graphql/generated';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
// Expo Go release info - update when releasing a new version
const EXPO_GO_SDK_VERSION = '55';
const EXPO_GO_APP_VERSION = '55.0.11';
const EXPO_GO_BUILD_NUMBER = '1017799';
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
// Create or get TestFlight group
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
const groups = await ascApp.getBetaGroupsAsync({
query: { includes: ['betaTesters'] },
});
group = groups.find(
g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME
);
if (!group) {
group = await ascApp.createBetaGroupAsync({
name: TESTFLIGHT_GROUP_NAME,
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
}
break;
} catch (error: any) {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await new Promise(resolve => setTimeout(resolve, 10000));
continue;
}
}
throw error;
}
}
if (!group) {
throw new Error('Failed to create TestFlight group');
}
const users = await User.getAsync(ascApp.context);
const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN));
const existingEmails = new Set(
group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? []
);
const newTesters = admins
.filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase()))
.map(u => ({
email: u.attributes.email!,
firstName: u.attributes.firstName ?? '',
lastName: u.attributes.lastName ?? '',
}));
if (newTesters.length > 0) {
await group.createBulkBetaTesterAssignmentsAsync(newTesters);
}
}
/* eslint-disable no-console */
async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
let capturedOutput = '';
const capture = (chunk: any): boolean => {
if (typeof chunk === 'string') {
capturedOutput += chunk;
}
return true;
};
// Only suppress stdout, not stderr — ora writes spinner frames to stderr and
// patching it would freeze the spinner animation during suppressed async work.
process.stdout.write = capture as any;
console.log = () => {};
console.error = () => {};
console.warn = () => {};
try {
return await fn();
} catch (error) {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (capturedOutput) {
originalConsoleLog(capturedOutput);
}
throw error;
} finally {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
}
}
/* eslint-enable no-console */
export default class Go extends EasCommand {
static override description = 'Create a custom Expo Go and submit to TestFlight';
static override hidden = true;
static override flags = {
'bundle-id': Flags.string({
description: 'iOS bundle identifier (auto-generated if not provided)',
required: false,
}),
name: Flags.string({
description: 'App name',
default: 'My Expo Go',
}),
credentials: Flags.boolean({
description: 'Interactively select credentials (default: auto-select)',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
Log.log(
chalk.bold(
`Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/personal-expo-go')}`
)
);
const { flags } = await this.parse(Go);
const spinner = ora('Logging in to Expo...').start();
const {
loggedIn: { actor, graphqlClient },
analytics,
} = await this.getContextAsync(Go, {
nonInteractive: false,
});
spinner.succeed(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const appName = flags.name ?? 'My Expo Go';
const slug = bundleId.split('.').pop() || 'my-expo-go';
const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`);
await fs.emptyDir(projectDir);
const originalCwd = process.cwd();
process.chdir(projectDir);
const setupSpinner = ora('Creating project...').start();
// Step 1: Create project files and initialize git (silently)
try {
await withSuppressedOutputAsync(async () => {
await this.createProjectFilesAsync(projectDir, bundleId, appName);
await this.initGitRepoAsync(projectDir);
});
const vcsClient = resolveVcsClient();
// Step 2: Create/link EAS project (silently)
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId)
);
// Step 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
projectDir,
projectId,
bundleId,
appName,
graphqlClient,
actor,
analytics,
vcsClient,
flags.credentials,
() => {
setupSpinner.stop();
Log.markFreshLine();
}
);
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectDir,
projectId,
actor,
vcsClient
);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Workflow failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Workflow was canceled');
}
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
Log.newLine();
Log.succeed(
`Done! Your custom Expo Go has been submitted to TestFlight. ${learnMore(
`https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight`,
{ learnMoreMessage: 'Open it on App Store Connect' }
)}`
);
Log.log(
`App Store processing may take several minutes to complete. ${learnMore(
'https://expo.fyi/personal-expo-go',
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
)}`
);
await fs.remove(projectDir);
} catch (error) {
Log.gray(`Project files preserved for debugging: ${projectDir}`);
throw error;
} finally {
process.chdir(originalCwd);
}
}
private generateBundleId(actor: Actor): string {
const username = actor.accounts[0].name;
// Sanitize username for bundle ID: only alphanumeric and hyphens allowed
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
// Deterministic bundle ID per user + SDK version (reuses same ASC app)
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
}
private async createProjectFilesAsync(
projectDir: string,
bundleId: string,
appName: string
): Promise<void> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: {
usesNonExemptEncryption: false,
},
},
extra: {
eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
},
};
const easJson = {
cli: {
version: '>= 5.0.0',
},
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: {},
},
},
};
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: {
expo: '~54.0.0',
},
};
await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 });
await spawnAsync('npm', ['install'], { cwd: projectDir });
}
private async initGitRepoAsync(projectDir: string): Promise<void> {
await spawnAsync('git', ['init'], { cwd: projectDir });
await spawnAsync('git', ['add', '.'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir });
}
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
bundleId: string
): Promise<string> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const account = actor.accounts[0];
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
account.name,
slug
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
const projectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
projectDir: string,
projectId: string,
bundleId: string,
appName: string,
graphqlClient: ExpoGraphqlClient,
actor: Actor,
analytics: Analytics,
vcsClient: VcsClient,
customizeCreds: boolean,
onBeforeAppleAuth?: () => void
): Promise<App> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive: false,
autoAcceptCredentialReuse: !customizeCreds,
projectDir,
user: actor,
graphqlClient,
analytics,
vcsClient,
});
onBeforeAppleAuth?.();
const userAuthCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync();
const app = await getAppFromContextAsync(credentialsCtx);
const targets: Target[] = [
{
targetName: exp.slug,
bundleIdentifier: bundleId,
entitlements: {},
},
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
parentBundleIdentifier: bundleId,
entitlements: {},
},
];
const ascApp = await withSuppressedOutputAsync(async () => {
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const ascAppResult = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
const easJsonPath = path.join(projectDir, 'eas.json');
const easJson = await fs.readJson(easJsonPath);
easJson.submit = easJson.submit || {};
easJson.submit.production = easJson.submit.production || {};
easJson.submit.production.ios = easJson.submit.production.ios || {};
easJson.submit.production.ios.ascAppId = ascAppResult.id;
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir });
return ascAppResult;
});
// Set up push notifications (outside suppressed block so prompts are visible)
const appLookupParamsForPushKey = { ...app, bundleIdentifier: bundleId };
const setupPushKeyAction = new SetUpPushKey(appLookupParamsForPushKey);
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(credentialsCtx);
if (!isPushKeySetup) {
if (customizeCreds) {
const wantsPushNotifications = await confirmAsync({
message: 'Would you like to set up Push Notifications for your app?',
initial: true,
});
if (wantsPushNotifications) {
await setupPushKeyAction.runAsync(credentialsCtx);
}
} else {
await setupPushKeyAction.runAsync(credentialsCtx);
}
}
return ascApp;
}
private async runWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
vcsClient: VcsClient
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = actor.accounts[0];
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
graphqlClient,
{
appId: projectId,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
projectRootDirectory: '.',
},
}
);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`;
return { workflowUrl, workflowRunId };
}
private async monitorWorkflowJobsAsync(
graphqlClient: ExpoGraphqlClient,
workflowRunId: string
): Promise<WorkflowRunStatus> {
const buildSpinner = ora('Building Expo Go').start();
let submitSpinner: ReturnType<typeof ora> | null = null;
let buildCompleted = false;
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go');
const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight');
if (!buildCompleted) {
if (repackJob?.status === WorkflowJobStatus.Success) {
buildSpinner.succeed('Built Expo Go');
buildCompleted = true;
} else if (
repackJob?.status === WorkflowJobStatus.Failure ||
repackJob?.status === WorkflowJobStatus.Canceled
) {
buildSpinner.fail('Build failed');
return WorkflowRunStatus.Failure;
}
}
if (buildCompleted && submitSpinner === null && submitJob) {
submitSpinner = ora('Submitting to TestFlight').start();
}
if (workflowRun.status === WorkflowRunStatus.Success) {
submitSpinner?.stop();
return WorkflowRunStatus.Success;
} else if (workflowRun.status === WorkflowRunStatus.Failure) {
buildSpinner.stop();
submitSpinner?.fail('Submission failed');
return WorkflowRunStatus.Failure;
} else if (workflowRun.status === WorkflowRunStatus.Canceled) {
buildSpinner.stop();
submitSpinner?.stop();
return WorkflowRunStatus.Canceled;
}
} catch {
failedFetchesCount++;
if (failedFetchesCount > 6) {
buildSpinner.fail();
submitSpinner?.fail();
throw new Error('Failed to fetch the workflow run status 6 times in a row');
}
}
await sleepAsync(10 * 1000);
}
}
}
@@ -0,0 +1,237 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveEventsAsync } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
jest.mock('../../../observe/fetchEvents');
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveEvents, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
function createCommand(argv: string[]): ObserveEvents {
const command = new ObserveEvents(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('uses --days-from-now to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti', '--days-from-now', '7']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--metric',
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days-from-now combined with --start', async () => {
const command = createCommand([
'--metric',
'tti',
'--days-from-now',
'7',
'--start',
'2025-01-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days-from-now combined with --end', async () => {
const command = createCommand([
'--metric',
'tti',
'--days-from-now',
'7',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['--metric', 'tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['--metric', 'tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockFetchObserveEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['--metric', 'tti', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,214 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
return {
...actual,
fetchObserveMetricsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveMetrics, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsAsync.mockResolvedValue(new Map());
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'count']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount']
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'med', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median']
);
});
it('uses --days-from-now to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days-from-now', '7']);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days-from-now combined with --start', async () => {
const command = createCommand(['--days-from-now', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('rejects --days-from-now combined with --end', async () => {
const command = createCommand(['--days-from-now', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('uses default stats when --stat is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount']
);
});
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'avg',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average']
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
describe(validateDateFlag, () => {
it('throws on invalid --start date', () => {
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
'Invalid --start date: "not-a-date"'
);
});
it('throws on invalid --end date', () => {
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
});
it('accepts valid ISO date in --start', () => {
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
});
});
@@ -0,0 +1,133 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AppObservePlatform } from '../../graphql/generated';
import Log from '../../log';
import {
type EventsOrderPreset,
fetchObserveEventsAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { resolveMetricName } from '../../observe/metricNames';
import { validateDateFlag } from '../../observe/fetchMetrics';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
const DEFAULT_DAYS_BACK = 60;
export default class ObserveEvents extends EasCommand {
static override description = 'display individual app performance events ordered by metric value';
static override flags = {
metric: Flags.string({
description:
'Metric to query (full name or alias: tti, ttr, cold_launch, warm_launch, bundle_load)',
required: true,
}),
sort: Flags.enum<EventsOrderPreset>({
description: 'Sort order for events',
options: ['slowest', 'fastest', 'newest', 'oldest'],
default: 'slowest',
}),
platform: Flags.enum<'android' | 'ios'>({
description: 'Filter by platform',
options: ['android', 'ios'],
}),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_EVENTS_LIMIT, limit: 100 }),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days-from-now'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days-from-now'],
}),
'days-from-now': Flags.integer({
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'app-version': Flags.string({
description: 'Filter by app version',
}),
'update-id': Flags.string({
description: 'Filter by EAS update ID',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveEvents);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
if (flags.start) {
validateDateFlag(flags.start, '--start');
}
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
const metricName = resolveMetricName(flags.metric);
const orderBy = resolveOrderBy(flags.sort);
let startTime: string;
let endTime: string;
if (flags['days-from-now']) {
endTime = new Date().toISOString();
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
} else {
endTime = flags.end ?? new Date().toISOString();
startTime =
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
}
const platform = flags.platform
? flags.platform === 'android'
? AppObservePlatform.Android
: AppObservePlatform.Ios
: undefined;
const { events, pageInfo } = await fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
});
if (flags.json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveEventsTable(events, pageInfo));
}
}
}
@@ -0,0 +1,142 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { resolveMetricName } from '../../observe/metricNames';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_METRICS = [
'expo.app_startup.cold_launch_time',
'expo.app_startup.warm_launch_time',
'expo.app_startup.tti',
'expo.app_startup.ttr',
'expo.app_startup.bundle_load_time',
];
const DEFAULT_DAYS_BACK = 60;
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
export default class ObserveMetrics extends EasCommand {
static override description = 'display app performance metrics grouped by app version';
static override flags = {
platform: Flags.enum<'android' | 'ios'>({
description: 'Filter by platform',
options: ['android', 'ios'],
}),
metric: Flags.string({
description:
'Metric name to display (can be specified multiple times). Supports aliases: tti, ttr, cold_launch, warm_launch, bundle_load',
multiple: true,
}),
stat: Flags.string({
description:
'Statistic to display per metric (can be specified multiple times). Options: min, max, med, avg, p80, p90, p99, count',
multiple: true,
}),
start: Flags.string({
description: 'Start of time range for metrics data (ISO date).',
exclusive: ['days-from-now'],
}),
end: Flags.string({
description: 'End of time range for metrics data (ISO date).',
exclusive: ['days-from-now'],
}),
'days-from-now': Flags.integer({
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
if (flags.start) {
validateDateFlag(flags.start, '--start');
}
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
const metricNames = flags.metric?.length
? flags.metric.map(resolveMetricName)
: DEFAULT_METRICS;
let startTime: string;
let endTime: string;
if (flags['days-from-now']) {
endTime = new Date().toISOString();
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
} else {
endTime = flags.end ?? new Date().toISOString();
startTime =
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
}
const platforms: AppPlatform[] = flags.platform
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
: [AppPlatform.Android, AppPlatform.Ios];
const metricsMap = await fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricNames,
platforms,
startTime,
endTime
);
const argumentsStat = flags.stat?.length
? Array.from(new Set(flags.stat.map(resolveStatKey)))
: undefined;
if (flags.json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats));
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats));
}
}
}
@@ -143,6 +143,67 @@ describe(UpdatePublish.name, () => {
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('errors when SDK >= 55 and --environment is not provided', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
mockTestProject({ expoConfig: { sdkVersion: '55.0.0' } });
await expect(new UpdatePublish(flags, commandOptions).run()).rejects.toThrow(
'--environment flag is required for projects using Expo SDK 55 or greater'
);
});
it('does not error when SDK >= 55 and --environment is provided', async () => {
const flags = [
'--non-interactive',
'--branch=branch123',
'--message=abc',
'--environment=production',
];
mockTestProject({ expoConfig: { sdkVersion: '55.0.0' } });
const { platforms, runtimeVersion } = mockTestExport();
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branch: {
id: 'branch123',
name: 'wat',
},
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdatePublish(flags, commandOptions).run();
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('does not error when SDK < 55 and --environment is not provided', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
mockTestProject({ expoConfig: { sdkVersion: '54.0.0' } });
const { platforms, runtimeVersion } = mockTestExport();
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branch: {
id: 'branch123',
name: 'wat',
},
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdatePublish(flags, commandOptions).run();
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('creates a new update with the public expo config', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
@@ -11,6 +11,7 @@ import {
ensureEASUpdateIsConfiguredAsync,
ensureEASUpdateIsConfiguredInEasJsonAsync,
} from '../../update/configure';
import { assertEnvironmentFlagForSdk55OrGreater } from '../../update/utils';
export default class UpdateConfigure extends EasCommand {
static override description = 'configure the project to support EAS Update';
@@ -42,6 +43,11 @@ export default class UpdateConfigure extends EasCommand {
withServerSideEnvironment: flags['environment'] ?? null,
});
assertEnvironmentFlagForSdk55OrGreater({
sdkVersion: exp.sdkVersion,
environment: flags['environment'],
});
Log.log(
'💡 The following process will configure your project to use EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
);
@@ -9,6 +9,7 @@ import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags, EasUpdateEnvironmentFlag } from '../../commandUtils/flags';
import { assertEnvironmentFlagForSdk55OrGreater } from '../../update/utils';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
@@ -249,6 +250,11 @@ export default class UpdatePublish extends EasCommand {
projectDir,
} = await getDynamicPublicProjectConfigAsync();
assertEnvironmentFlagForSdk55OrGreater({
sdkVersion: expPossiblyWithoutEasUpdateConfigured.sdkVersion,
environment,
});
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
@@ -25,6 +25,7 @@ export class CredentialsContext {
public readonly appStore = new AppStoreApi();
public readonly ios = IosGraphqlClient;
public readonly nonInteractive: boolean;
public readonly autoAcceptCredentialReuse: boolean;
public readonly freezeCredentials: boolean = false;
public readonly projectDir: string;
public readonly user: Actor;
@@ -50,6 +51,7 @@ export class CredentialsContext {
analytics: Analytics;
vcsClient: Client;
freezeCredentials?: boolean;
autoAcceptCredentialReuse?: boolean;
env?: Env;
}
) {
@@ -60,6 +62,7 @@ export class CredentialsContext {
this.analytics = options.analytics;
this.vcsClient = options.vcsClient;
this.nonInteractive = options.nonInteractive ?? false;
this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false;
this.projectInfo = options.projectInfo;
this.freezeCredentials = options.freezeCredentials ?? false;
this.usesBroadcastPushNotifications =
@@ -66,6 +66,11 @@ export async function provideOrGenerateAscApiKeyAsync(
throw new Error(`A new App Store Connect API Key cannot be created in non-interactive mode.`);
}
// When auto-accepting credentials, always auto-generate without asking for user input
if (ctx.autoAcceptCredentialReuse) {
return await generateAscApiKeyAsync(ctx, purpose);
}
const userProvided = await promptForAscApiKeyAsync(ctx);
if (!userProvided) {
return await generateAscApiKeyAsync(ctx, purpose);
@@ -14,7 +14,7 @@ import { pushKeySchema } from '../credentials';
import { isPushKeyValidAndTrackedAsync } from '../validators/validatePushKey';
export async function provideOrGeneratePushKeyAsync(ctx: CredentialsContext): Promise<PushKey> {
if (!ctx.nonInteractive) {
if (!ctx.nonInteractive && !ctx.autoAcceptCredentialReuse) {
const userProvided = await promptForPushKeyAsync(ctx);
if (userProvided) {
if (!ctx.appStore.authCtx) {
@@ -65,6 +65,12 @@ export class SetUpAscApiKey {
return await new AssignAscApiKey(this.app).runAsync(ctx, maybeAutoselectedKey, this.purpose);
}
// When auto-accepting credentials and no valid key was found, generate a new one without prompting
if (ctx.autoAcceptCredentialReuse) {
const ascApiKey = await new CreateAscApiKey(this.app.account).runAsync(ctx, this.purpose);
return await new AssignAscApiKey(this.app).runAsync(ctx, ascApiKey, this.purpose);
}
const availableChoices =
keysForAccount.length === 0
? this.choices.filter(choice => choice.value !== SetupAscApiKeyChoice.USE_EXISTING)
@@ -90,9 +96,11 @@ export class SetUpAscApiKey {
return null;
}
const [autoselectedKey] = sortAscApiKeysByUpdatedAtDesc(validKeys);
const useAutoselected = await confirmAsync({
message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`,
});
const useAutoselected =
ctx.autoAcceptCredentialReuse ||
(await confirmAsync({
message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`,
}));
if (useAutoselected) {
Log.log(`Using App Store Connect API Key with ID ${autoselectedKey.keyIdentifier}`);
@@ -117,11 +117,13 @@ export class SetUpDistributionCertificate {
const validDistCerts = await this.getValidDistCertsAsync(ctx);
const autoselectedDistCert = validDistCerts[0];
const useAutoselected = await confirmAsync({
message: `Reuse this distribution certificate?\n${formatDistributionCertificate(
autoselectedDistCert
)}`,
});
const useAutoselected =
ctx.autoAcceptCredentialReuse ||
(await confirmAsync({
message: `Reuse this distribution certificate?\n${formatDistributionCertificate(
autoselectedDistCert
)}`,
}));
if (useAutoselected) {
Log.log(
@@ -142,7 +142,7 @@ export class SetUpProvisioningProfile {
}
const isNonInteractiveOrUserDidConfirmAsync = async (): Promise<boolean> => {
if (ctx.nonInteractive) {
if (ctx.nonInteractive || ctx.autoAcceptCredentialReuse) {
return true;
}
return await confirmAsync({
@@ -63,12 +63,14 @@ export class SetUpPushKey {
if (validPushKeys.length > 0) {
const autoselectedPushKey = validPushKeys[0];
const useAutoselected = await confirmAsync({
message: `Reuse this Push Key?\n${formatPushKey(
autoselectedPushKey,
validPushKeys.map(pushKey => pushKey.keyIdentifier)
)}`,
});
const useAutoselected =
ctx.autoAcceptCredentialReuse ||
(await confirmAsync({
message: `Reuse this Push Key?\n${formatPushKey(
autoselectedPushKey,
validPushKeys.map(pushKey => pushKey.keyIdentifier)
)}`,
}));
if (useAutoselected) {
Log.log(`Using push key with ID ${autoselectedPushKey.keyIdentifier}`);
@@ -77,6 +79,11 @@ export class SetUpPushKey {
}
}
// When auto-accepting and no valid push key was found, generate a new one without prompting
if (ctx.autoAcceptCredentialReuse) {
return await new CreatePushKey(this.app.account).runAsync(ctx);
}
const { action } = await promptAsync({
type: 'select',
name: 'action',
@@ -9,6 +9,7 @@ import {
CreateWorkflowRunFromGitRefMutationVariables,
CreateWorkflowRunMutation,
CreateWorkflowRunMutationVariables,
WorkflowProjectSourceInput,
WorkflowRevisionInput,
WorkflowRunInput,
} from '../generated';
@@ -103,6 +104,40 @@ export namespace WorkflowRunMutation {
return { id: data.workflowRun.createWorkflowRunFromGitRef.id };
}
export async function createExpoGoRepackWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
projectSource,
}: {
appId: string;
projectSource: WorkflowProjectSourceInput;
}
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ workflowRun: { createExpoGoRepackWorkflowRun: { id: string } } },
{ appId: string; projectSource: WorkflowProjectSourceInput }
>(
/* eslint-disable graphql/template-strings */
gql`
mutation CreateExpoGoRepackWorkflowRun($appId: ID!, $projectSource: WorkflowProjectSourceInput!) {
workflowRun {
createExpoGoRepackWorkflowRun(appId: $appId, projectSource: $projectSource) {
id
}
}
}
`,
/* eslint-enable graphql/template-strings */
{ appId, projectSource }
)
.toPromise()
);
return { id: data.workflowRun.createExpoGoRepackWorkflowRun.id };
}
export async function cancelWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
{
@@ -0,0 +1,175 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObservePlatform,
AppObserveTimeSeriesInput,
AppObserveVersionMarker,
PageInfo,
} from '../generated';
type AppObserveTimeSeriesQuery = {
app: {
byId: {
id: string;
observe: {
timeSeries: {
versionMarkers: AppObserveVersionMarker[];
};
};
};
};
};
type AppObserveTimeSeriesQueryVariables = {
appId: string;
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
};
type AppObserveEventsQuery = {
app: {
byId: {
id: string;
observe: {
events: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveEvent;
}>;
};
};
};
};
};
type AppObserveEventsQueryVariables = {
appId: string;
filter?: AppObserveEventsFilter;
first?: number;
after?: string;
orderBy?: AppObserveEventsOrderBy;
};
export const ObserveQuery = {
async timeSeriesVersionMarkersAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
metricName,
platform,
startTime,
endTime,
}: {
appId: string;
metricName: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
}
): Promise<AppObserveVersionMarker[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
gql`
query AppObserveTimeSeries($appId: String!, $input: AppObserveTimeSeriesInput!) {
app {
byId(appId: $appId) {
id
observe {
timeSeries(input: $input) {
versionMarkers {
appVersion
eventCount
firstSeenAt
statistics {
min
max
median
average
p80
p90
p99
}
}
}
}
}
}
}
`,
{
appId,
input: { metricName, platform, startTime, endTime },
}
)
.toPromise()
);
return data.app.byId.observe.timeSeries.versionMarkers;
},
async eventsAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveEventsQueryVariables
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
gql`
query AppObserveEvents(
$appId: String!
$filter: AppObserveEventsFilter
$first: Int
$after: String
$orderBy: AppObserveEventsOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
events(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
metricName
metricValue
timestamp
appVersion
appBuildNumber
deviceModel
deviceOs
deviceOsVersion
countryCode
sessionId
easClientId
}
}
}
}
}
}
}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.events;
return {
events: edges.map(edge => edge.node),
pageInfo,
};
},
};
+9
View File
@@ -26,6 +26,15 @@ export default class Log {
}
}
/**
* Signal that the cursor is already at the start of a fresh line (e.g. after
* a spinner clears itself), so that the next `addNewLineIfNone()` call knows
* it doesn't need to emit an extra blank line.
*/
public static markFreshLine(): void {
Log.isLastLineNewLine = true;
}
public static error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
@@ -0,0 +1,272 @@
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
jest.mock('../../graphql/queries/ObserveQuery');
describe(resolveOrderBy, () => {
it('maps "slowest" to METRIC_VALUE DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('maps "fastest" to METRIC_VALUE ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
it('maps "newest" to TIMESTAMP DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('maps "oldest" to TIMESTAMP ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
});
describe(fetchObserveEventsAsync, () => {
const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync);
const mockGraphqlClient = {} as any;
beforeEach(() => {
mockEventsAsync.mockClear();
});
it('calls ObserveQuery.eventsAsync with assembled filter', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
expect(mockEventsAsync).toHaveBeenCalledTimes(1);
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
appId: 'app-123',
filter: {
metricName: 'expo.app_startup.tti',
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
},
first: 10,
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
});
});
it('includes platform in filter when provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 5,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platform: AppObservePlatform.Ios,
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
platform: AppObservePlatform.Ios,
}),
})
);
});
it('includes appVersion in filter when provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
appVersion: '1.2.0',
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
appVersion: '1.2.0',
}),
})
);
});
it('includes appUpdateId in filter when updateId is provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
updateId: 'update-abc-123',
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
appUpdateId: 'update-abc-123',
}),
})
);
});
it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
const calledFilter = mockEventsAsync.mock.calls[0][1].filter;
expect(calledFilter).not.toHaveProperty('platform');
expect(calledFilter).not.toHaveProperty('appVersion');
expect(calledFilter).not.toHaveProperty('appUpdateId');
});
it('returns events and pageInfo from the query result', async () => {
const mockEvents = [
{
__typename: 'AppObserveEvent' as const,
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cursor-1' },
});
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
expect(result.events).toHaveLength(1);
expect(result.events[0].metricValue).toBe(1.23);
expect(result.pageInfo.hasNextPage).toBe(true);
});
it('passes after cursor to ObserveQuery.eventsAsync', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
after: 'cursor-abc',
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({ first: 10, after: 'cursor-abc' })
);
});
it('omits after when not provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
const calledVars = mockEventsAsync.mock.calls[0][1];
expect(calledVars).not.toHaveProperty('after');
});
});
@@ -0,0 +1,222 @@
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { makeMetricsKey } from '../formatMetrics';
import { fetchObserveMetricsAsync } from '../fetchMetrics';
jest.mock('../../graphql/queries/ObserveQuery');
describe('fetchObserveMetricsAsync', () => {
const mockTimeSeriesMarkers = jest.mocked(ObserveQuery.timeSeriesVersionMarkersAsync);
const mockGraphqlClient = {} as any;
beforeEach(() => {
mockTimeSeriesMarkers.mockClear();
});
it('fans out queries for each metric+platform combo and assembles metricsMap', async () => {
mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName, platform }) => {
if (metricName === 'expo.app_startup.tti' && platform === AppObservePlatform.Ios) {
return [
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '1.0.0',
eventCount: 100,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.01,
max: 0.5,
median: 0.1,
average: 0.15,
p80: 0.3,
p90: 0.4,
p99: 0.48,
},
},
];
}
if (
metricName === 'expo.app_startup.cold_launch_time' &&
platform === AppObservePlatform.Ios
) {
return [
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '1.0.0',
eventCount: 80,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.05,
max: 1.2,
median: 0.3,
average: 0.4,
p80: 0.8,
p90: 1.0,
p99: 1.15,
},
},
];
}
return [];
});
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
[AppPlatform.Ios],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// Should have called the query twice (2 metrics x 1 platform)
expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2);
// Verify metricsMap was assembled correctly
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
expect(metricsMap.has(key)).toBe(true);
const metricsForVersion = metricsMap.get(key)!;
expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({
min: 0.01,
max: 0.5,
median: 0.1,
average: 0.15,
p80: 0.3,
p90: 0.4,
p99: 0.48,
eventCount: 100,
});
expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({
min: 0.05,
max: 1.2,
median: 0.3,
average: 0.4,
p80: 0.8,
p90: 1.0,
p99: 1.15,
eventCount: 80,
});
});
it('fans out across multiple platforms', async () => {
mockTimeSeriesMarkers.mockResolvedValue([]);
await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios, AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// 1 metric x 2 platforms = 2 calls
expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2);
const platforms = mockTimeSeriesMarkers.mock.calls.map(call => call[1].platform);
expect(platforms).toContain(AppObservePlatform.Ios);
expect(platforms).toContain(AppObservePlatform.Android);
});
it('handles partial failures gracefully — successful queries still populate metricsMap', async () => {
mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName }) => {
if (metricName === 'bad.metric') {
throw new Error('Unknown metric');
}
return [
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '2.0.0',
eventCount: 50,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.1,
max: 0.9,
median: 0.5,
average: 0.5,
p80: 0.7,
p90: 0.8,
p99: 0.85,
},
},
];
});
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti', 'bad.metric'],
[AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// Should not throw; the good metric should still be in the map
const key = makeMetricsKey('2.0.0', AppPlatform.Android);
expect(metricsMap.has(key)).toBe(true);
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
min: 0.1,
max: 0.9,
median: 0.5,
average: 0.5,
p80: 0.7,
p90: 0.8,
p99: 0.85,
eventCount: 50,
});
// The bad metric should not be present
expect(metricsMap.get(key)!.has('bad.metric')).toBe(false);
});
it('returns empty map when all queries fail', async () => {
mockTimeSeriesMarkers.mockRejectedValue(new Error('Network error'));
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
expect(metricsMap.size).toBe(0);
});
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
mockTimeSeriesMarkers.mockResolvedValue([
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '3.0.0',
eventCount: 10,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.1,
max: 0.2,
median: 0.15,
average: 0.15,
p80: 0.18,
p90: 0.19,
p99: 0.2,
},
},
]);
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// The key should use AppPlatform (ANDROID), not AppObservePlatform
expect(metricsMap.has('3.0.0:ANDROID')).toBe(true);
expect(metricsMap.has('3.0.0:Android' as any)).toBe(false);
});
});
@@ -0,0 +1,189 @@
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
return {
__typename: 'AppObserveEvent',
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
appIdentifier: 'com.example.app',
appName: 'ExampleApp',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
eventBatchId: 'batch-1',
tags: {},
...overrides,
};
}
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
const withNextPage: PageInfo = {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor-abc',
};
describe(buildObserveEventsTable, () => {
it('formats events into aligned columns', () => {
const events = [
createMockEvent({
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
appVersion: '1.2.0',
appBuildNumber: '42',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
countryCode: 'US',
timestamp: '2025-01-15T10:30:00.000Z',
}),
createMockEvent({
id: 'evt-2',
metricName: 'expo.app_startup.tti',
metricValue: 0.85,
appVersion: '1.1.0',
appBuildNumber: '38',
deviceOs: 'Android',
deviceOsVersion: '14',
deviceModel: 'Pixel 8',
countryCode: 'PL',
timestamp: '2025-01-14T08:15:00.000Z',
}),
];
const output = buildObserveEventsTable(events, noNextPage);
// Escape codes are included, because the header is bolded.
expect(output).toMatchInlineSnapshot(`
"Metric Value App Version Platform Device Country Timestamp 
------ ----- ----------- ---------- --------- ------- ----------------------
TTI 1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM
TTI 0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM"
`);
});
it('returns yellow warning for empty array', () => {
const output = buildObserveEventsTable([], noNextPage);
expect(output).toContain('No events found.');
});
it('uses short names for known metrics', () => {
const events = [
createMockEvent({ metricName: 'expo.app_startup.cold_launch_time' }),
createMockEvent({
id: 'evt-2',
metricName: 'expo.app_startup.warm_launch_time',
}),
createMockEvent({ id: 'evt-3', metricName: 'expo.app_startup.ttr' }),
createMockEvent({
id: 'evt-4',
metricName: 'expo.app_startup.bundle_load_time',
}),
];
const output = buildObserveEventsTable(events, noNextPage);
expect(output).toContain('Cold Launch');
expect(output).toContain('Warm Launch');
expect(output).toContain('TTR');
expect(output).toContain('Bundle Load');
});
it('shows - for null countryCode', () => {
const events = [createMockEvent({ countryCode: null })];
const output = buildObserveEventsTable(events, noNextPage);
// The country column should contain a dash
const lines = output.split('\n');
const dataLine = lines[2]; // header, separator, first data row
expect(dataLine).toContain('-');
});
it('appends next page hint when hasNextPage is true', () => {
const events = [createMockEvent()];
const output = buildObserveEventsTable(events, withNextPage);
expect(output).toContain('Next page: --after cursor-abc');
});
it('does not append next page hint when hasNextPage is false', () => {
const events = [createMockEvent()];
const output = buildObserveEventsTable(events, noNextPage);
expect(output).not.toContain('Next page');
});
});
describe(buildObserveEventsJson, () => {
it('maps event to JSON shape with all relevant fields and pageInfo', () => {
const events = [
createMockEvent({
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
timestamp: '2025-01-15T10:30:00.000Z',
}),
];
const result = buildObserveEventsJson(events, withNextPage);
expect(result).toEqual({
events: [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
timestamp: '2025-01-15T10:30:00.000Z',
},
],
pageInfo: {
hasNextPage: true,
endCursor: 'cursor-abc',
},
});
});
it('handles null optional fields', () => {
const events = [
createMockEvent({
countryCode: null,
sessionId: null,
}),
];
const result = buildObserveEventsJson(events, noNextPage);
expect(result.events[0].countryCode).toBeNull();
expect(result.events[0].sessionId).toBeNull();
});
it('returns empty events array for empty input', () => {
const result = buildObserveEventsJson([], noNextPage);
expect(result.events).toEqual([]);
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
});
});
@@ -0,0 +1,346 @@
import { AppPlatform } from '../../graphql/generated';
import {
ObserveMetricsMap,
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
makeMetricsKey,
resolveStatKey,
type MetricValues,
} from '../formatMetrics';
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'];
function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricValues {
return {
min: 0.1,
median: 0.3,
max: 1.1,
average: 0.5,
p80: 0.8,
p90: 0.9,
p99: 1.0,
eventCount: 100,
...overrides,
};
}
describe(buildObserveMetricsTable, () => {
it('formats metrics grouped by version with metric columns', () => {
const metricsMap: ObserveMetricsMap = new Map();
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
metricsMap.set(
iosKey,
new Map([
[
'expo.app_startup.cold_launch_time',
makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }),
],
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })],
])
);
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
metricsMap.set(
androidKey,
new Map([
[
'expo.app_startup.cold_launch_time',
makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }),
],
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })],
])
);
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
// The header is bolded, thus the escape characters in the snapshot
expect(output).toMatchInlineSnapshot(`
"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count
----------- -------- --------------- ----------------- ------- ---------
1.2.0 iOS 0.35s 110 1.32s 90
1.1.0 Android 0.25s 120 1.12s 100 "
`);
});
it('shows - for versions with no matching metric data', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
metricsMap.set(key, new Map());
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
expect(output).toMatchInlineSnapshot(`
"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count
----------- -------- --------------- ----------------- ------- ---------
2.0.0 iOS - - - - "
`);
});
it('returns message when no metrics data found', () => {
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
expect(output).toMatchInlineSnapshot(`"No metrics data found."`);
});
});
describe(buildObserveMetricsJson, () => {
it('produces JSON with all stats per metric', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })],
])
);
const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], DEFAULT_STATS_JSON);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
appVersion: '1.0.0',
platform: AppPlatform.Ios,
metrics: {
'expo.app_startup.tti': {
min: 0.1,
median: 0.12,
max: 1.1,
average: 0.5,
p80: 0.8,
p90: 0.9,
p99: 1.0,
eventCount: 90,
},
},
});
});
it('produces null values when no observe data matches for a metric', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
metricsMap.set(key, new Map());
const result = buildObserveMetricsJson(
metricsMap,
['expo.app_startup.tti'],
DEFAULT_STATS_JSON
);
expect(result[0].metrics).toEqual({
'expo.app_startup.tti': {
min: null,
median: null,
max: null,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: null,
},
});
});
});
describe(makeMetricsKey, () => {
it('creates a key from version and platform', () => {
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
});
});
describe(resolveStatKey, () => {
it('resolves canonical stat names', () => {
expect(resolveStatKey('min')).toBe('min');
expect(resolveStatKey('max')).toBe('max');
expect(resolveStatKey('median')).toBe('median');
expect(resolveStatKey('average')).toBe('average');
expect(resolveStatKey('p80')).toBe('p80');
expect(resolveStatKey('p90')).toBe('p90');
expect(resolveStatKey('p99')).toBe('p99');
expect(resolveStatKey('eventCount')).toBe('eventCount');
});
it('resolves short aliases', () => {
expect(resolveStatKey('med')).toBe('median');
expect(resolveStatKey('avg')).toBe('average');
expect(resolveStatKey('count')).toBe('eventCount');
expect(resolveStatKey('event_count')).toBe('eventCount');
});
it('throws on unknown stat', () => {
expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"');
});
});
describe('DEFAULT_STATS_TABLE', () => {
it('defaults to median, eventCount', () => {
expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']);
});
});
describe('DEFAULT_STATS_JSON', () => {
it('includes all stats', () => {
expect(DEFAULT_STATS_JSON).toEqual([
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
]);
});
});
describe('custom stats parameter', () => {
it('table renders only selected stats', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.01,
median: 0.1,
max: 0.5,
average: null,
p80: null,
p90: null,
p99: 0.9,
eventCount: 42,
},
],
])
);
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], [
'p99',
'eventCount',
]);
expect(output).toContain('TTI P99');
expect(output).toContain('TTI Count');
expect(output).toContain('0.90s');
expect(output).toContain('42');
expect(output).not.toContain('TTI Min');
expect(output).not.toContain('TTI Med');
expect(output).not.toContain('TTI Max');
});
it("table formats eventCount as integer without 's' suffix", () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.01,
median: 0.1,
max: 0.5,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: 100,
},
],
])
);
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']);
expect(output).toContain('100');
expect(output).not.toContain('100s');
expect(output).not.toContain('100.00s');
});
it('JSON includes only selected stats', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.01,
median: 0.1,
max: 0.5,
average: 0.15,
p80: 0.3,
p90: 0.4,
p99: 0.9,
eventCount: 42,
},
],
])
);
const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], [
'p90',
'eventCount',
]);
expect(result[0].metrics['expo.app_startup.tti']).toEqual({
p90: 0.4,
eventCount: 42,
});
});
it('JSON uses default stats when not specified', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.02,
median: 0.1,
max: 0.4,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: null,
},
],
])
);
const result = buildObserveMetricsJson(
metricsMap,
['expo.app_startup.tti'],
DEFAULT_STATS_JSON
);
expect(result[0].metrics['expo.app_startup.tti']).toEqual({
min: 0.02,
median: 0.1,
max: 0.4,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: null,
});
});
});
@@ -0,0 +1,52 @@
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
describe(resolveMetricName, () => {
it('resolves short alias "tti" to full metric name', () => {
expect(resolveMetricName('tti')).toBe('expo.app_startup.tti');
});
it('resolves short alias "ttr" to full metric name', () => {
expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr');
});
it('resolves short alias "cold_launch" to full metric name', () => {
expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time');
});
it('resolves short alias "warm_launch" to full metric name', () => {
expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time');
});
it('resolves short alias "bundle_load" to full metric name', () => {
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
});
it('passes through full metric names unchanged', () => {
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
'expo.app_startup.cold_launch_time'
);
});
it('throws on unknown alias', () => {
expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"');
});
it('passes through dot-containing custom metric names', () => {
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
});
});
describe(getMetricDisplayName, () => {
it('returns short display name for known metrics', () => {
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
});
it('returns the full metric name for unknown metrics', () => {
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
});
});
@@ -0,0 +1,78 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
export type EventsOrderPreset = 'slowest' | 'fastest' | 'newest' | 'oldest';
export function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrderBy {
switch (preset) {
case 'slowest':
return {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
};
case 'fastest':
return {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
};
case 'newest':
return {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
};
case 'oldest':
return {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
};
}
}
interface FetchObserveEventsOptions {
metricName: string;
orderBy: AppObserveEventsOrderBy;
limit: number;
after?: string;
startTime: string;
endTime: string;
platform?: AppObservePlatform;
appVersion?: string;
updateId?: string;
}
interface FetchObserveEventsResult {
events: AppObserveEvent[];
pageInfo: PageInfo;
}
export async function fetchObserveEventsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchObserveEventsOptions
): Promise<FetchObserveEventsResult> {
const filter: AppObserveEventsFilter = {
metricName: options.metricName,
startTime: options.startTime,
endTime: options.endTime,
...(options.platform && { platform: options.platform }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.updateId && { appUpdateId: options.updateId }),
};
return await ObserveQuery.eventsAsync(graphqlClient, {
appId,
filter,
first: options.limit,
...(options.after && { after: options.after }),
orderBy: options.orderBy,
});
}
@@ -0,0 +1,99 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics';
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
const observePlatformToAppPlatform: Record<AppObservePlatform, AppPlatform> = {
[AppObservePlatform.Android]: AppPlatform.Android,
[AppObservePlatform.Ios]: AppPlatform.Ios,
};
interface ObserveQueryResult {
metricName: string;
platform: AppObservePlatform;
markers: AppObserveVersionMarker[];
}
export function validateDateFlag(value: string, flagName: string): void {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new EasCommandError(
`Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).`
);
}
}
export async function fetchObserveMetricsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
metricNames: string[],
platforms: AppPlatform[],
startTime: string,
endTime: string
): Promise<ObserveMetricsMap> {
const observeQueries: Promise<ObserveQueryResult | null>[] = [];
for (const metricName of metricNames) {
for (const appPlatform of platforms) {
const observePlatform = appPlatformToObservePlatform[appPlatform];
observeQueries.push(
ObserveQuery.timeSeriesVersionMarkersAsync(graphqlClient, {
appId,
metricName,
platform: observePlatform,
startTime,
endTime,
})
.then(markers => ({
metricName,
platform: observePlatform,
markers,
}))
.catch(error => {
Log.warn(
`Failed to fetch observe data for metric "${metricName}" on ${observePlatform}: ${error.message}`
);
return null;
})
);
}
}
const observeResults = await Promise.all(observeQueries);
const metricsMap: ObserveMetricsMap = new Map();
for (const result of observeResults) {
if (!result) {
continue;
}
const { metricName, platform, markers } = result;
const appPlatform = observePlatformToAppPlatform[platform];
for (const marker of markers) {
const key = makeMetricsKey(marker.appVersion, appPlatform);
if (!metricsMap.has(key)) {
metricsMap.set(key, new Map());
}
const values: MetricValues = {
min: marker.statistics.min,
max: marker.statistics.max,
median: marker.statistics.median,
average: marker.statistics.average,
p80: marker.statistics.p80,
p90: marker.statistics.p90,
p99: marker.statistics.p99,
eventCount: marker.eventCount,
};
metricsMap.get(key)!.set(metricName, values);
}
}
return metricsMap;
}
@@ -0,0 +1,88 @@
import chalk from 'chalk';
import { AppObserveEvent, PageInfo } from '../graphql/generated';
import { getMetricDisplayName } from './metricNames';
function formatTimestamp(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export interface ObserveEventJson {
id: string;
metricName: string;
metricValue: number;
appVersion: string;
appBuildNumber: string;
deviceModel: string;
deviceOs: string;
deviceOsVersion: string;
countryCode: string | null;
sessionId: string | null;
easClientId: string;
timestamp: string;
}
export function buildObserveEventsTable(events: AppObserveEvent[], pageInfo: PageInfo): string {
if (events.length === 0) {
return chalk.yellow('No events found.');
}
const headers = ['Metric', 'Value', 'App Version', 'Platform', 'Device', 'Country', 'Timestamp'];
const rows: string[][] = events.map(event => [
getMetricDisplayName(event.metricName),
`${event.metricValue.toFixed(2)}s`,
`${event.appVersion} (${event.appBuildNumber})`,
`${event.deviceOs} ${event.deviceOsVersion}`,
event.deviceModel,
event.countryCode ?? '-',
formatTimestamp(event.timestamp),
]);
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
if (pageInfo.hasNextPage && pageInfo.endCursor) {
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
}
return lines.join('\n');
}
export function buildObserveEventsJson(
events: AppObserveEvent[],
pageInfo: PageInfo
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
return {
events: events.map(event => ({
id: event.id,
metricName: event.metricName,
metricValue: event.metricValue,
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
deviceModel: event.deviceModel,
deviceOs: event.deviceOs,
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
sessionId: event.sessionId ?? null,
easClientId: event.easClientId,
timestamp: event.timestamp,
})),
pageInfo: {
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor ?? null,
},
};
}
@@ -0,0 +1,165 @@
import chalk from 'chalk';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import { getMetricDisplayName } from './metricNames';
export type StatisticKey =
| 'min'
| 'max'
| 'median'
| 'average'
| 'p80'
| 'p90'
| 'p99'
| 'eventCount';
export const STAT_ALIASES: Record<string, StatisticKey> = {
min: 'min',
max: 'max',
med: 'median',
median: 'median',
avg: 'average',
average: 'average',
p80: 'p80',
p90: 'p90',
p99: 'p99',
count: 'eventCount',
event_count: 'eventCount',
eventCount: 'eventCount',
};
export const STAT_DISPLAY_NAMES: Record<StatisticKey, string> = {
min: 'Min',
max: 'Max',
median: 'Med',
average: 'Avg',
p80: 'P80',
p90: 'P90',
p99: 'P99',
eventCount: 'Count',
};
export function resolveStatKey(input: string): StatisticKey {
const resolved = STAT_ALIASES[input];
if (resolved) {
return resolved;
}
throw new EasCommandError(
`Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}`
);
}
function formatStatValue(stat: StatisticKey, value: number | null | undefined): string {
if (value == null) {
return '-';
}
if (stat === 'eventCount') {
return String(value);
}
return `${value.toFixed(2)}s`;
}
export interface MetricValues {
min: number | null | undefined;
max: number | null | undefined;
median: number | null | undefined;
average: number | null | undefined;
p80: number | null | undefined;
p90: number | null | undefined;
p99: number | null | undefined;
eventCount: number | null | undefined;
}
type ObserveMetricsKey = `${string}:${AppPlatform}`;
export type ObserveMetricsMap = Map<ObserveMetricsKey, Map<string, MetricValues>>;
export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey {
return `${appVersion}:${platform}`;
}
function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } {
const lastColon = key.lastIndexOf(':');
return {
appVersion: key.slice(0, lastColon),
platform: key.slice(lastColon + 1) as AppPlatform,
};
}
export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
export interface ObserveMetricsVersionResult {
appVersion: string;
platform: AppPlatform;
metrics: Record<string, MetricValuesJson>;
}
export function buildObserveMetricsJson(
metricsMap: ObserveMetricsMap,
metricNames: string[],
stats: StatisticKey[]
): ObserveMetricsVersionResult[] {
const results: ObserveMetricsVersionResult[] = [];
for (const [key, versionMetrics] of metricsMap) {
const { appVersion, platform } = parseMetricsKey(key);
const metrics: Record<string, MetricValuesJson> = {};
for (const metricName of metricNames) {
const values = versionMetrics.get(metricName);
const statValues: MetricValuesJson = {};
for (const stat of stats) {
statValues[stat] = values?.[stat] ?? null;
}
metrics[metricName] = statValues;
}
results.push({ appVersion, platform, metrics });
}
return results;
}
export function buildObserveMetricsTable(
metricsMap: ObserveMetricsMap,
metricNames: string[],
stats: StatisticKey[]
): string {
const results = buildObserveMetricsJson(metricsMap, metricNames, stats);
if (results.length === 0) {
return chalk.yellow('No metrics data found.');
}
const fixedHeaders = ['App Version', 'Platform'];
const metricHeaders: string[] = [];
for (const m of metricNames) {
const name = getMetricDisplayName(m);
for (const stat of stats) {
metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`);
}
}
const headers = [...fixedHeaders, ...metricHeaders];
const rows: string[][] = results.map(result => {
const metricCells: string[] = [];
for (const m of metricNames) {
const values = result.metrics[m];
for (const stat of stats) {
metricCells.push(formatStatValue(stat, values?.[stat] ?? null));
}
}
return [result.appVersion, appPlatformDisplayNames[result.platform], ...metricCells];
});
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
}
@@ -0,0 +1,35 @@
import { EasCommandError } from '../commandUtils/errors';
export const METRIC_ALIASES: Record<string, string> = {
tti: 'expo.app_startup.tti',
ttr: 'expo.app_startup.ttr',
cold_launch: 'expo.app_startup.cold_launch_time',
warm_launch: 'expo.app_startup.warm_launch_time',
bundle_load: 'expo.app_startup.bundle_load_time',
};
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
export const METRIC_SHORT_NAMES: Record<string, string> = {
'expo.app_startup.cold_launch_time': 'Cold Launch',
'expo.app_startup.warm_launch_time': 'Warm Launch',
'expo.app_startup.tti': 'TTI',
'expo.app_startup.ttr': 'TTR',
'expo.app_startup.bundle_load_time': 'Bundle Load',
};
export function resolveMetricName(input: string): string {
if (METRIC_ALIASES[input]) {
return METRIC_ALIASES[input];
}
if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) {
return input;
}
throw new EasCommandError(
`Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}`
);
}
export function getMetricDisplayName(metricName: string): string {
return METRIC_SHORT_NAMES[metricName] ?? metricName;
}
+19
View File
@@ -1,7 +1,9 @@
import { ExpoConfig } from '@expo/config';
import { Errors } from '@oclif/core';
import { format } from '@expo/timeago.js';
import chalk from 'chalk';
import dateFormat from 'dateformat';
import semver from 'semver';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -357,3 +359,20 @@ export const updatePublishPlatformToAppPlatform: Record<UpdatePublishPlatform, A
android: AppPlatform.Android,
ios: AppPlatform.Ios,
};
export function assertEnvironmentFlagForSdk55OrGreater({
sdkVersion,
environment,
}: {
sdkVersion: string | undefined;
environment: string | undefined;
}): void {
if (process.env.EAS_BUILD) {
return;
}
if (sdkVersion && semver.gte(sdkVersion, '55.0.0') && !environment) {
Errors.error('--environment flag is required for projects using Expo SDK 55 or greater', {
exit: 1,
});
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "18.0.1",
"version": "18.0.2",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
@@ -47,6 +47,7 @@
"@tsconfig/node20": "20.1.8",
"@types/babel__code-frame": "7.0.3",
"@types/fs-extra": "11.0.4",
"jest": "29.7.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.0.1",
"version": "18.0.2",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -10,9 +10,7 @@
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/local-build-plugin"
},
"bin": {
"eas-cli-local-build-plugin": "./bin/run"
},
"bin": "./bin/run",
"files": [
"dist",
"bin"
@@ -28,8 +26,8 @@
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"@expo/build-tools": "18.0.1",
"@expo/eas-build-job": "18.0.1",
"@expo/build-tools": "18.0.2",
"@expo/eas-build-job": "18.0.2",
"@expo/logger": "18.0.1",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "18.0.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.0.1",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"@expo/eas-build-job": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/logger": "18.0.1",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
@@ -1,5 +1,9 @@
name: Build Worker
defaults:
tools:
corepack: true
on:
workflow_dispatch:
inputs:
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.0.1",
"version": "18.0.2",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.0.1",
"@expo/build-tools": "18.0.2",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/logger": "18.0.1",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.0.1",
"@expo/steps": "18.0.2",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@hapi/boom": "10.0.1",
@@ -49,6 +49,7 @@
"semver": "7.6.3",
"tar": "7.5.7",
"uuid": "3.4.0",
"uuidv7": "1.1.0",
"ws": "8.18.0",
"xml2js": "0.6.2",
"zod": "4.1.8"
+25 -4
View File
@@ -45,21 +45,42 @@ copy_package() {
fi
}
copy_manifest() {
src=$1
dst=$2
mkdir -p $dst
cp "$src/package.json" "$dst/package.json"
if [[ -f "$src/tsconfig.json" ]]; then
cp "$src/tsconfig.json" "$dst/tsconfig.json"
fi
if [[ -f "$src/tsconfig.build.json" ]]; then
cp "$src/tsconfig.build.json" "$dst/tsconfig.build.json"
fi
}
cp "$ROOT_DIR/yarn.lock" $target_root_dir
cp "$ROOT_DIR/.yarnrc.yml" $target_root_dir
cp "$ROOT_DIR/package.json" $target_root_dir
cp "$ROOT_DIR/lerna.json" $target_root_dir
for package_dir in "$ROOT_DIR"/packages/*; do
if [[ -f "$package_dir/package.json" ]]; then
package_name=$(basename "$package_dir")
copy_manifest "$package_dir" "$target_root_dir/packages/$package_name"
fi
done
copy_package $WORKER_DIR $target_worker_dir
while IFS= read -r package_dir; do
package_name=$(basename "$package_dir")
copy_package "$package_dir" "$target_root_dir/packages/$package_name"
done <<< "$(yarn --silent lerna list --scope "@expo/worker" --include-dependencies --parseable --loglevel silent)"
done <<< "$(cd "$ROOT_DIR" && yarn run -T lerna list --scope "@expo/worker" --include-dependencies --parseable --loglevel silent)"
pushd $target_root_dir >/dev/null 2>&1
yarn install --silent --frozen-lockfile
yarn build
yarn install --silent --production=true --frozen-lockfile
yarn install --immutable
yarn run -T lerna run build --scope @expo/worker --include-dependencies
yarn workspaces focus @expo/worker --production
rm -rf tsconfig.json tsconfig.build.json
popd >/dev/null 2>&1
@@ -0,0 +1,84 @@
import { TransformCallback, Writable } from 'node:stream';
import { createBuildLoggerWithSecretsFilter } from '../logger';
import z from 'zod';
import { EnvironmentSecretType } from '@expo/eas-build-job';
async function waitForStreamFlush(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
describe('logger', () => {
it('obfuscates secrets in logs', async () => {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter([
{ name: 'TEST_SECRET', value: 'secret', type: EnvironmentSecretType.STRING },
{
name: 'ANOTHER_SECRET_BASE64',
value: 'YW5vdGhlclNlY3JldA==',
type: EnvironmentSecretType.STRING,
},
]);
const logs: any[] = [];
const writable = new Writable({
objectMode: true,
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
logs.push(chunk);
callback(null, chunk);
},
});
outputStream.pipe(writable);
logger.info('this is a secret');
logger.info(`another secret in base64 is ${Buffer.from('anotherSecret').toString('base64')}`);
await waitForStreamFlush();
expect(logs.length).toBe(2);
expect(logs[0].msg).toBe('this is a ******');
expect(logs[1].msg).toBe('another ****** in base64 is ********************');
});
it('adds logId to each log', async () => {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter([]);
const logs: any[] = [];
const writable = new Writable({
objectMode: true,
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
logs.push(chunk);
callback(null, chunk);
},
});
outputStream.pipe(writable);
logger.info('Test log');
logger.info('Test log');
await waitForStreamFlush();
expect(logs.length).toBe(2);
for (const log of logs) {
expect(log.logId).toBeDefined();
expect(z.uuidv7().parse(log.logId)).toBe(log.logId);
}
expect(logs[0].logId).not.toBe(logs[1].logId);
});
it('drains transformed logs even without explicit output consumer', async () => {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter([]);
logger.info('Test log');
logger.info('Test log');
logger.info('Test log');
await waitForStreamFlush();
expect(outputStream.readableLength).toBe(0);
});
});
@@ -89,9 +89,21 @@ describe('prepareRuntimeEnvironment', () => {
await prepareRuntimeEnvironment(ctx, { bun: '2.0.0' }, false);
expect(spawn).toHaveBeenCalledWith('yarn', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('pnpm', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('bun', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith(
'yarn',
['--version'],
expect.objectContaining({ stdio: 'pipe', cwd: expect.any(String) })
);
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['--version'],
expect.objectContaining({ stdio: 'pipe', cwd: expect.any(String) })
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['--version'],
expect.objectContaining({ stdio: 'pipe', cwd: expect.any(String) })
);
expect(spawn).toHaveBeenCalledWith(
'curl',
@@ -123,9 +135,21 @@ describe('prepareRuntimeEnvironment', () => {
await prepareRuntimeEnvironment(ctx, { bun: '2.0.0' }, false);
expect(spawn).toHaveBeenCalledWith('yarn', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('pnpm', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('bun', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith(
'yarn',
['--version'],
expect.objectContaining({ stdio: 'pipe', cwd: expect.any(String) })
);
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['--version'],
expect.objectContaining({ stdio: 'pipe', cwd: expect.any(String) })
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['--version'],
expect.objectContaining({ stdio: 'pipe', cwd: expect.any(String) })
);
expect(spawn).not.toHaveBeenCalledWith(
'curl',
+1 -1
View File
@@ -30,7 +30,7 @@ export default {
}),
loggers: {
base: {
name: env('LOGGER_NAME', { defaultValue: 'turtle-worker' }),
name: env('LOGGER_NAME', { defaultValue: 'worker' }),
uploadIntervalMs: env('LOGGER_INTERVAL_MS', { defaultValue: 10000, transform: Number }),
},
gcs: {
+28 -5
View File
@@ -5,6 +5,7 @@ import { ExpoConfig } from '@expo/config-types';
import { BuildJob, EnvironmentSecret, Platform, Workflow } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { readFile, readJson } from 'fs-extra';
import os from 'os';
import path from 'path';
import semver from 'semver';
@@ -38,12 +39,13 @@ export async function logProjectDependenciesAsync(
const dependencies = (packageJSON?.dependencies as Record<string, string> | undefined) ?? {};
const devDependencies =
(packageJSON?.devDependencies as Record<string, string> | undefined) ?? {};
const projectDirectory = ctx.getReactNativeProjectDirectory();
const plugins = filterSecretsAndParsePlugins(
ctx.appConfig.plugins,
ctx.job.secrets?.environmentSecrets
);
const babelConfig = loadPartialConfig({ cwd: ctx.getReactNativeProjectDirectory() });
const babelConfig = loadPartialConfig({ cwd: projectDirectory });
const babelPlugins =
babelConfig?.options?.plugins
?.map(plugin => {
@@ -65,7 +67,10 @@ export async function logProjectDependenciesAsync(
version: dependency[1],
})),
packageManager: ctx.packageManager,
packageManagerVersion: await getPackageManagerVersion(ctx.packageManager),
packageManagerVersion: await getPackageManagerVersion(ctx.packageManager, {
cwd: projectDirectory,
env: ctx.env,
}),
jsEngine: resolveJsEngine(ctx, dependencies),
newArchEnabled: await resolveNewArchEnabled(ctx, dependencies['react-native']),
source: 'Turtle Worker',
@@ -202,11 +207,29 @@ function isConfigItem(plugin: PluginItem): plugin is ConfigItem {
return !Array.isArray(plugin) && (plugin as any)?.file;
}
async function getPackageManagerVersion(packageManager: string): Promise<string | undefined> {
async function getPackageManagerVersion(
packageManager: string,
{
cwd,
env,
}: {
cwd: string;
env: NodeJS.ProcessEnv;
}
): Promise<string | undefined> {
try {
const { stdout } = await spawnAsync(packageManager, ['--version']);
const { stdout } = await spawnAsync(packageManager, ['--version'], { cwd, env });
return stdout.toString().trim();
} catch {
return undefined;
// Some package managers can fail version checks in project dirs with mismatched packageManager fields.
try {
const { stdout } = await spawnAsync(packageManager, ['--version'], {
cwd: os.tmpdir(),
env,
});
return stdout.toString().trim();
} catch {
return undefined;
}
}
}
+70 -29
View File
@@ -1,15 +1,11 @@
import { LogBuffer } from '@expo/build-tools';
import { EnvironmentSecret } from '@expo/eas-build-job';
import { LoggerLevel, createLogger } from '@expo/logger';
import { Transform, TransformCallback, Writable } from 'stream';
import { GCSLoggerStream, LogBuffer } from '@expo/build-tools';
import { BuildPhase, EnvironmentSecret } from '@expo/eas-build-job';
import { LoggerLevel, bunyan, createLogger } from '@expo/logger';
import { Readable, Transform, TransformCallback, Writable } from 'stream';
import config from './config';
import { maybeStringBase64Decode, simpleSecretsWhitelist } from './secrets';
import { BuildLogger as CommonBuildLogger, createGCSBuildLogger } from './utils/logger';
export interface BuildLogger extends CommonBuildLogger {
logBuffer: LogBuffer;
}
import { uuidv7 } from 'uuidv7';
const defaultLogger = createLogger({
name: config.loggers.base.name,
@@ -60,20 +56,33 @@ export class WorkerLogBuffer extends Writable implements LogBuffer {
}
}
function createSecretMaskingStream(secrets: EnvironmentSecret[]): Transform {
function createTransformStream(secrets: EnvironmentSecret[]): Transform {
const secretValues = secrets.map(({ value }) => value);
const secretList: string[] = [
...secretValues,
...secretValues.map(maybeStringBase64Decode).filter((i): i is string => !!i),
].filter(i => i.length > 1 && !simpleSecretsWhitelist.includes(i));
return new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
transform(_chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
let chunk = _chunk;
if (chunk && typeof chunk === 'object') {
// Remove hostname from logs since we don't need it.
const { hostname: _hostname, ...rest } = chunk;
chunk = {
...rest,
// Add logId to each log.
logId: uuidv7(),
};
}
if (chunk && typeof chunk === 'object' && chunk.msg) {
const msgWithoutSecrets = secretList.reduce((acc: string, pattern: string): string => {
return acc.replaceAll(pattern, '*'.repeat(pattern.length));
}, chunk.msg as string);
callback(null, {
...chunk,
msg: msgWithoutSecrets,
@@ -85,32 +94,64 @@ function createSecretMaskingStream(secrets: EnvironmentSecret[]): Transform {
});
}
export async function createBuildLoggerWithSecretsFilter(
secrets?: EnvironmentSecret[]
): Promise<BuildLogger> {
const logBuffer = new WorkerLogBuffer(MAX_LINES_IN_BUFFER);
const childLogger = defaultLogger.child({ service: 'worker' });
const { logger, stream } = await createGCSBuildLogger({
uploadMethod: config.loggers.gcs.signedUploadUrlForLogs
? { signedUrl: config.loggers.gcs.signedUploadUrlForLogs }
: undefined,
options: {
uploadIntervalMs: config.loggers.base.uploadIntervalMs,
compress: config.loggers.gcs.compressionMethod,
function createDiscardStream(): Writable {
return new Writable({
objectMode: true,
write(_chunk: any, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
callback(null);
},
transformStream: secrets && createSecretMaskingStream(secrets),
logger: childLogger,
});
}
export async function createBuildLoggerWithSecretsFilter(secrets?: EnvironmentSecret[]): Promise<{
logger: bunyan;
cleanUp: () => Promise<void>;
logBuffer: WorkerLogBuffer;
outputStream: Readable;
}> {
const buildLogger = defaultLogger.child({ phase: BuildPhase.UNKNOWN });
const transformStream = createTransformStream(secrets ?? []);
const discardStream = createDiscardStream();
transformStream.pipe(discardStream);
buildLogger.addStream({
type: 'raw',
stream: transformStream,
reemitErrorEvents: true,
level: buildLogger.level(),
});
logger.addStream({
let gcsLoggerStream: GCSLoggerStream | null = null;
if (config.loggers.gcs.signedUploadUrlForLogs) {
gcsLoggerStream = new GCSLoggerStream({
uploadMethod: { signedUrl: config.loggers.gcs.signedUploadUrlForLogs },
options: {
uploadIntervalMs: config.loggers.base.uploadIntervalMs,
compress: config.loggers.gcs.compressionMethod,
},
logger: defaultLogger,
});
transformStream.pipe(gcsLoggerStream);
await gcsLoggerStream.init();
}
const logBuffer = new WorkerLogBuffer(MAX_LINES_IN_BUFFER);
buildLogger.addStream({
type: 'raw',
stream: logBuffer,
reemitErrorEvents: true,
level: logger.level(),
level: buildLogger.level(),
});
return { logger, stream, logBuffer };
return {
logger: buildLogger,
cleanUp: async () => {
await gcsLoggerStream?.cleanUp();
},
logBuffer,
outputStream: transformStream,
};
}
export default defaultLogger;
+12 -6
View File
@@ -72,9 +72,15 @@ export async function prepareRuntimeEnvironment(
return;
}
const currentYarnVersion = (await spawn('yarn', ['--version'], { stdio: 'pipe' })).stdout.trim();
const currentPnpmVersion = (await spawn('pnpm', ['--version'], { stdio: 'pipe' })).stdout.trim();
const currentBunVersion = (await spawn('bun', ['--version'], { stdio: 'pipe' })).stdout.trim();
const currentYarnVersion = (
await spawn('yarn', ['--version'], { stdio: 'pipe', cwd: os.tmpdir() })
).stdout.trim();
const currentPnpmVersion = (
await spawn('pnpm', ['--version'], { stdio: 'pipe', cwd: os.tmpdir() })
).stdout.trim();
const currentBunVersion = (
await spawn('bun', ['--version'], { stdio: 'pipe', cwd: os.tmpdir() })
).stdout.trim();
if (builderConfig.node) {
const installedNodeVersion = await installNode(ctx, builderConfig.node);
@@ -204,7 +210,7 @@ async function installBun({
});
const currentBunVersion = (
await spawn('bun', ['--version'], { stdio: 'pipe', env: ctx.env })
await spawn('bun', ['--version'], { stdio: 'pipe', env: ctx.env, cwd: os.tmpdir() })
).stdout.trim();
if (currentBunVersion !== versionToInstall) {
throw new Error(
@@ -242,7 +248,7 @@ async function installPnpm({
env: ctx.env,
});
const currentPnpmVersion = (
await spawn('pnpm', ['--version'], { stdio: 'pipe', env: ctx.env })
await spawn('pnpm', ['--version'], { stdio: 'pipe', env: ctx.env, cwd: os.tmpdir() })
).stdout.trim();
if (currentPnpmVersion !== versionToInstall) {
throw new Error(
@@ -279,7 +285,7 @@ async function installYarn({
env: ctx.env,
});
const currentYarnVersion = (
await spawn('yarn', ['--version'], { stdio: 'pipe', env: ctx.env })
await spawn('yarn', ['--version'], { stdio: 'pipe', env: ctx.env, cwd: os.tmpdir() })
).stdout.trim();
if (currentYarnVersion !== versionToInstall) {
throw new Error(
+30 -36
View File
@@ -31,7 +31,6 @@ import logger, { createBuildLoggerWithSecretsFilter } from './logger';
import sentry from './sentry';
import State from './state';
import { WebSocketServer } from './utils/WebSocketServer';
import { LoggerStream } from './utils/logger';
import { turtleFetch } from './utils/turtleFetch';
export const HANGING_WORKER_CHECK_TIMEOUT_MS = 5 * 60 * 1000;
@@ -42,7 +41,7 @@ export default class BuildService {
private readonly state: State = new State();
private ws: WebSocketServer<LauncherMessage.Message, WorkerMessage.Message> | null = null;
private shouldCloseWorker = false;
private loggerStream?: LoggerStream;
private logsCleanUp?: () => Promise<void>;
private _startBuildTime?: number;
private buildContext?: BuildContext<Job>;
@@ -121,7 +120,7 @@ export default class BuildService {
const isSocketClosed: boolean = !this.ws;
// wait 5 seconds to make sure all logs are flushed
await setTimeoutAsync(5 * 1000);
await this.loggerStream?.cleanUp();
await this.logsCleanUp?.();
if (this.ws) {
logger.info('Send build result - error');
this.ws.send({
@@ -142,7 +141,7 @@ export default class BuildService {
buildArtifactsName: artifacts.BUILD_ARTIFACTS ?? null,
});
const isSocketClosed: boolean = !this.ws;
await this.loggerStream?.cleanUp();
await this.logsCleanUp?.();
if (this.ws) {
logger.info('Send build result - success');
this.ws.send({
@@ -194,7 +193,7 @@ export default class BuildService {
}
const isSocketClosed: boolean = !this.ws;
await this.loggerStream?.cleanUp();
await this.logsCleanUp?.();
if (this.ws) {
logger.info('Send build result - aborted');
this.ws.send({
@@ -280,10 +279,10 @@ export default class BuildService {
try {
const {
logger: buildLogger,
stream,
cleanUp,
logBuffer,
} = await createBuildLoggerWithSecretsFilter(job.secrets?.environmentSecrets);
this.loggerStream = stream;
this.logsCleanUp = cleanUp;
const analytics = new Analytics(initiatingUserId, metadata?.trackingContext ?? {});
@@ -330,37 +329,32 @@ export default class BuildService {
},
});
if (err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
let rawErrorMessage: string;
if (maybeRawError instanceof Error) {
rawErrorMessage = maybeRawError.message;
} else {
try {
rawErrorMessage = JSON.stringify(maybeRawError);
} catch {
rawErrorMessage = String(maybeRawError);
}
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
let rawErrorMessage: string = '';
if (maybeRawError.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
}
if (maybeRawError.stdout) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
}
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken) {
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
}
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
}
await this.finishError(err, maybeArtifacts);
-56
View File
@@ -1,56 +0,0 @@
import { GCSLoggerStream } from '@expo/build-tools';
import { BuildPhase } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { randomUUID } from 'crypto';
import { Transform, Writable } from 'stream';
export interface LoggerStream extends Writable {
writable: boolean;
init(): Promise<string>;
cleanUp(): Promise<void>;
write(rec: any): boolean;
}
export interface BuildLogger {
logger: bunyan;
stream?: LoggerStream;
}
export function makeKeyForBuildLogs(prefix: string): string {
return `${prefix}/${Date.now()}-${randomUUID()}.txt`;
}
async function createBuildLogger(logger: bunyan, stream?: NodeJS.WritableStream): Promise<bunyan> {
const fields =
'phase' in (logger.fields as Record<string, any>) ? {} : { phase: BuildPhase.UNKNOWN };
const childLogger = logger.child(fields);
if (stream) {
childLogger.addStream({
type: 'raw',
stream,
reemitErrorEvents: true,
level: logger.level(),
});
}
return childLogger;
}
export async function createGCSBuildLogger({
transformStream,
...config
}: GCSLoggerStream.Config & { transformStream?: Transform }): Promise<BuildLogger> {
if (config.uploadMethod) {
const stream = new GCSLoggerStream(config);
transformStream?.pipe(stream);
await stream.init();
const logger = await createBuildLogger(config.logger, transformStream ?? stream);
return {
logger,
stream,
};
} else {
return { logger: await createBuildLogger(config.logger) };
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
pushd $SCRIPTS_DIR >/dev/null 2>&1
# Install modules
yarn --silent
yarn
# Run script
yarn --silent $@
yarn $@
popd >/dev/null 2>&1
+3 -1
View File
@@ -12,9 +12,11 @@ ROOT_DIR="$( cd "$SCRIPTS_DIR"/.. && pwd )"
# to @expo/eas-build-job dependency, so the update-local-plugin step is no longer needed.
# $SCRIPTS_DIR/bin/run update-local-plugin
next_version_bump=$($SCRIPTS_DIR/bin/run next-version)
next_version_bump=`(cd $SCRIPTS_DIR; node --no-warnings=ExperimentalWarning --loader ts-node/esm src/nextVersion.ts)`
next_version=${1:-$next_version_bump}
echo "next_version = ${next_version}"
if [[ "$GITHUB_USER" == "Expo CI" && "$GITHUB_EMAIL" == "[email protected]" && "$INPUT_DRY_RUN" != "true" ]]; then
echo "Releasing with version $next_version"
lerna version --yes --exact "$next_version"
+327 -187
View File
@@ -1,238 +1,378 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
"@cspotcode/source-map-support@npm:^0.8.0":
version: 0.8.1
resolution: "@cspotcode/source-map-support@npm:0.8.1"
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@jridgewell/trace-mapping": "npm:0.3.9"
checksum: 10c0/05c5368c13b662ee4c122c7bfbe5dc0b613416672a829f3e78bc49a357a197e0218d6e74e7c66cfcd04e15a179acab080bd3c69658c9fbefd0e1ccd950a07fc6
languageName: node
linkType: hard
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/resolve-uri@npm:^3.0.3":
version: 3.1.0
resolution: "@jridgewell/resolve-uri@npm:3.1.0"
checksum: 10c0/78055e2526108331126366572045355051a930f017d1904a4f753d3f4acee8d92a14854948095626f6163cffc24ea4e3efa30637417bb866b84743dec7ef6fd9
languageName: node
linkType: hard
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/sourcemap-codec@npm:^1.4.10":
version: 1.4.14
resolution: "@jridgewell/sourcemap-codec@npm:1.4.14"
checksum: 10c0/3fbaff1387c1338b097eeb6ff92890d7838f7de0dde259e4983763b44540bfd5ca6a1f7644dc8ad003a57f7e80670d5b96a8402f1386ba9aee074743ae9bad51
languageName: node
linkType: hard
"@jridgewell/[email protected]":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
"@jridgewell/trace-mapping@npm:0.3.9":
version: 0.3.9
resolution: "@jridgewell/trace-mapping@npm:0.3.9"
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/resolve-uri": "npm:^3.0.3"
"@jridgewell/sourcemap-codec": "npm:^1.4.10"
checksum: 10c0/fa425b606d7c7ee5bfa6a31a7b050dd5814b4082f318e0e4190f991902181b4330f43f4805db1dd4f2433fd0ed9cc7a7b9c2683f1deeab1df1b0a98b1e24055b
languageName: node
linkType: hard
"@tsconfig/node10@^1.0.7":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9"
integrity sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==
"@tsconfig/node10@npm:^1.0.7":
version: 1.0.8
resolution: "@tsconfig/node10@npm:1.0.8"
checksum: 10c0/d400f7b5c02acd74620f892c0f41cea39e7c1b5f7f272ad6f127f4b1fba23346b2d8e30d272731a733675494145f6aa74f9faf050390c034c7c553123ab979b3
languageName: node
linkType: hard
"@tsconfig/node12@^1.0.7":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.9.tgz#62c1f6dee2ebd9aead80dc3afa56810e58e1a04c"
integrity sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==
"@tsconfig/node12@npm:^1.0.7":
version: 1.0.9
resolution: "@tsconfig/node12@npm:1.0.9"
checksum: 10c0/fc1fb68a89d8a641953036d23d95fe68f69f74d37a499db20791b09543ad23afe7ae9ee0840eea92dd470bdcba69eef6f1ed3fe90ba64d763bcd3f738e364597
languageName: node
linkType: hard
"@tsconfig/node14@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2"
integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==
"@tsconfig/node14@npm:^1.0.0":
version: 1.0.1
resolution: "@tsconfig/node14@npm:1.0.1"
checksum: 10c0/abd4e27d9ad712e1e229716a3dbf35d5cbb580d624a82d67414e7606cefd85d502e58800a2ab930d46a428fcfcb199436283b1a88e47d738ca1a5f7fd022ee74
languageName: node
linkType: hard
"@tsconfig/node16@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==
"@tsconfig/node16@npm:^1.0.2":
version: 1.0.2
resolution: "@tsconfig/node16@npm:1.0.2"
checksum: 10c0/d402706562444a173d48810d13fdf866c78f1b876ed8962eeac6c7cddf4e29e8aaa06dc28093219e3e9eb6316799cf4d9a7acba62c6a4e215ee0c94d83f9081f
languageName: node
linkType: hard
"@types/[email protected]":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/dateformat/-/dateformat-3.0.1.tgz#98d747a2e5e9a56070c6bf14e27bff56204e34cc"
integrity sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g==
"@types/dateformat@npm:3.0.1":
version: 3.0.1
resolution: "@types/dateformat@npm:3.0.1"
checksum: 10c0/f0cbd170987ede1fb4b541f1471de7eb722577b391ba3209df6e6840f32cb3ec2be8a8a5e00ff364834429d16bc341472087d91a960b903f331f5c44073af9a4
languageName: node
linkType: hard
"@types/[email protected]":
version "11.0.4"
resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45"
integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==
"@types/fs-extra@npm:11.0.4":
version: 11.0.4
resolution: "@types/fs-extra@npm:11.0.4"
dependencies:
"@types/jsonfile" "*"
"@types/node" "*"
"@types/jsonfile": "npm:*"
"@types/node": "npm:*"
checksum: 10c0/9e34f9b24ea464f3c0b18c3f8a82aefc36dc524cc720fc2b886e5465abc66486ff4e439ea3fb2c0acebf91f6d3f74e514f9983b1f02d4243706bdbb7511796ad
languageName: node
linkType: hard
"@types/jsonfile@*":
version "6.1.4"
resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.4.tgz#614afec1a1164e7d670b4a7ad64df3e7beb7b702"
integrity sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==
"@types/jsonfile@npm:*":
version: 6.1.4
resolution: "@types/jsonfile@npm:6.1.4"
dependencies:
"@types/node" "*"
"@types/node": "npm:*"
checksum: 10c0/b12d068b021e4078f6ac4441353965769be87acf15326173e2aea9f3bf8ead41bd0ad29421df5bbeb0123ec3fc02eb0a734481d52903704a1454a1845896b9eb
languageName: node
linkType: hard
"@types/[email protected]":
version "4.17.12"
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
"@types/lodash-es@npm:4.17.12":
version: 4.17.12
resolution: "@types/lodash-es@npm:4.17.12"
dependencies:
"@types/lodash" "*"
"@types/lodash": "npm:*"
checksum: 10c0/5d12d2cede07f07ab067541371ed1b838a33edb3c35cb81b73284e93c6fd0c4bbeaefee984e69294bffb53f62d7272c5d679fdba8e595ff71e11d00f2601dde0
languageName: node
linkType: hard
"@types/lodash@*":
version "4.14.182"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
"@types/lodash@npm:*":
version: 4.14.182
resolution: "@types/lodash@npm:4.14.182"
checksum: 10c0/d6bd4789dfb3be631d5e3277e6a1be5becb21440f3364f5d15b982c2e6b6bb1f8048d46fc5bff5ef0f90bebaf4d07c49b2919ba369d07af72d3beb3fea70c61a
languageName: node
linkType: hard
"@types/node@*":
version "16.10.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.3.tgz#7a8f2838603ea314d1d22bb3171d899e15c57bd5"
integrity sha512-ho3Ruq+fFnBrZhUYI46n/bV2GjwzSkwuT4dTf0GkuNFmnb8nq4ny2z9JEVemFi6bdEJanHLlYfy9c6FN9B9McQ==
"@types/node@npm:*":
version: 16.10.3
resolution: "@types/node@npm:16.10.3"
checksum: 10c0/8d6a81dc5b5d5dd2a7c7025b378f9d1c7a212bb1ebbf048070988d7b2f66b51278dc462090403a9a882dc305d3086ce61979a3f2ff2c4930b62831494c8f6abd
languageName: node
linkType: hard
"@types/[email protected]":
version "20.11.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f"
integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==
"@types/node@npm:20.11.0":
version: 20.11.0
resolution: "@types/node@npm:20.11.0"
dependencies:
undici-types "~5.26.4"
undici-types: "npm:~5.26.4"
checksum: 10c0/560aa850dfccb83326f9cba125459f6c3fb0c71ec78f22c61e4d248f1df78bd25fd6792cef573dfbdc49c882f8e38bb1a82ca87e0e28ff2513629c704c2b02af
languageName: node
linkType: hard
"@types/[email protected]":
version "7.5.6"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339"
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
"@types/semver@npm:7.5.6":
version: 7.5.6
resolution: "@types/semver@npm:7.5.6"
checksum: 10c0/196dc32db5f68cbcde2e6a42bb4aa5cbb100fa2b7bd9c8c82faaaf3e03fbe063e205dbb4f03c7cdf53da2edb70a0d34c9f2e601b54281b377eb8dc1743226acd
languageName: node
linkType: hard
acorn-walk@^8.1.1:
version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
"acorn-walk@npm:^8.1.1":
version: 8.2.0
resolution: "acorn-walk@npm:8.2.0"
checksum: 10c0/dbe92f5b2452c93e960c5594e666dd1fae141b965ff2cb4a1e1d0381e3e4db4274c5ce4ffa3d681a86ca2a8d4e29d5efc0670a08e23fd2800051ea387df56ca2
languageName: node
linkType: hard
acorn@^8.4.1:
version "8.5.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2"
integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==
"acorn@npm:^8.4.1":
version: 8.5.0
resolution: "acorn@npm:8.5.0"
bin:
acorn: bin/acorn
checksum: 10c0/a0cd0009d215df0c5a6c69ea40a5e784114f1b89d5649d663264ccbb855d4c6b6da6936fed607078b7d681bd8d8f2e730dad25931edbdef709bc4b520c0caa80
languageName: node
linkType: hard
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
"arg@npm:^4.1.0":
version: 4.1.3
resolution: "arg@npm:4.1.3"
checksum: 10c0/070ff801a9d236a6caa647507bdcc7034530604844d64408149a26b9e87c2f97650055c0f049abd1efc024b334635c01f29e0b632b371ac3f26130f4cf65997a
languageName: node
linkType: hard
[email protected]:
version "5.2.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.2.0.tgz#249623b7d66869c673699fb66d65723e54dfcfb3"
integrity sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==
"chalk@npm:5.2.0":
version: 5.2.0
resolution: "chalk@npm:5.2.0"
checksum: 10c0/8a519b35c239f96e041b7f1ed8fdd79d3ca2332a8366cb957378b8a1b8a4cdfb740d19628e8bf74654d4c0917aa10cf39c20752e177a1304eac29a1168a740e9
languageName: node
linkType: hard
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
"create-require@npm:^1.1.0":
version: 1.1.1
resolution: "create-require@npm:1.1.1"
checksum: 10c0/157cbc59b2430ae9a90034a5f3a1b398b6738bf510f713edc4d4e45e169bc514d3d99dd34d8d01ca7ae7830b5b8b537e46ae8f3c8f932371b0875c0151d7ec91
languageName: node
linkType: hard
[email protected]:
version "4.5.1"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.5.1.tgz#c20e7a9ca77d147906b6dc2261a8be0a5bd2173c"
integrity sha512-OD0TZ+B7yP7ZgpJf5K2DIbj3FZvFvxgFUuaqA/V5zTjAtAAXZ1E8bktHxmAGs4x5b7PflqA9LeQ84Og7wYtF7Q==
"dateformat@npm:4.5.1":
version: 4.5.1
resolution: "dateformat@npm:4.5.1"
checksum: 10c0/b8a0729386254f8e1633b552f156160777302f91cd81bba3dc91641b80ce2ea7f330e2980e95ee460e079fb4d7d1b821ef8562f38dc2b461c7101db9b503f504
languageName: node
linkType: hard
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
"diff@npm:^4.0.1":
version: 4.0.2
resolution: "diff@npm:4.0.2"
checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1
languageName: node
linkType: hard
[email protected]:
version "11.2.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b"
integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==
"fs-extra@npm:11.2.0":
version: 11.2.0
resolution: "fs-extra@npm:11.2.0"
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
graceful-fs: "npm:^4.2.0"
jsonfile: "npm:^6.0.1"
universalify: "npm:^2.0.0"
checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398
languageName: node
linkType: hard
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.8"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0":
version: 4.2.8
resolution: "graceful-fs@npm:4.2.8"
checksum: 10c0/68365485460f7d2e95c05c1b7aeee935349f3b7776488d5bd95a45d8a45bd4977442e88cbbdb4ea01bc72f49f01f75d83f049069774ac8cc4328af4bcff1c542
languageName: node
linkType: hard
jsonfile@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
"jsonfile@npm:^6.0.1":
version: 6.1.0
resolution: "jsonfile@npm:6.1.0"
dependencies:
universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
graceful-fs: "npm:^4.1.6"
universalify: "npm:^2.0.0"
dependenciesMeta:
graceful-fs:
optional: true
checksum: 10c0/4f95b5e8a5622b1e9e8f33c96b7ef3158122f595998114d1e7f03985649ea99cb3cd99ce1ed1831ae94c8c8543ab45ebd044207612f31a56fd08462140e46865
languageName: node
linkType: hard
[email protected]:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
"lodash-es@npm:4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
languageName: node
linkType: hard
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
"lru-cache@npm:^6.0.0":
version: 6.0.0
resolution: "lru-cache@npm:6.0.0"
dependencies:
yallist "^4.0.0"
yallist: "npm:^4.0.0"
checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9
languageName: node
linkType: hard
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
"make-error@npm:^1.1.1":
version: 1.3.6
resolution: "make-error@npm:1.3.6"
checksum: 10c0/171e458d86854c6b3fc46610cfacf0b45149ba043782558c6875d9f42f222124384ad0b468c92e996d815a8a2003817a710c0a160e49c1c394626f76fa45396f
languageName: node
linkType: hard
[email protected]:
version "3.0.4"
resolved "https://registry.yarnpkg.com/marked/-/marked-3.0.4.tgz#b8a1539e5e05c6ea9e93f15c0bad1d54ce890406"
integrity sha512-jBo8AOayNaEcvBhNobg6/BLhdsK3NvnKWJg33MAAPbvTWiG4QBn9gpW1+7RssrKu4K1dKlN+0goVQwV41xEfOA==
"marked@npm:3.0.4":
version: 3.0.4
resolution: "marked@npm:3.0.4"
bin:
marked: bin/marked
checksum: 10c0/2c31fab4d3a65a84895ad26e25b238baf93453ff8344df83bf79f4940636fde5bd744747261eba468f773f21124d7da10b2cf40d4101dbe71aa885f04e61db7b
languageName: node
linkType: hard
[email protected]:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
"scripts@workspace:.":
version: 0.0.0-use.local
resolution: "scripts@workspace:."
dependencies:
lru-cache "^6.0.0"
"@types/dateformat": "npm:3.0.1"
"@types/fs-extra": "npm:11.0.4"
"@types/lodash-es": "npm:4.17.12"
"@types/node": "npm:20.11.0"
"@types/semver": "npm:7.5.6"
chalk: "npm:5.2.0"
dateformat: "npm:4.5.1"
fs-extra: "npm:11.2.0"
lodash-es: "npm:4.17.21"
marked: "npm:3.0.4"
semver: "npm:7.5.4"
ts-node: "npm:10.9.2"
tslib: "npm:2.6.2"
typescript: "npm:5.3.3"
languageName: unknown
linkType: soft
[email protected]:
version "10.9.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
"semver@npm:7.5.4":
version: 7.5.4
resolution: "semver@npm:7.5.4"
dependencies:
"@cspotcode/source-map-support" "^0.8.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
lru-cache: "npm:^6.0.0"
bin:
semver: bin/semver.js
checksum: 10c0/5160b06975a38b11c1ab55950cb5b8a23db78df88275d3d8a42ccf1f29e55112ac995b3a26a522c36e3b5f76b0445f1eef70d696b8c7862a2b4303d7b0e7609e
languageName: node
linkType: hard
ts[email protected].2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
"ts-node@npm:10.9.2":
version: 10.9.2
resolution: "ts-node@npm:10.9.2"
dependencies:
"@cspotcode/source-map-support": "npm:^0.8.0"
"@tsconfig/node10": "npm:^1.0.7"
"@tsconfig/node12": "npm:^1.0.7"
"@tsconfig/node14": "npm:^1.0.0"
"@tsconfig/node16": "npm:^1.0.2"
acorn: "npm:^8.4.1"
acorn-walk: "npm:^8.1.1"
arg: "npm:^4.1.0"
create-require: "npm:^1.1.0"
diff: "npm:^4.0.1"
make-error: "npm:^1.1.1"
v8-compile-cache-lib: "npm:^3.0.1"
yn: "npm:3.1.1"
peerDependencies:
"@swc/core": ">=1.2.50"
"@swc/wasm": ">=1.2.50"
"@types/node": "*"
typescript: ">=2.7"
peerDependenciesMeta:
"@swc/core":
optional: true
"@swc/wasm":
optional: true
bin:
ts-node: dist/bin.js
ts-node-cwd: dist/bin-cwd.js
ts-node-esm: dist/bin-esm.js
ts-node-script: dist/bin-script.js
ts-node-transpile-only: dist/bin-transpile.js
ts-script: dist/bin-script-deprecated.js
checksum: 10c0/5f29938489f96982a25ba650b64218e83a3357d76f7bede80195c65ab44ad279c8357264639b7abdd5d7e75fc269a83daa0e9c62fd8637a3def67254ecc9ddc2
languageName: node
linkType: hard
[email protected]:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
"tslib@npm:2.6.2":
version: 2.6.2
resolution: "tslib@npm:2.6.2"
checksum: 10c0/e03a8a4271152c8b26604ed45535954c0a45296e32445b4b87f8a5abdb2421f40b59b4ca437c4346af0f28179780d604094eb64546bee2019d903d01c6c19bdb
languageName: node
linkType: hard
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
"typescript@npm:5.3.3":
version: 5.3.3
resolution: "typescript@npm:5.3.3"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 10c0/e33cef99d82573624fc0f854a2980322714986bc35b9cb4d1ce736ed182aeab78e2cb32b385efa493b2a976ef52c53e20d6c6918312353a91850e2b76f1ea44f
languageName: node
linkType: hard
universalify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
"typescript@patch:typescript@npm%3A5.3.3#optional!builtin<compat/typescript>":
version: 5.3.3
resolution: "typescript@patch:typescript@npm%3A5.3.3#optional!builtin<compat/typescript>::version=5.3.3&hash=e012d7"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 10c0/1d0a5f4ce496c42caa9a30e659c467c5686eae15d54b027ee7866744952547f1be1262f2d40de911618c242b510029d51d43ff605dba8fb740ec85ca2d3f9500
languageName: node
linkType: hard
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
"undici-types@npm:~5.26.4":
version: 5.26.5
resolution: "undici-types@npm:5.26.5"
checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
languageName: node
linkType: hard
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
"universalify@npm:^2.0.0":
version: 2.0.0
resolution: "universalify@npm:2.0.0"
checksum: 10c0/07092b9f46df61b823d8ab5e57f0ee5120c178b39609a95e4a15a98c42f6b0b8e834e66fbb47ff92831786193be42f1fd36347169b88ce8639d0f9670af24a71
languageName: node
linkType: hard
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
"v8-compile-cache-lib@npm:^3.0.1":
version: 3.0.1
resolution: "v8-compile-cache-lib@npm:3.0.1"
checksum: 10c0/bdc36fb8095d3b41df197f5fb6f11e3a26adf4059df3213e3baa93810d8f0cc76f9a74aaefc18b73e91fe7e19154ed6f134eda6fded2e0f1c8d2272ed2d2d391
languageName: node
linkType: hard
"yallist@npm:^4.0.0":
version: 4.0.0
resolution: "yallist@npm:4.0.0"
checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a
languageName: node
linkType: hard
"yn@npm:3.1.1":
version: 3.1.1
resolution: "yn@npm:3.1.1"
checksum: 10c0/0732468dd7622ed8a274f640f191f3eaf1f39d5349a1b72836df484998d7d9807fbea094e2f5486d6b0cd2414aad5775972df0e68f8604db89a239f0f4bf7443
languageName: node
linkType: hard
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec corepack yarn "$@"
Generated
+22416 -17006
View File
File diff suppressed because it is too large. Load diff