Compare commits

..
Author SHA1 Message Date
Krystof Woldrich eb3caef49b feat(simulator): expose topic commands 2026-05-20 16:24:09 +02:00
Stanisław Chmiela 6ec2ea0530 [eas-cli] Capture iOS config introspection fallback in Sentry (#3759)
## Summary
- capture local Expo CLI iOS entitlements introspection failures in Sentry before falling back to bundled config resolution
- tag the event with `app_id` from `EAS_BUILD_PROJECT_ID` and the config resolution path
- capture a short fallback message so this is easier to distinguish from generic spawn failures
- include the failed command stdout, stderr, and exit status as Sentry extras

## Why
If `expo config --json --type introspect` fails but the bundled config fallback succeeds, users no longer hit a hard failure. Capturing the original command failure gives us visibility into the remaining fallback cases without breaking builds, and the explicit message makes these events easier to identify.

## Validation

CI should pass.
2026-05-20 15:51:37 +02:00
Szymon Dziedzic d5a6b13dae [eas-cli] Add --json mode to simulator:start (#3751)
# Why

`eas simulator:start` currently only emits human-readable connect instructions, which is awkward to consume from scripts/automation. `--json` (which implies `--non-interactive`) makes the session id, job run URL, and remote config directly parseable.

# How

- Swapped `EASNonInteractiveFlag` for `EasNonInteractiveAndJsonFlags` and resolved both via `resolveNonInteractiveAndJsonFlags`, matching the pattern used by `eas upload` and other JSON-capable commands.
- Calls `enableJsonOutput()` early so spinners/log lines redirect to stderr when JSON is enabled.
- After the remote config is ready, emits a single JSON payload (`deviceRunSessionId`, `type`, `jobRunUrl`, `remoteConfig`) and returns without entering the interactive wait loop.
- Existing `--non-interactive` (without `--json`) behavior is preserved.

# Test Plan

- `yarn typecheck` reports no new errors (the two pre-existing `src/build/metadata.ts` errors are unrelated to this PR).
- `yarn run -T oxlint packages/eas-cli/src/commands/simulator/start.ts` → 0 errors.
- `yarn run -T oxfmt --check packages/eas-cli/src/commands/simulator/start.ts` → clean.
- Manual (TODO before merge): run `eas simulator:start --platform ios --json`, confirm a single JSON object lands on stdout while spinners/log output go to stderr, and that the payload contains the expected fields.
2026-05-20 12:47:00 +02:00
Ash Wu 41eb9f1499 [build-tools] Enable build time statistics for xcodebuild (#3750)
* [build-tools] Enable build time statistics for xcodebuild

Signed-off-by: Ash Wu <[email protected]>

* address comment

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-20 15:45:59 +08:00
Ash Wu 5c9b93fc34 [build-tools] Pass ctx.env to parse xcactivitylog (#3758)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 15:08:55 +08:00
Ash Wu fbc57831cc [build-tools] Use pre-install xclogparser (#3747)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 09:40:19 +08:00
Mustafa Abban 34ca7b0ab9 [build-tools] ignore no Gradle profile found error (#3755)
* [build-tools] ignore no Gradle profile found error

* [build-tools] ignore no Gradle profile found error

* [build-tools] ignore no Gradle profile found error
2026-05-19 15:42:33 -04:00
Expo CI 7d6e4db4e8 update CHANGELOG.md 2026-05-19 18:28:20 +00:00
Expo CI 32355274ec v19.0.1 2026-05-19 18:26:02 +00:00
Stanisław Chmiela 50ca4cbec1 [eas-cli] Restore iOS entitlements introspection fallback (#3753)
## Summary
- wrap managed iOS `expo config --json --type introspect` entitlement resolution in a local Expo CLI try/catch
- fall back to bundled `getPrebuildConfigAsync` + `compileModsAsync`, matching the previous behavior
- include stderr/stdout in the thrown error when both the local CLI and fallback fail
- check `isExpoInstalled` before spawning the local Expo CLI, consistent with the regular config reader

## Why
After `18.13.1`, managed iOS builds fail hard if the local Expo CLI introspection command exits non-zero. Previously this path could still resolve entitlements through the bundled config fallback. Restoring that fallback limits the regression while preserving the local CLI path when it is available.

## Validation

CI should pass.
2026-05-19 20:24:00 +02:00
Stanisław Chmiela 2dbb4372d8 [eas-cli] Add EXPO_NO_DOTENV when resolving iOS entitlements back (#3752)
## Summary
- restore `EXPO_NO_DOTENV=1` when resolving managed iOS entitlements through `expo config --type introspect`
- keep the introspection environment consistent with the regular Expo config path

## Why
`18.13.1` changed this path to call the local Expo CLI directly, but the entitlements-specific call no longer disabled dotenv loading. This can make the iOS-only entitlements introspection evaluate app config under a different environment than the rest of EAS CLI config resolution.

## Validation

CI should pass. Repacks should work.
2026-05-19 20:14:45 +02:00
Expo CI 4bf38452a9 update CHANGELOG.md 2026-05-19 16:42:12 +00:00
Expo CI fc7c2950e7 v19.0.0 2026-05-19 16:38:56 +00:00
Byron Karlen 30248528ad [auth] Make browser-based login the default (#3746) 2026-05-19 09:19:39 -07:00
Mustafa Abban ecf58ab8a8 [build-tools] - Gradle build profile report with task execution breakdown (#3629)
* [build] gradle parse profiler

* gradleProfileTasks

* individual tasks nested underneath with tree characters

* filter out whats less than 1s

* parseGradleProfile

* profile flag

* enable by default

* gradleProfile utils

* parse profile HTML with fast-xml-parser

* parse profile HTML with fast-xml-parser

* error handling, send to sentry

* sentry capture
2026-05-19 12:07:36 -04:00
Douglas Lowder 10c7ac64ba [eas-cli] filter observe:routes results by route names (#3744)
# Why

The `eas observe:routes` command lacked the ability to filter navigation route results by specific route names. Users had no way to narrow down the routes data to only the routes they cared about.

# How

A new `--route-name` flag (repeatable) was added to the command. When one or more values are provided, they are collected into a `routeNames` array and forwarded to the `fetchObserveNavigationRoutesAsync` function, which passes them along in the GraphQL filter. An empty array is treated the same as omitting the flag — no `routeNames` key is sent in the filter.

Example:

```
$ easd observe:routes 
EAS Observe is in preview and subject to breaking changes.

Med values (navigation count) for the last 60 days

Android
Route           Nav Cold TTR  Nav Warm TTR  Nav TTI    
--------------  ------------  ------------  -----------
/               0.18s (63)    0.08s (217)   0.22s (280)
/task/[taskId]  0.55s (39)    0.17s (139)   0.35s (178)
/new            0.37s (20)    0.13s (83)    0.26s (103)
/stats          0.88s (19)    0.36s (94)    0.64s (113)
/settings       0.15s (13)    0.06s (41)    0.12s (54) 

iOS
Route           Nav Cold TTR  Nav Warm TTR  Nav TTI    
--------------  ------------  ------------  -----------
/               0.18s (106)   0.08s (504)   0.22s (610)
/task/[taskId]  0.51s (88)    0.18s (325)   0.34s (413)
/stats          1.06s (68)    0.34s (243)   0.62s (311)
/new            0.32s (67)    0.13s (219)   0.27s (286)
/settings       0.16s (17)    0.06s (87)    0.13s (104)

$ easd observe:routes --route-name /new --route-name /settings
EAS Observe is in preview and subject to breaking changes.

Med values (navigation count) for the last 60 days

Android
Route      Nav Cold TTR  Nav Warm TTR  Nav TTI    
---------  ------------  ------------  -----------
/new       0.37s (20)    0.13s (83)    0.26s (103)
/settings  0.15s (13)    0.06s (41)    0.12s (54) 

iOS
Route      Nav Cold TTR  Nav Warm TTR  Nav TTI    
---------  ------------  ------------  -----------
/new       0.32s (67)    0.13s (219)   0.27s (286)
/settings  0.16s (17)    0.06s (87)    0.13s (104)
```

# Test Plan

- New unit tests cover both the command flag parsing and the fetch function behavior
- Manual testing with the kadiexpo/cadence app in staging
2026-05-19 03:50:12 -07:00
Phil Pluckthun 7ee81e856b fix(eas-cli): Check if expo is resolvable before assuming it's installed (#3748)
* Add resolution check to `isExpoCommand` shared with `spawnExpoCommand`

* Add CHANGELOG entry
2026-05-19 11:02:14 +01:00
Gabe Debes 8328172827 [worker] report build phase duration to www metrics endpoint (#3732)
# Why

Turtle reports build-phase durations to Datadog. Orchestrator-routed builds don't, so we lose those stats post-migration.

Ref ENG-21208

# How

Adds a `Datadog` helper in `@expo/build-tools`, following the existing Sentry singleton pattern. Worker configures it once per task with the www API base URL, turtle build id, and robot token when a token is available. `BuildContext` emits `eas.build.phase_duration` distribution samples when build phases end, while the existing build phase stats callback remains only for launcher websocket reporting.

The payload matches what shipped in https://github.com/expo/universe/pull/27475: `POST /v2/turtle-builds/:buildId/metrics` with `metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value, tags }]`. Upload failures are captured through Sentry and never fail the build. Worker flushes pending metric sends before sending terminal build results.

# Test Plan

- CI
- `yarn exec oxfmt --check CHANGELOG.md packages/build-tools/src/datadog.ts packages/build-tools/src/index.ts packages/build-tools/src/context.ts packages/build-tools/src/__tests__/datadog.test.ts packages/build-tools/src/__tests__/context.test.ts packages/worker/src/context.ts packages/worker/src/service.ts packages/worker/src/__unit__/service.test.ts`
- `yarn workspace @expo/build-tools build`
- `yarn workspace @expo/build-tools jest-unit src/__tests__/datadog.test.ts src/__tests__/context.test.ts --runInBand`
- `yarn workspace @expo/worker test:unit packages/worker/src/__unit__/service.test.ts --runInBand`
2026-05-19 11:13:53 +02:00
Expo CI 29909b223d update CHANGELOG.md 2026-05-18 21:34:02 +00:00
Expo CI dbf597957d v18.13.1 2026-05-18 21:15:12 +00:00
Mustafa Abban 4d2b406dc5 [build-job] add GRADLE_PROFILE build phase enum (#3743) 2026-05-18 16:22:22 -04:00
Douglas Lowder 774195ff74 [eas-cli] Add direct dependency on debug (#3741)
# Why

The `Log.debug()` helper in `src/log.ts` has been calling `require('debug')` under an `eslint-disable` comment, relying on the package being resolved transitively through `@babel/*` and other dependencies.

# How

Declare `debug` and `@types/debug` directly so the require is stable under stricter resolution modes and so we can drop the `eslint-disable` and the runtime require in favor of a typed import.

Fixes #3630 .

# Test Plan

- Unit tests pass
- Tested various commands and tested with `DEBUG=eas:*`
2026-05-18 12:37:09 -07:00
Phil Pluckthun 255590cd45 refactor(eas-cli): Remove npx expo invocations and config/entitlements fallbacks (#3282)
* Expose raw `spawnExpoCommand` utility

* Remove use of `npx expo` invocation for `spawnExpoCommand`

* Replace manual `npx expo` invocation in onboarding with `expoCommandAsync`

* Remove npx invocation in "new" command

* Remove catch-case from `getExpoConfigInternalAsync`

* Remove catch-case from `getManagedApplicationTargetEntitlementsAsync`

* Force fallback to `@expo/config` in tests

This isn't great and shouldn't be done, but otherwise we're mocking the
expo command utility here which also isn't great

* Update commands.test.ts

* Add CHANGELOG entry

* Apply lints
2026-05-18 11:59:30 +01:00
Szymon Dziedzic d15641948c Improve tunnel perf by using constrained stream quality and dimensions (#3737)
<!-- 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

Use parameters to improve tunnel perf.

# How

Use quic cloudflared tunnel protocol + constrain stream max dimension + quality to improve speed.

# Test Plan

Tested locally.
2026-05-18 11:54:14 +02:00
Stanisław Chmiela 6cbcc8537c [eas-cli] Add expoPackageVersion to build metadata (#3725)
# Why

Persist the installed `expo` package version separately from `sdkVersion` so server-side log uploads can reproduce the legacy `expo_package_version` Datadog tag without conflating it with SDK version.

# How

- Added `expoPackageVersion` to build metadata schema/types.
- Moved the existing worker `getExpoPackageVersionAsync(...)` behavior into shared `@expo/eas-build-job#getInstalledExpoPackageVersionAsync(...)`: `node --print require.resolve('expo/package.json')`, `fs.readJson`, semver validation, and the same `EAS_BUILD_EXPO_PACKAGE_VERSION_*` `UserError`s.
- `collectMetadataAsync(...)` now resolves `expoPackageVersion` inline with `asyncResult(getInstalledExpoPackageVersionAsync(...)).value`, so build creation and the existing internal `updateBuildMetadata` path send it alongside the rest of the build metadata.
- Worker error-log tags now read `expo_package_version` from build metadata; the worker no longer resolves or mutates this field.

# Test Plan

CI should pass.
2026-05-15 16:32:13 +02:00
Expo CI 5ec4eaf052 update CHANGELOG.md 2026-05-14 20:15:30 +00:00
Expo CI 08a9b5ff7f v18.13.0 2026-05-14 20:13:13 +00:00
Douglas Lowder 501af5213e [eas-cli] Extract shared observe flag groups into observe/flags.ts (#3731)
# Why

It was found that observe commands were using common flag definitions with duplicate code.

# How

Refactored the observe commands to extract flag definitions.

# Test Plan

Commands tested locally.

Unit tests pass.
2026-05-14 13:10:13 -07:00
Douglas Lowder d48f931d77 [eas-cli] Add observe:routes for navigation metrics by route name (#3730)
# Why

New command `observe:routes` to get navigation metrics by route.

```
USAGE
  $ eas observe:routes [--platform android|ios] [--metric cold_ttr|warm_ttr|nav_tti...] [--stat
    median|med|p90|count|event_count|eventCount...] [--after <value>] [--limit <value>] [--start <value> | --days
    <value>] [--end <value> | ] [--app-version <value>] [--update-id <value>] [--build-number <value>] [--project-id
    <value>] [--json] [--non-interactive]

FLAGS
  --after=<value>         Cursor for pagination. Use the endCursor from a previous query to fetch the next page.
  --app-version=<value>   Filter by app version
  --build-number=<value>  Filter by app build number
  --days=<value>          Show results from the last N days (mutually exclusive with --start/--end)
  --end=<value>           End of time range (ISO date)
  --json                  Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --limit=<value>         The number of items to fetch each query. Defaults to 50 and is capped at 200.
  --metric=<option>...    Navigation metric to display (can be specified multiple times). Defaults to all three.
                          <options: cold_ttr|warm_ttr|nav_tti>
  --non-interactive       Run the command in non-interactive mode.
  --platform=<option>     Filter by platform
                          <options: android|ios>
  --project-id=<value>    EAS project ID (defaults to the project ID of the current directory)
  --start=<value>         Start of time range (ISO date)
  --stat=<option>...      Statistic to display per metric (can be specified multiple times)
                          <options: median|med|p90|count|event_count|eventCount>
  --update-id=<value>     Filter by EAS update ID

DESCRIPTION
  display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name

```

# How

Added a new command that that queries the new navigationRoutes GraphQL field to display Cold TTR, Warm TTR, and TTI, broken down per route name and platform.

# Test Plan

Tested with staging data in `@expokadi/cadence` project.

New unit tests added.
2026-05-14 12:11:38 -07:00
Mustafa Abban 99b18a2189 [worker] - remove logging errors to datadog (#3729)
* [worker] - remove logging errors to datadog

* [worker] - remove logging errors to datadog

* rem tests

* rem tests
2026-05-13 14:09:38 -04:00
Expo CI 9ece20157f update CHANGELOG.md 2026-05-13 14:21:35 +00:00
77 changed files with 4081 additions and 781 deletions

No files matched your search

+47
View File
@@ -12,6 +12,53 @@ This is the log of notable changes to EAS CLI and related packages.
### 🧹 Chores
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
### 🛠 Breaking changes
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
### 🎉 New features
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.12.3",
"version": "19.0.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.12.3",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -37,17 +37,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.12.0",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/logger": "19.0.0",
"@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.12.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/steps": "19.0.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@sentry/node": "7.77.0",
@@ -1,14 +1,36 @@
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { Datadog } from '../datadog';
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
describe('BuildContext', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -116,4 +138,68 @@ describe('BuildContext', () => {
foo: 'bar',
});
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'run_fastlane',
platform: Platform.IOS,
result: BuildPhaseResult.SUCCESS,
}
);
});
it('emits build phase duration metrics for failed build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
await expect(
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
throw new Error('boom');
})
).rejects.toThrow();
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'install_dependencies',
platform: Platform.ANDROID,
result: BuildPhaseResult.FAIL,
}
);
});
it('does not emit build phase duration metrics for non-platform jobs', async () => {
const ctx = createTestBuildContext({});
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
expect(datadogDistributionMock).not.toHaveBeenCalled();
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
{
...(platform ? { platform } : {}),
builderEnvironment: { env: {} },
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
@@ -0,0 +1,150 @@
import { Response } from 'node-fetch';
import { Datadog } from '../datadog';
import { Sentry } from '../sentry';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
const turtleFetchMock = jest.mocked(turtleFetch);
const sentryCaptureMock = jest.mocked(Sentry.capture);
describe('Datadog singleton', () => {
beforeEach(() => {
jest.clearAllMocks();
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
Datadog.distribution('eas.build.phase_duration', 1234, {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1234,
tags: {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows upload failures', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
Datadog.distribution('eas.build.phase_duration', 1);
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'first-build-id',
robotAccessToken: 'first-token',
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'second-build-id',
robotAccessToken: 'second-token',
});
Datadog.distribution('eas.build.phase_duration', 1);
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
let flushed = false;
Datadog.distribution('eas.build.phase_duration', 1);
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
+21 -12
View File
@@ -32,18 +32,27 @@ export async function runGradleCommand(
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
});
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...ctx.env,
...extraEnv,
...resolveVersionOverridesEnvs(ctx),
LC_ALL: 'C.UTF-8',
},
}
);
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -0,0 +1,231 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import path from 'path';
export interface GradleProfileTask {
path: string;
durationMs: number;
result: string;
}
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
// Gradle profile may not exist for all builds (e.g. custom, repack
// jobs). We should short circuit and not unnecessarily log.
if (!(await fs.pathExists(profileDir))) {
return [];
}
const files = await fs.readdir(profileDir);
const htmlFile = files
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
.sort()
.pop();
if (!htmlFile) {
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
}
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
// Locate the <h2>Task Execution</h2> heading and extract its <table>
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
if (!headingMatch || headingMatch.index === undefined) {
throw new Error('Could not find Task Execution section in Gradle profile');
}
const sectionStart = headingMatch.index;
const tableStart = html.indexOf('<table', sectionStart);
const tableEnd = html.indexOf('</table>', tableStart);
if (tableStart === -1 || tableEnd === -1) {
throw new Error('Could not find task execution table in Gradle profile');
}
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
const parser = new XMLParser({
ignoreAttributes: true,
isArray: name => name === 'tr' || name === 'td',
trimValues: true,
});
const parsed = parser.parse(tableHtml);
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
const tasks: GradleProfileTask[] = [];
for (const row of rows) {
const cells: unknown[] = row?.td;
if (!cells || cells.length < 2) {
continue;
}
const taskPath = String(cells[0] ?? '').trim();
const durationStr = String(cells[1] ?? '').trim();
const result = String(cells[2] ?? '').trim() || 'executed';
if (!taskPath || !durationStr) {
continue;
}
const durationMs = parseDurationToMs(durationStr);
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
}
return tasks;
}
function parseDurationToMs(duration: string): number {
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
const secondsMatch = duration.match(/^([\d.]+)s$/);
if (secondsMatch) {
return Math.round(parseFloat(secondsMatch[1]) * 1000);
}
return 0;
}
function formatSeconds(ms: number): string {
const s = ms / 1000;
if (s < 0.1) {
return `${ms}ms`;
}
return `${s.toFixed(1)}s`;
}
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
// Filter out tasks under 1 second
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
// Separate module totals from individual tasks
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
// Group individual tasks by their module prefix
const moduleChildren = new Map<string, GradleProfileTask[]>();
const orphanTasks: GradleProfileTask[] = [];
for (const task of individualTasks) {
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
if (parent) {
const children = moduleChildren.get(parent.path) ?? [];
children.push(task);
moduleChildren.set(parent.path, children);
} else {
orphanTasks.push(task);
}
}
// Sort module totals by duration, sort children within each module
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
for (const children of moduleChildren.values()) {
children.sort((a, b) => b.durationMs - a.durationMs);
}
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
// Build display rows: [displayName, task]
const rows: { displayName: string; task: GradleProfileTask }[] = [];
for (const mod of sortedModules) {
rows.push({ displayName: mod.path, task: mod });
const children = moduleChildren.get(mod.path) ?? [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const prefix = isLast ? ' └─ ' : ' ├─ ';
const shortName = children[i].path.slice(mod.path.length + 1);
rows.push({ displayName: prefix + shortName, task: children[i] });
}
}
for (const task of orphanTasks) {
rows.push({ displayName: task.path, task });
}
// Compute totals from individual tasks only (avoid double-counting)
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
const maxMs = totalMs || 1;
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
const barMaxWidth = 20;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const taskCount = individualTasks.length;
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
const lines: string[] = [];
lines.push('Gradle Build — Task Execution Profile');
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
lines.push('% Time = share of total task execution time');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Task'.padEnd(nameWidth) +
' │ ' +
'Duration'.padStart(10) +
' │ ' +
'% Time'.padStart(8) +
' │ ' +
'Result'.padEnd(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const row of rows) {
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
const result = row.task.result === '(total)' ? 'total' : row.task.result;
lines.push(
'│ ' +
row.displayName.padEnd(nameWidth) +
' │ ' +
formatSeconds(row.task.durationMs).padStart(10) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(8) +
' │ ' +
result.padEnd(10) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalMs).padStart(10) +
' │ ' +
'100.0%'.padStart(8) +
' │ ' +
' '.repeat(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
+14 -5
View File
@@ -16,6 +16,7 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
@@ -173,17 +174,25 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds skip build performance analysis.');
return;
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
});
}
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
+8
View File
@@ -20,6 +20,7 @@ import fs from 'fs-extra';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -355,6 +356,13 @@ export class BuildContext<TJob extends Job = Job> {
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
platform: this.job.platform,
result,
});
}
if (!doNotMarkEnd) {
this.logger.info(
+54
View File
@@ -0,0 +1,54 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
turtleBuildId: string;
robotAccessToken: string;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingMetricUploads: Promise<unknown>[] = [];
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
},
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags,
},
];
const uploadPromise = turtleFetch(
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}
).catch(err => {
Sentry.capture('Failed to report turtle build metric', err, {
extras: { metrics },
});
});
pendingMetricUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
await Promise.allSettled(pendingMetricUploads);
},
};
+4
View File
@@ -20,6 +20,10 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
@@ -1,5 +1,6 @@
import { Ios } from '@expo/eas-build-job';
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
@@ -8,6 +9,10 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
@@ -61,14 +66,10 @@ function makeIosBuildContext({
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
});
(spawn as jest.Mock).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
@@ -76,17 +77,12 @@ describe(installPods, () => {
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
@@ -134,7 +130,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
@@ -147,7 +143,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
@@ -158,7 +154,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
+5 -15
View File
@@ -1,6 +1,5 @@
import { Env, Ios } from '@expo/eas-build-job';
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
@@ -53,7 +52,10 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
} catch (err) {
ctx.logger.info(
{ err },
@@ -93,18 +95,6 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
return PRECOMPILED_MODULES_BASE_URL;
@@ -45,6 +45,7 @@ export function parseXcactivitylogFunction(): BuildFunction {
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
@@ -23,18 +23,27 @@ export async function runGradleCommand({
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
});
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...env,
...extraEnv,
LC_ALL: 'C.UTF-8',
},
}
);
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -195,6 +195,8 @@ const FIXTURE_OVERLAP_WITH_GAP = {
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
@@ -224,32 +226,38 @@ describe('parseAndReportXcactivitylog', () => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
it('downloads xclogparser from GCS and logs resolution + final report when no binary is preinstalled', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
stdio: 'pipe',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
2,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
{ stdio: 'pipe' }
{ stdio: 'pipe', env: TEST_ENV }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
3,
path.join(tempDir, 'xclogparser'),
[
'parse',
@@ -262,10 +270,10 @@ describe('parseAndReportXcactivitylog', () => {
'--output',
reportPath,
],
{ stdio: 'pipe' }
{ stdio: 'pipe', env: TEST_ENV }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
@@ -281,6 +289,7 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
@@ -288,6 +297,7 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
@@ -315,6 +325,7 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found'))
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
@@ -324,6 +335,7 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
@@ -353,6 +365,7 @@ describe('parseAndReportXcactivitylog', () => {
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -360,6 +373,7 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
@@ -382,6 +396,7 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -389,6 +404,7 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
@@ -407,6 +423,7 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(new Error('spawn xclogparser ENOENT')); // xclogparser run
@@ -415,6 +432,7 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
@@ -432,6 +450,7 @@ describe('parseAndReportXcactivitylog', () => {
mockFilesystem();
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -439,6 +458,7 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
@@ -459,6 +479,7 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -466,13 +487,171 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
describe('xclogparser version resolution', () => {
it('uses preinstalled binary when no version is requested', async () => {
const logger = createMockLogger();
const { reportPath } = mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalledWith('unzip', expect.any(Array), expect.any(Object));
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xclogparser',
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--output',
reportPath,
],
{ stdio: 'pipe', env: TEST_ENV }
);
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('downloads default version when no version requested and no preinstall found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
it('uses preinstalled binary when requested version matches', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
});
it('downloads requested version when it does not match the preinstalled binary', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.48\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('downloads requested version when no preinstall is found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('falls back to download when the version output cannot be parsed', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'something unexpected', stderr: '' } as any) // version detect
.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
});
});
describe('isCompileStep', () => {
@@ -1,6 +1,7 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
@@ -22,35 +23,54 @@ const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
xclogparserVersion,
logger,
proxyBaseUrl,
env,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<void> {
let tempDir: string | undefined;
let phase = 'creating_temp_directory';
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
phase = 'downloading_xclogparser';
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
phase = 'resolving_xclogparser';
const preinstalledVersion = await detectPreinstalledXclogparserVersion(env);
let binaryPath: string;
let resolvedVersion: string;
if (
preinstalledVersion &&
(!xclogparserVersion || preinstalledVersion === xclogparserVersion)
) {
binaryPath = 'xclogparser';
resolvedVersion = preinstalledVersion;
logger.info(`Using preinstalled xclogparser ${resolvedVersion}.`);
} else {
phase = 'downloading_xclogparser';
resolvedVersion = xclogparserVersion ?? DEFAULT_XCLOGPARSER_VERSION;
binaryPath = await downloadXclogparser({
tempDir,
version: resolvedVersion,
logger,
proxyBaseUrl,
env,
});
logger.info(`Using downloaded xclogparser ${resolvedVersion}.`);
}
phase = 'running_xclogparser';
const jsonOutputPath = await runXclogparser({
binaryPath: xclogparserPath,
binaryPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
env,
});
phase = 'parsing_xclogparser_output';
@@ -70,12 +90,28 @@ export async function parseAndReportXcactivitylog({
}
}
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
async function detectPreinstalledXclogparserVersion(env: BuildStepEnv): Promise<string | null> {
const result = await asyncResult(spawn('xclogparser', ['version'], { stdio: 'pipe', env }));
if (!result.ok) {
return null;
}
const match = result.value.stdout.match(/(\d+\.\d+\.\d+)/);
return match ? `v${match[1]}` : null;
}
async function downloadXclogparser({
tempDir,
version,
logger,
proxyBaseUrl,
env,
}: {
tempDir: string;
version: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
@@ -83,7 +119,7 @@ async function downloadXclogparser(
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl, env })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
@@ -96,31 +132,35 @@ async function downloadXclogparser(
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl, env });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
env,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
env: BuildStepEnv;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath });
return await unpackXclogparser({ tempDir, zipPath, env });
}
async function unpackXclogparser({
tempDir,
zipPath,
env,
}: {
tempDir: string;
zipPath: string;
env: BuildStepEnv;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe', env });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
@@ -143,11 +183,13 @@ async function runXclogparser({
derivedDataPath,
workspacePath,
outputDir,
env,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
env: BuildStepEnv;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
@@ -164,7 +206,7 @@ async function runXclogparser({
'--output',
outputPath,
],
{ stdio: 'pipe' }
{ stdio: 'pipe', env }
);
return outputPath;
@@ -420,7 +462,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' '.repeat(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -123,7 +123,16 @@ export async function startServeSimWithTunnelAsync({
logger.info('Launching serve-sim with tunnel.');
const serveSim = spawnDetached({
command: 'npx',
args: ['serve-sim-szdziedzic@latest', '--tunnel'],
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-protocol',
'quic',
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
],
env,
});
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.12.0",
"version": "19.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.12.0"
"@expo/steps": "19.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "18.5.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "18.5.0",
"version": "19.0.0",
"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",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.12.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,7 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.5.0",
"@expo/logger": "19.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "19.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -0,0 +1,77 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { errors } from '..';
import { getInstalledExpoPackageVersionAsync } from '../projectPackage';
describe(getInstalledExpoPackageVersionAsync, () => {
it('returns the installed expo package version resolved by project node', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).resolves.toBe('55.0.17');
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo cannot be resolved from the project', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo package.json cannot be read', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('read failed'));
const promise = getInstalledExpoPackageVersionAsync({ projectDir });
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
});
await expect(promise).rejects.toBeInstanceOf(errors.UserError);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when the installed expo package version is not valid semver', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: 'invalid-version' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});
+1
View File
@@ -27,6 +27,7 @@ export * from './context';
export * from './generic';
export * from './step';
export * from './submission-config';
export * from './projectPackage';
const version = require('../package.json').version;
export { version };
+3
View File
@@ -36,6 +36,7 @@ export enum BuildPhase {
// ANDROID
FIX_GRADLEW = 'FIX_GRADLEW',
RUN_GRADLEW = 'RUN_GRADLEW',
GRADLE_BUILD_PROFILE = 'GRADLE_BUILD_PROFILE',
// IOS
INSTALL_PODS = 'INSTALL_PODS',
@@ -104,6 +105,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'Fix gradlew',
[BuildPhase.RUN_GRADLEW]: 'Run gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'Gradle build profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'Install pods',
@@ -169,6 +171,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'fix-gradlew',
[BuildPhase.RUN_GRADLEW]: 'run-gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'gradle-build-profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'install-pods',
+6
View File
@@ -49,6 +49,11 @@ export type Metadata = {
*/
sdkVersion?: string;
/**
* Version of the expo package used in the project.
*/
expoPackageVersion?: string;
/**
* Runtime version (for Expo Updates)
*/
@@ -180,6 +185,7 @@ export const MetadataSchema = Joi.object({
distribution: Joi.string().valid('store', 'internal', 'simulator'),
credentialsSource: Joi.string().valid('local', 'remote'),
sdkVersion: Joi.string(),
expoPackageVersion: Joi.string(),
runtimeVersion: Joi.string(),
fingerprintHash: Joi.string(),
reactNativeVersion: Joi.string(),
@@ -0,0 +1,54 @@
import spawn from '@expo/turtle-spawn';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import semver from 'semver';
import { type Env } from './common';
import * as errors from './errors';
export async function getInstalledExpoPackageVersionAsync({
env = process.env,
projectDir,
}: {
env?: Env | NodeJS.ProcessEnv;
projectDir: string;
}): Promise<string> {
const expoPackageJsonPathResult = await asyncResult(
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: projectDir,
env,
stdio: 'pipe',
})
);
if (!expoPackageJsonPathResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
{ cause: expoPackageJsonPathResult.reason }
);
}
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
const expoPackageJsonResult = await asyncResult(readJsonAsync(expoPackageJsonPath));
if (!expoPackageJsonResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
{ cause: expoPackageJsonResult.reason }
);
}
const expoPackageVersion = expoPackageJsonResult.value.version;
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
);
}
return expoPackageVersion;
}
async function readJsonAsync(filePath: string): Promise<any> {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
}
+104 -104
View File
@@ -180,8 +180,8 @@ USAGE
$ eas account:login [-s] [-b]
FLAGS
-b, --browser Login with your browser
-s, --sso Login with SSO
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
-s, --sso Log in with SSO
DESCRIPTION
log in with your Expo account
@@ -190,7 +190,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -207,7 +207,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -229,7 +229,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -246,7 +246,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -260,7 +260,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -312,7 +312,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -333,7 +333,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -353,7 +353,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -373,7 +373,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -396,7 +396,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -434,7 +434,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -454,7 +454,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -472,7 +472,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -492,7 +492,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -517,7 +517,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -544,7 +544,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -582,7 +582,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -622,7 +622,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -652,7 +652,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -680,7 +680,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -732,7 +732,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -750,7 +750,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -768,7 +768,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -785,7 +785,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -806,7 +806,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -827,7 +827,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -849,7 +849,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -873,7 +873,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -893,7 +893,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -915,7 +915,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -937,7 +937,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -972,7 +972,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -995,7 +995,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1016,7 +1016,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.12.3/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1033,7 +1033,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1051,7 +1051,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1080,7 +1080,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1105,7 +1105,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1126,7 +1126,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1147,7 +1147,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1184,7 +1184,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1204,7 +1204,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1225,7 +1225,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1246,7 +1246,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1260,7 +1260,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1274,7 +1274,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1306,7 +1306,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1332,7 +1332,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1354,7 +1354,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1382,7 +1382,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1410,7 +1410,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1433,7 +1433,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1455,7 +1455,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1489,7 +1489,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1533,7 +1533,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1564,7 +1564,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1643,7 +1643,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1662,7 +1662,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1680,7 +1680,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
@@ -1702,7 +1702,7 @@ DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
@@ -1716,7 +1716,7 @@ DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
@@ -1730,7 +1730,7 @@ DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/project.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
@@ -1748,7 +1748,7 @@ DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
@@ -1762,7 +1762,7 @@ DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/team.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
@@ -1783,7 +1783,7 @@ DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
@@ -1803,7 +1803,7 @@ DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas login`
@@ -1814,8 +1814,8 @@ USAGE
$ eas login [-s] [-b]
FLAGS
-b, --browser Login with your browser
-s, --sso Login with SSO
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
-s, --sso Log in with SSO
DESCRIPTION
log in with your Expo account
@@ -1855,7 +1855,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1873,7 +1873,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1891,7 +1891,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1943,7 +1943,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.12.3/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1957,7 +1957,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1980,7 +1980,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2004,7 +2004,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2022,7 +2022,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -2055,7 +2055,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2098,7 +2098,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2119,7 +2119,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2140,7 +2140,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2165,7 +2165,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -2192,7 +2192,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.12.3/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2219,7 +2219,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2257,7 +2257,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2285,7 +2285,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2315,7 +2315,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2336,7 +2336,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2360,7 +2360,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2381,7 +2381,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.12.3/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2403,7 +2403,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2423,7 +2423,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2442,7 +2442,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2465,7 +2465,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2482,7 +2482,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2603,7 +2603,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2623,7 +2623,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2647,7 +2647,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2687,7 +2687,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2709,7 +2709,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2737,7 +2737,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2757,7 +2757,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2779,5 +2779,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.12.3/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/v19.0.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.12.3",
"version": "19.0.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,11 +47,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.12.0",
"@expo/eas-json": "18.12.0",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-json": "19.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.5.0",
"@expo/logger": "19.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -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.12.0",
"@expo/steps": "19.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -78,6 +78,7 @@
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
"debug": "4.4.3",
"diff": "7.0.0",
"dotenv": "16.3.1",
"env-paths": "2.2.0",
@@ -138,6 +139,7 @@
"@tsconfig/node20": "20.1.8",
"@types/cli-progress": "3.11.5",
"@types/dateformat": "3.0.1",
"@types/debug": "4.1.13",
"@types/diff": "6.0.0",
"@types/envinfo": "7.8.3",
"@types/express": "4.17.15",
@@ -219,6 +221,9 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"update": {
"description": "manage individual updates"
},
+19 -4
View File
@@ -1,5 +1,11 @@
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import {
Metadata,
Platform,
getInstalledExpoPackageVersionAsync,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
@@ -9,9 +15,7 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import {
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { isExpoUpdatesInstalled } from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
@@ -26,6 +30,16 @@ export async function collectMetadataAsync<T extends Platform>(
): Promise<Metadata> {
const channelObject = await resolveChannelAsync(ctx);
const distribution = ctx.buildProfile.distribution ?? BuildDistributionType.STORE;
const expoPackageVersionResult = await asyncResult(
getInstalledExpoPackageVersionAsync({
env: process.env,
projectDir: ctx.projectDir,
})
);
if (!expoPackageVersionResult.ok) {
Log.debug('Failed to resolve expo package version:');
Log.debug(expoPackageVersionResult.reason);
}
const metadata: Metadata = {
trackingContext: ctx.analyticsEventProperties,
...(await maybeResolveVersionsAsync(ctx)),
@@ -36,6 +50,7 @@ export async function collectMetadataAsync<T extends Platform>(
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
expoPackageVersion: expoPackageVersionResult.value,
...channelObject,
distribution,
appName: ctx.exp.name,
@@ -74,7 +74,10 @@ describe(getProjectIdAsync, () => {
);
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
jest.mocked(isExpoInstalled).mockReturnValue(true);
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
});
it('gets the project ID from app config if exists', async () => {
@@ -1,12 +1,14 @@
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { expoCommandAsync } from '../../../utils/expoCli';
import {
cloneTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../commands';
jest.mock('../../../utils/expoCli');
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
@@ -79,21 +81,11 @@ describe('commands', () => {
packageManager: 'npm',
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: false,
showSpinner: false,
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
['install', 'expo-updates', '@expo/metro-runtime'],
{ silent: true }
);
});
});
@@ -7,6 +7,7 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboar
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
import { expoCommandAsync } from '../../utils/expoCli';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
@@ -48,16 +49,8 @@ export async function installProjectDependenciesAsync(
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
spinner.text = `Installing ${chalk.bold(dependency)}`;
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
showSpinner: false,
});
}
spinner.text = `Installing ${dependencies.map(dep => chalk.bold(dep)).join(', ')}`;
await expoCommandAsync(projectDir, ['install', ...dependencies], { silent: true });
spinner.succeed(`Installed project dependencies`);
}
@@ -13,14 +13,15 @@ export default class AccountLogin extends EasCommand {
static override flags = {
// can pass either --sso or -s
sso: Flags.boolean({
description: 'Login with SSO',
description: 'Log in with SSO',
char: 's',
default: false,
}),
browser: Flags.boolean({
description: 'Login with your browser',
description: 'Log in with your browser (default; use --no-browser for CLI-based login)',
char: 'b',
default: false,
default: true,
allowNo: true,
}),
};
@@ -0,0 +1,205 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
import {
buildObserveNavigationRoutesJson,
buildObserveNavigationRoutesTable,
} from '../../../observe/formatNavigationRoutes';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveRoutes from '../routes';
jest.mock('../../../observe/fetchNavigationRoutes');
jest.mock('../../../observe/formatNavigationRoutes', () => {
const actual = jest.requireActual('../../../observe/formatNavigationRoutes');
return {
...actual,
buildObserveNavigationRoutesTable: jest.fn().mockReturnValue('table'),
buildObserveNavigationRoutesJson: jest.fn().mockReturnValue({}),
};
});
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveNavigationRoutesAsync = jest.mocked(fetchObserveNavigationRoutesAsync);
const mockBuildObserveNavigationRoutesTable = jest.mocked(buildObserveNavigationRoutesTable);
const mockBuildObserveNavigationRoutesJson = jest.mocked(buildObserveNavigationRoutesJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveRoutes, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveNavigationRoutesAsync.mockResolvedValue({
routes: [],
pageInfoByPlatform: new Map(),
});
});
function createCommand(argv: string[]): ObserveRoutes {
const command = new ObserveRoutes(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('queries both platforms by default with all three navigation metric full names', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveNavigationRoutesAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(options.limit).toBe(50);
const tableCall = mockBuildObserveNavigationRoutesTable.mock.calls[0];
expect(tableCall[1]).toEqual([
'expo.navigation.cold_ttr',
'expo.navigation.warm_ttr',
'expo.navigation.tti',
]);
expect(tableCall[2]).toEqual(['median', 'count']);
});
it('filters platform when --platform ios is provided', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
const command = createCommand([
'--metric',
'cold_ttr',
'--metric',
'cold_ttr',
'--metric',
'nav_tti',
]);
await command.runAsync();
expect(mockBuildObserveNavigationRoutesTable.mock.calls[0][1]).toEqual([
'expo.navigation.cold_ttr',
'expo.navigation.tti',
]);
});
it('passes --limit and --after through to the fetcher', async () => {
const command = createCommand(['--limit', '25', '--after', 'cursor-abc']);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.limit).toBe(25);
expect(options.after).toBe('cursor-abc');
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7']);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.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 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 options = mockFetchObserveNavigationRoutesAsync.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('rejects --days combined with --start', async () => {
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes app-version, update-id, and build-number filters', async () => {
const command = createCommand([
'--app-version',
'2.1.0',
'--update-id',
'update-xyz',
'--build-number',
'42',
]);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
expect(options.updateId).toBe('update-xyz');
expect(options.buildNumber).toBe('42');
});
it('passes --route-name flags through as routeNames array', async () => {
const command = createCommand(['--route-name', '/home', '--route-name', '/profile']);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.routeNames).toEqual(['/home', '/profile']);
});
it('dedupes duplicate --route-name flags', async () => {
const command = createCommand([
'--route-name',
'/home',
'--route-name',
'/profile',
'--route-name',
'/home',
]);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.routeNames).toEqual(['/home', '/profile']);
});
it('does not pass routeNames when --route-name is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.routeNames).toBeUndefined();
});
it('resolves --stat aliases and passes them through', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
await command.runAsync();
expect(mockBuildObserveNavigationRoutesTable.mock.calls[0][2]).toEqual(['p90', 'count']);
});
it('uses default JSON stats (median, p90, count) when --json is set without --stat', async () => {
const command = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveNavigationRoutesJson.mock.calls[0][2]).toEqual([
'median',
'p90',
'count',
]);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
+15 -35
View File
@@ -11,13 +11,17 @@ import {
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import {
allowedPlatformFlagValues,
appObservePlatformFromFlag,
appPlatformsFromFlag,
} from '../../observe/platforms';
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
@@ -44,40 +48,16 @@ export default class ObserveEvents extends EasCommand {
required: false,
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
})(),
platform: Flags.option({
description: 'Filter by platform',
options: allowedPlatformFlagValues,
})(),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
...ObservePlatformFlag,
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: 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',
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
+15 -31
View File
@@ -6,6 +6,14 @@ import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
buildObserveCustomEventNamesJson,
buildObserveCustomEventNamesTable,
@@ -14,7 +22,7 @@ import {
buildObserveCustomEventsJson,
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { allowedPlatformFlagValues, appObservePlatformFromFlag } from '../../observe/platforms';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -34,37 +42,15 @@ export default class ObserveLogs extends EasCommand {
};
static override flags = {
platform: Flags.option({
description: 'Filter by platform',
options: allowedPlatformFlagValues,
})(),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
...ObservePlatformFlag,
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: 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',
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
'session-id': Flags.string({
description: 'Filter by session ID',
}),
@@ -73,9 +59,7 @@ export default class ObserveLogs extends EasCommand {
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
default: false,
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -4,6 +4,11 @@ import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import {
StatisticKey,
buildObserveMetricsJson,
@@ -11,7 +16,7 @@ import {
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -41,10 +46,7 @@ export default class ObserveMetrics extends EasCommand {
static override description = 'display app performance metrics grouped by app version';
static override flags = {
platform: Flags.option({
description: 'Filter by platform',
options: allowedPlatformFlagValues,
})(),
...ObservePlatformFlag,
metric: Flags.option({
description: 'Metric name to display (can be specified multiple times).',
multiple: true,
@@ -55,22 +57,8 @@ export default class ObserveMetrics extends EasCommand {
multiple: true,
options: DEFAULT_STATS_JSON,
})(),
start: Flags.string({
description: 'Start of time range for metrics data (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range for metrics data (ISO date).',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...ObserveTimeRangeFlags,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -0,0 +1,149 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigationRoutes';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
NAVIGATION_METRIC_NAMES,
NavigationStatKey,
buildObserveNavigationRoutesJson,
buildObserveNavigationRoutesTable,
resolveNavigationStatKey,
} from '../../observe/formatNavigationRoutes';
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_ROUTES_LIMIT = 50;
const STAT_OPTIONS = ['median', 'med', 'p90', 'count', 'event_count', 'eventCount'];
const DEFAULT_STATS_TABLE: NavigationStatKey[] = ['median', 'count'];
const DEFAULT_STATS_JSON: NavigationStatKey[] = ['median', 'p90', 'count'];
export default class ObserveRoutes extends EasCommand {
static override hidden = true;
static override description =
'display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name';
static override flags = {
...ObservePlatformFlag,
metric: Flags.option({
description:
'Navigation metric to display (can be specified multiple times). Defaults to all three.',
multiple: true,
options: Object.keys(NAVIGATION_METRIC_ALIASES),
})(),
stat: Flags.option({
description: 'Statistic to display per metric (can be specified multiple times)',
multiple: true,
options: STAT_OPTIONS,
})(),
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_ROUTES_LIMIT,
limit: 200,
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
'build-number': Flags.string({
description: 'Filter by app build number',
}),
'route-name': Flags.string({
description:
'Filter by route name (can be specified multiple times to include several routes)',
multiple: true,
}),
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveRoutes);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveRoutes,
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
const metricNames = flags.metric?.length
? Array.from(new Set(flags.metric.map(resolveNavigationMetricName)))
: NAVIGATION_METRIC_NAMES;
const argumentsStat = flags.stat?.length
? Array.from(new Set(flags.stat.map(resolveNavigationStatKey)))
: undefined;
const routeNames = flags['route-name']?.length
? Array.from(new Set(flags['route-name']))
: undefined;
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
graphqlClient,
projectId,
{
startTime,
endTime,
platforms,
limit: flags.limit ?? DEFAULT_ROUTES_LIMIT,
...(flags.after && { after: flags.after }),
appVersion: flags['app-version'],
updateId: flags['update-id'],
buildNumber: flags['build-number'],
routeNames,
}
);
if (flags.json) {
const stats = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveNavigationRoutesJson(routes, metricNames, stats, pageInfoByPlatform)
);
} else {
const stats = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(
buildObserveNavigationRoutesTable(routes, metricNames, stats, {
daysBack,
startTime,
endTime,
pageInfoByPlatform,
})
);
}
}
}
@@ -1,11 +1,14 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import {
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -15,26 +18,9 @@ export default class ObserveVersions extends EasCommand {
static override description = 'display app versions with build and update details';
static override flags = {
platform: Flags.option({
description: 'Filter by platform',
options: allowedPlatformFlagValues,
})(),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show versions from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...ObservePlatformFlag,
...ObserveTimeRangeFlags,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -95,7 +95,10 @@ function mockTestProject(options: {
graphqlClient,
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
});
jest.mocked(isExpoInstalled).mockReturnValue(true);
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
}
const commandOptions = getMockOclifConfig({ root: '/test-project' });
@@ -36,6 +36,7 @@ import { ExpoConfigOptions, getPrivateExpoConfigAsync } from '../../project/expo
import { promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
import { easCliVersion } from '../../utils/easCli';
import { expoCommandAsync } from '../../utils/expoCli';
import GitClient from '../../vcs/clients/git';
export default class Onboarding extends EasCommand {
@@ -205,23 +206,12 @@ export default class Onboarding extends EasCommand {
});
if (!app.githubRepository) {
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
});
Log.log();
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'npx',
args: ['expo', 'install', 'expo-insights'],
});
Log.log();
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'npx',
args: ['expo', 'install', 'expo-dev-client'],
});
await expoCommandAsync(finalTargetProjectDirectory, [
'install',
'expo-updates',
'expo-insights',
'expo-dev-client',
]);
Log.log();
}
await vcsClient.trackFileAsync('package-lock.json');
@@ -10,7 +10,6 @@ import { ora } from '../../ora';
import { formatRemoteSessionInstructions } from '../../simulator/utils';
export default class SimulatorGet extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its device run session ID';
@@ -3,7 +3,10 @@ import { Flags } from '@oclif/core';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
AppPlatform,
DeviceRunSessionStatus,
@@ -18,6 +21,7 @@ import {
DeviceRunSessionRemoteConfig,
formatRemoteSessionInstructions,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import nullthrows from 'nullthrows';
@@ -28,6 +32,7 @@ const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
// so adding a new enum value in codegen fails the build until it is wired up here.
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
[DeviceRunSessionType.Argent]: 'argent',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
@@ -38,7 +43,6 @@ const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
) as Record<string, DeviceRunSessionType>;
export default class SimulatorStart extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
@@ -57,7 +61,7 @@ export default class SimulatorStart extends EasCommand {
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
...EASNonInteractiveFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
@@ -67,12 +71,17 @@ export default class SimulatorStart extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStart);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStart, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
@@ -148,11 +157,21 @@ export default class SimulatorStart extends EasCommand {
);
}
if (jsonFlag) {
printJsonOnlyOutput({
id: deviceRunSessionId,
type: flags.type,
jobRunUrl,
remoteConfig,
});
return;
}
Log.newLine();
Log.log(formatRemoteSessionInstructions(remoteConfig));
Log.newLine();
if (flags['non-interactive']) {
if (nonInteractive) {
Log.log(
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
);
@@ -6,7 +6,6 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi
import { ora } from '../../ora';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
+139 -2
View File
@@ -120,6 +120,7 @@ export type Account = {
/** Billing information. Only visible to members with the ADMIN or OWNER role. */
billing?: Maybe<Billing>;
billingPeriod: BillingPeriod;
concurrencyConsumers: Array<ConcurrencyConsumer>;
/** Convex team connections for this account */
convexTeamConnections: Array<ConvexTeamConnection>;
createdAt: Scalars['DateTime']['output'];
@@ -399,6 +400,15 @@ export type AccountBillingPeriodArgs = {
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
*/
export type AccountConcurrencyConsumersArgs = {
limit?: Scalars['Int']['input'];
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
@@ -2167,6 +2177,7 @@ export type AppObserveCustomEventNamesArgs = {
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
orderBy?: InputMaybe<AppObserveCustomEventNamesOrderBy>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
@@ -2209,10 +2220,23 @@ export type AppObserveUpdatesArgs = {
input: AppObserveUpdatesInput;
};
export type AppObserveAppBuildEmbeddedSummary = {
__typename?: 'AppObserveAppBuildEmbeddedSummary';
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
export type AppObserveAppBuildNumber = {
__typename?: 'AppObserveAppBuildNumber';
appBuildNumber: Scalars['String']['output'];
easBuilds: Array<AppObserveAppEasBuild>;
/**
* Summary restricted to events running this build's embedded bundle
* (no OTA update applied). Null when the build has no such events
* in the queried time range.
*/
embedded?: Maybe<AppObserveAppBuildEmbeddedSummary>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
uniqueUserCount: Scalars['Int']['output'];
@@ -2229,6 +2253,7 @@ export type AppObserveAppEasBuild = {
export type AppObserveAppUpdate = {
__typename?: 'AppObserveAppUpdate';
appUpdateId: Scalars['String']['output'];
appUpdateMessage?: Maybe<Scalars['String']['output']>;
easBuilds: Array<AppObserveAppEasBuild>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
@@ -2259,6 +2284,7 @@ export type AppObserveCustomEvent = {
appEasBuildId?: Maybe<Scalars['String']['output']>;
appIdentifier: Scalars['String']['output'];
appUpdateId?: Maybe<Scalars['String']['output']>;
appUpdateMessage?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
@@ -2300,6 +2326,7 @@ export type AppObserveCustomEventCountsInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
eventName: Scalars['String']['input'];
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform: AppObservePlatform;
startTime: Scalars['DateTime']['input'];
};
@@ -2325,6 +2352,7 @@ export type AppObserveCustomEventListFilter = {
endTime?: InputMaybe<Scalars['DateTime']['input']>;
environment?: InputMaybe<Scalars['String']['input']>;
eventName?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
sessionId?: InputMaybe<Scalars['String']['input']>;
@@ -2369,6 +2397,7 @@ export type AppObserveEvent = {
appIdentifier: Scalars['String']['output'];
appName: Scalars['String']['output'];
appUpdateId?: Maybe<Scalars['String']['output']>;
appUpdateMessage?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
@@ -2423,6 +2452,7 @@ export type AppObserveEventsFilter = {
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime?: InputMaybe<Scalars['DateTime']['input']>;
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
metricName?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
routeName?: InputMaybe<Scalars['String']['input']>;
@@ -2479,6 +2509,7 @@ export type AppObserveNavigationRoutesFilter = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform: AppObservePlatform;
routeNames?: InputMaybe<Array<Scalars['String']['input']>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2556,6 +2587,7 @@ export type AppObserveTimeSeriesInput = {
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
metricName: Scalars['String']['input'];
platform: AppObservePlatform;
routeName?: InputMaybe<Scalars['String']['input']>;
@@ -2577,6 +2609,7 @@ export type AppObserveTimeSeriesStatistics = {
export type AppObserveUpdate = {
__typename?: 'AppObserveUpdate';
appUpdateId: Scalars['String']['output'];
appUpdateMessage?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
downloadCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
@@ -3470,6 +3503,16 @@ export enum AppsFilter {
New = 'NEW'
}
export type ArgentRunSessionRemoteConfig = {
__typename?: 'ArgentRunSessionRemoteConfig';
toolsUrl: Scalars['String']['output'];
/**
* URL of the web preview surface for the session. Null when web previews are
* not available for the platform (e.g. Android).
*/
webPreviewUrl?: Maybe<Scalars['String']['output']>;
};
export type AscApiKeyInput = {
issuerIdentifier: Scalars['String']['input'];
keyIdentifier: Scalars['String']['input'];
@@ -4029,6 +4072,7 @@ export type BuildMetadataInput = {
developmentClient?: InputMaybe<Scalars['Boolean']['input']>;
distribution?: InputMaybe<DistributionType>;
environment?: InputMaybe<Scalars['String']['input']>;
expoPackageVersion?: InputMaybe<Scalars['String']['input']>;
fingerprintHash?: InputMaybe<Scalars['String']['input']>;
fingerprintSource?: InputMaybe<FingerprintSourceInput>;
gitCommitHash?: InputMaybe<Scalars['String']['input']>;
@@ -4178,6 +4222,7 @@ export enum BuildPhase {
EasBuildInternal = 'EAS_BUILD_INTERNAL',
FailBuild = 'FAIL_BUILD',
FixGradlew = 'FIX_GRADLEW',
GradleBuildProfile = 'GRADLE_BUILD_PROFILE',
InstallCustomTools = 'INSTALL_CUSTOM_TOOLS',
InstallDependencies = 'INSTALL_DEPENDENCIES',
InstallPods = 'INSTALL_PODS',
@@ -4186,6 +4231,7 @@ export enum BuildPhase {
OnBuildErrorHook = 'ON_BUILD_ERROR_HOOK',
OnBuildSuccessHook = 'ON_BUILD_SUCCESS_HOOK',
ParseCustomWorkflowConfig = 'PARSE_CUSTOM_WORKFLOW_CONFIG',
ParseXcactivitylog = 'PARSE_XCACTIVITYLOG',
PostInstallHook = 'POST_INSTALL_HOOK',
Prebuild = 'PREBUILD',
PrepareArtifacts = 'PREPARE_ARTIFACTS',
@@ -4409,6 +4455,8 @@ export type Concurrencies = {
total: Scalars['Int']['output'];
};
export type ConcurrencyConsumer = Build | JobRun;
export enum ContinentCode {
Af = 'AF',
An = 'AN',
@@ -5229,7 +5277,7 @@ export type DeviceRunSessionQueryByIdArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export enum DeviceRunSessionStatus {
Errored = 'ERRORED',
@@ -5240,6 +5288,7 @@ export enum DeviceRunSessionStatus {
export enum DeviceRunSessionType {
AgentDevice = 'AGENT_DEVICE',
Argent = 'ARGENT',
ServeSim = 'SERVE_SIM'
}
@@ -5999,6 +6048,67 @@ export type EditUpdateBranchInput = {
newName: Scalars['String']['input'];
};
export type EmbeddedUpdate = {
__typename?: 'EmbeddedUpdate';
channel: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
/** The manifest UUID baked into the binary by expo-updates at build time. */
id: Scalars['ID']['output'];
launchAsset: EmbeddedUpdateAsset;
platform: AppPlatform;
runtimeVersion: Scalars['String']['output'];
};
export type EmbeddedUpdateAsset = {
__typename?: 'EmbeddedUpdateAsset';
contentType: Scalars['String']['output'];
fileSHA256: Scalars['String']['output'];
fileSize: Scalars['Int']['output'];
finalFileSize?: Maybe<Scalars['Int']['output']>;
id: Scalars['ID']['output'];
storageKey: Scalars['String']['output'];
};
export type EmbeddedUpdateAssetMutation = {
__typename?: 'EmbeddedUpdateAssetMutation';
/**
* Returns a presigned POST URL for uploading an embedded update bundle.
* Requires PUBLISH permission on the app.
*/
getSignedEmbeddedUpdateAssetUploadSpecifications: EmbeddedUpdateAssetUploadSpec;
};
export type EmbeddedUpdateAssetMutationGetSignedEmbeddedUpdateAssetUploadSpecificationsArgs = {
appId: Scalars['ID']['input'];
contentType: Scalars['String']['input'];
embeddedUpdateId: Scalars['ID']['input'];
};
export type EmbeddedUpdateAssetUploadSpec = {
__typename?: 'EmbeddedUpdateAssetUploadSpec';
/** Form fields that must be included with the POST request alongside the file. */
fields: Scalars['JSONObject']['output'];
/** Presigned POST URL targeting the upload bucket. Valid for one hour. */
presignedUrl: Scalars['String']['output'];
/** Storage key (`{appId}/{embeddedUpdateId}`). Same key in both upload and destination buckets. */
storageKey: Scalars['String']['output'];
};
export type EmbeddedUpdateMutation = {
__typename?: 'EmbeddedUpdateMutation';
/**
* Register an embedded bundle as the launch asset for a given app/platform/channel.
* Returns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet.
*/
uploadEmbeddedUpdate: EmbeddedUpdate;
};
export type EmbeddedUpdateMutationUploadEmbeddedUpdateArgs = {
input: UploadEmbeddedUpdateInput;
};
export enum EntityTypeName {
AccountEntity = 'AccountEntity',
AccountSsoConfigurationEntity = 'AccountSSOConfigurationEntity',
@@ -7263,6 +7373,7 @@ export type JobRun = {
createdAt: Scalars['DateTime']['output'];
displayName?: Maybe<Scalars['String']['output']>;
endedAt?: Maybe<Scalars['DateTime']['output']>;
enqueuedAt?: Maybe<Scalars['DateTime']['output']>;
errors: Array<JobRunError>;
expiresAt: Scalars['DateTime']['output'];
gitCommitHash?: Maybe<Scalars['String']['output']>;
@@ -7274,6 +7385,8 @@ export type JobRun = {
logFileUrls: Array<Scalars['String']['output']>;
name: Scalars['String']['output'];
priority: JobRunPriority;
/** String describing the worker profile used to run this job run. */
resourceClassDisplayName: Scalars['String']['output'];
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: JobRunStatus;
updateGroups: Array<Array<Update>>;
@@ -8153,6 +8266,9 @@ export type RootMutation = {
echoTurn: EchoTurnMutation;
/** Mutations for Echo versions */
echoVersion: EchoVersionMutation;
/** Mutations that register embedded update bundles for bundle diffing. */
embeddedUpdate: EmbeddedUpdateMutation;
embeddedUpdateAsset: EmbeddedUpdateAssetMutation;
/** Mutations that create and delete EnvironmentSecrets */
environmentSecret: EnvironmentSecretMutation;
/** Mutations that create and delete EnvironmentVariables */
@@ -9700,6 +9816,16 @@ export type UpdatesMetricsData = {
labels: Array<Scalars['String']['output']>;
};
export type UploadEmbeddedUpdateInput = {
appId: Scalars['ID']['input'];
channel: Scalars['String']['input'];
/** UUID baked into the binary by expo-updates at build time (from app.manifest id field). */
embeddedUpdateId: Scalars['ID']['input'];
platform: AppPlatform;
runtimeVersion: Scalars['String']['input'];
turtleBuildId?: InputMaybe<Scalars['ID']['input']>;
};
export type UploadSession = {
__typename?: 'UploadSession';
/** Create an Upload Session for a specific account */
@@ -12780,7 +12906,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
}>;
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -12903,6 +13029,17 @@ export type AppObserveCustomEventNamesQueryVariables = Exact<{
export type AppObserveCustomEventNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', customEventNames: { __typename?: 'AppObserveCustomEventNames', isTruncated: boolean, names: Array<{ __typename?: 'AppObserveCustomEventName', eventName: string, count: number }> } } } } };
export type AppObserveNavigationRoutesQueryVariables = Exact<{
appId: Scalars['String']['input'];
filter: AppObserveNavigationRoutesFilter;
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AppObserveNavigationRoutesOrderBy>;
}>;
export type AppObserveNavigationRoutesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', navigationRoutes: { __typename?: 'AppObserveNavigationRoutesConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveNavigationRouteEdge', cursor: string, node: { __typename?: 'AppObserveNavigationRoute', routeName: string, coldTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, warmTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, tti: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null } } }> } } } } };
export type GetAssetMetadataQueryVariables = Exact<{
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
}>;
@@ -34,6 +34,10 @@ export const DeviceRunSessionQuery = {
agentDeviceRemoteSessionToken
webPreviewUrl
}
... on ArgentRunSessionRemoteConfig {
toolsUrl
webPreviewUrl
}
... on ServeSimRunSessionRemoteConfig {
previewUrl
streamUrl
@@ -10,6 +10,9 @@ import {
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObserveNavigationRoute,
AppObserveNavigationRoutesFilter,
AppObserveNavigationRoutesOrderBy,
AppObservePlatform,
AppObserveReleasesInput,
AppObserveTimeSeriesInput,
@@ -133,6 +136,31 @@ type AppObserveCustomEventNamesQueryVariables = {
environment?: string;
};
type AppObserveNavigationRoutesQuery = {
app: {
byId: {
id: string;
observe: {
navigationRoutes: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveNavigationRoute;
}>;
};
};
};
};
};
type AppObserveNavigationRoutesQueryVariables = {
appId: string;
filter: AppObserveNavigationRoutesFilter;
first?: number;
after?: string;
orderBy?: AppObserveNavigationRoutesOrderBy;
};
export const ObserveQuery = {
async timeSeriesAsync(
graphqlClient: ExpoGraphqlClient,
@@ -398,4 +426,68 @@ export const ObserveQuery = {
return data.app.byId.observe.customEventNames;
},
async navigationRoutesAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveNavigationRoutesQueryVariables
): Promise<{ routes: AppObserveNavigationRoute[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveNavigationRoutesQuery, AppObserveNavigationRoutesQueryVariables>(
gql`
query AppObserveNavigationRoutes(
$appId: String!
$filter: AppObserveNavigationRoutesFilter!
$first: Int
$after: String
$orderBy: AppObserveNavigationRoutesOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
navigationRoutes(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
routeName
coldTtr {
count
median
p90
}
warmTtr {
count
median
p90
}
tti {
count
median
p90
}
}
}
}
}
}
}
}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.navigationRoutes;
return {
routes: edges.map(edge => edge.node),
pageInfo,
};
},
};
+4 -3
View File
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import debug from 'debug';
import figures from 'figures';
import { boolish } from 'getenv';
import logSymbols from 'log-symbols';
@@ -6,8 +7,7 @@ import terminalLink from 'terminal-link';
type Color = (...text: string[]) => string;
// eslint-disable-next-line eslint-import/no-extraneous-dependencies
const nodeDebug = require('debug')('eas:log:debug') as (...args: any[]) => void;
const nodeDebug = debug('eas:log:debug');
export default class Log {
public static readonly isDebug = boolish('EXPO_DEBUG', false);
@@ -48,7 +48,8 @@ export default class Log {
Log.consoleLog(...args);
} else {
Log.updateIsLastLineNewLine(args);
nodeDebug(...args);
const [first, ...rest] = args;
nodeDebug(first, ...rest);
}
}
@@ -0,0 +1,190 @@
import {
AppObserveEventsOrderByDirection,
AppObserveNavigationRoute,
AppObserveNavigationRoutesOrderByField,
AppObservePlatform,
AppPlatform,
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
jest.mock('../../graphql/queries/ObserveQuery');
jest.mock('../../log');
function makeRoute(routeName: string): AppObserveNavigationRoute {
return {
__typename: 'AppObserveNavigationRoute' as const,
routeName,
coldTtr: { __typename: 'AppObserveNavigationStat' as const, count: 10, median: 0.5, p90: 0.9 },
warmTtr: { __typename: 'AppObserveNavigationStat' as const, count: 8, median: 0.3, p90: 0.6 },
tti: { __typename: 'AppObserveNavigationStat' as const, count: 12, median: 0.45, p90: 0.7 },
};
}
describe('fetchObserveNavigationRoutesAsync', () => {
const mockNavigationRoutesAsync = jest.mocked(ObserveQuery.navigationRoutesAsync);
const mockGraphqlClient = {} as any;
beforeEach(() => {
mockNavigationRoutesAsync.mockClear();
mockNavigationRoutesAsync.mockResolvedValue({
routes: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
it('passes appId, time range, limit, and filter values for each platform', async () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
limit: 25,
appVersion: '2.1.0',
updateId: 'update-xyz',
buildNumber: '42',
});
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(1);
const call = mockNavigationRoutesAsync.mock.calls[0][1];
expect(call.appId).toBe('project-123');
expect(call.first).toBe(25);
expect(call.filter).toEqual({
platform: AppObservePlatform.Ios,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
appVersion: '2.1.0',
appUpdateId: 'update-xyz',
appBuildNumber: '42',
});
});
it('defaults orderBy to NAVIGATION_COUNT DESC', async () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
limit: 50,
});
const call = mockNavigationRoutesAsync.mock.calls[0][1];
expect(call.orderBy).toEqual({
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('does not send optional filters when not provided', async () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
limit: 50,
});
const filter = mockNavigationRoutesAsync.mock.calls[0][1].filter;
expect(filter).not.toHaveProperty('appVersion');
expect(filter).not.toHaveProperty('appUpdateId');
expect(filter).not.toHaveProperty('appBuildNumber');
expect(filter).not.toHaveProperty('routeNames');
});
it('forwards routeNames filter when provided', async () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
limit: 50,
routeNames: ['/home', '/profile'],
});
expect(mockNavigationRoutesAsync.mock.calls[0][1].filter.routeNames).toEqual([
'/home',
'/profile',
]);
});
it('does not send routeNames when array is empty', async () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
limit: 50,
routeNames: [],
});
expect(mockNavigationRoutesAsync.mock.calls[0][1].filter).not.toHaveProperty('routeNames');
});
it('forwards after cursor when provided', async () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
limit: 50,
after: 'cursor-abc',
});
expect(mockNavigationRoutesAsync.mock.calls[0][1].after).toBe('cursor-abc');
});
it('fans out across multiple platforms and tags each route with its platform', async () => {
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
if (vars.filter.platform === AppObservePlatform.Android) {
return {
routes: [makeRoute('/android-home')],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
};
}
return {
routes: [makeRoute('/ios-home')],
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'ios-cursor' },
};
});
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
mockGraphqlClient,
'project-123',
{
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios, AppPlatform.Android],
limit: 50,
}
);
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(2);
expect(routes).toHaveLength(2);
const byName = new Map(routes.map(r => [r.route.routeName, r.platform]));
expect(byName.get('/android-home')).toBe(AppPlatform.Android);
expect(byName.get('/ios-home')).toBe(AppPlatform.Ios);
expect(pageInfoByPlatform.get(AppPlatform.Ios)?.hasNextPage).toBe(true);
expect(pageInfoByPlatform.get(AppPlatform.Android)?.hasNextPage).toBe(false);
});
it('handles partial failures gracefully', async () => {
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
if (vars.filter.platform === AppObservePlatform.Android) {
throw new Error('Network error');
}
return {
routes: [makeRoute('/home')],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
};
});
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
mockGraphqlClient,
'project-123',
{
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios, AppPlatform.Android],
limit: 50,
}
);
expect(routes).toHaveLength(1);
expect(routes[0].platform).toBe(AppPlatform.Ios);
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
});
});
@@ -71,12 +71,12 @@ describe(buildObserveMetricsTable, () => {
"Med values (event count)
iOS
App Version Cold Launch TTI 
----------- ----------- ----------
1.2.0 0.35s (110) 1.32s (90)
App Version Cold Launch Startup TTI
----------- ----------- -----------
1.2.0 0.35s (110) 1.32s (90)
Android
App Version Cold Launch TTI 
App Version Cold Launch Startup TTI
----------- ----------- -----------
1.1.0 0.25s (120) 1.12s (100)"
`);
@@ -93,9 +93,9 @@ describe(buildObserveMetricsTable, () => {
"Med values (event count)
iOS
App Version Cold Launch TTI 
----------- ----------- -----
2.0.0 - (-) - (-)"
App Version Cold Launch Startup TTI
----------- ----------- -----------
2.0.0 - (-) - (-) "
`);
});
@@ -0,0 +1,210 @@
import { AppPlatform } from '../../graphql/generated';
import { NavigationRouteWithPlatform } from '../fetchNavigationRoutes';
import {
buildObserveNavigationRoutesJson,
buildObserveNavigationRoutesTable,
isNavigationMetric,
resolveNavigationStatKey,
} from '../formatNavigationRoutes';
function makeRoute(
routeName: string,
platform: AppPlatform,
overrides?: Partial<{
coldTtr: { count: number; median: number | null; p90: number | null };
warmTtr: { count: number; median: number | null; p90: number | null };
tti: { count: number; median: number | null; p90: number | null };
}>
): NavigationRouteWithPlatform {
return {
platform,
route: {
__typename: 'AppObserveNavigationRoute' as const,
routeName,
coldTtr: {
__typename: 'AppObserveNavigationStat' as const,
count: 10,
median: 0.5,
p90: 0.9,
...overrides?.coldTtr,
},
warmTtr: {
__typename: 'AppObserveNavigationStat' as const,
count: 8,
median: 0.3,
p90: 0.6,
...overrides?.warmTtr,
},
tti: {
__typename: 'AppObserveNavigationStat' as const,
count: 12,
median: 0.45,
p90: 0.7,
...overrides?.tti,
},
},
};
}
describe(isNavigationMetric, () => {
it('returns true for each known navigation metric full name', () => {
expect(isNavigationMetric('expo.navigation.cold_ttr')).toBe(true);
expect(isNavigationMetric('expo.navigation.warm_ttr')).toBe(true);
expect(isNavigationMetric('expo.navigation.tti')).toBe(true);
});
it('returns false for app-startup metrics or unknown names', () => {
expect(isNavigationMetric('expo.app_startup.tti')).toBe(false);
expect(isNavigationMetric('cold_ttr')).toBe(false);
expect(isNavigationMetric('unknown')).toBe(false);
});
});
describe(resolveNavigationStatKey, () => {
it('resolves aliases', () => {
expect(resolveNavigationStatKey('med')).toBe('median');
expect(resolveNavigationStatKey('median')).toBe('median');
expect(resolveNavigationStatKey('p90')).toBe('p90');
expect(resolveNavigationStatKey('count')).toBe('count');
expect(resolveNavigationStatKey('eventCount')).toBe('count');
expect(resolveNavigationStatKey('event_count')).toBe('count');
});
it('throws on unknown stat', () => {
expect(() => resolveNavigationStatKey('min')).toThrow('Unknown statistic');
});
});
describe(buildObserveNavigationRoutesTable, () => {
it('returns a yellow warning when no routes are present', () => {
const output = buildObserveNavigationRoutesTable(
[],
['expo.navigation.cold_ttr'],
['median', 'count']
);
expect(output).toContain('No navigation routes found.');
});
it('formats routes grouped by platform with merged (med + count) cells', () => {
const routes = [
makeRoute('/home', AppPlatform.Ios),
makeRoute('/profile', AppPlatform.Ios, {
coldTtr: { count: 5, median: 0.7, p90: 1.0 },
}),
makeRoute('/home', AppPlatform.Android),
];
const output = buildObserveNavigationRoutesTable(
routes,
['expo.navigation.cold_ttr', 'expo.navigation.warm_ttr', 'expo.navigation.tti'],
['median', 'count'],
{ daysBack: 7 }
);
expect(output).toContain('Med values (navigation count) for the last 7 days');
expect(output).toContain('Cold TTR');
expect(output).toContain('Warm TTR');
expect(output).toContain('Nav TTI');
expect(output).toContain('/home');
expect(output).toContain('/profile');
expect(output).toContain('0.50s (10)');
expect(output).toContain('0.70s (5)');
expect(output).toContain('iOS');
expect(output).toContain('Android');
});
it('renders separate columns when count is omitted from stats', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const output = buildObserveNavigationRoutesTable(
routes,
['expo.navigation.cold_ttr'],
['median', 'p90']
);
expect(output).toContain('Med, P90 values');
expect(output).toContain('Cold TTR Med');
expect(output).toContain('Cold TTR P90');
expect(output).toContain('0.50s');
expect(output).toContain('0.90s');
});
it('renders count-only column when only count stat is requested', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const output = buildObserveNavigationRoutesTable(
routes,
['expo.navigation.cold_ttr'],
['count']
);
expect(output).toContain('Navigation count values');
expect(output).toContain('Cold TTR Count');
expect(output).toContain('10');
});
it('shows a next-page hint per platform when hasNextPage is true', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const pageInfoByPlatform = new Map([
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
]);
const output = buildObserveNavigationRoutesTable(
routes,
['expo.navigation.cold_ttr'],
['median', 'count'],
{ pageInfoByPlatform }
);
expect(output).toContain('Next page (iOS): --after cursor-ios');
});
});
describe(buildObserveNavigationRoutesJson, () => {
it('maps routes to the requested metrics and stats, including pageInfoByPlatform', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const pageInfoByPlatform = new Map([
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
]);
const result = buildObserveNavigationRoutesJson(
routes,
['expo.navigation.cold_ttr', 'expo.navigation.tti'],
['median', 'p90', 'count'],
pageInfoByPlatform
);
expect(result.routes).toEqual([
{
routeName: '/home',
platform: AppPlatform.Ios,
metrics: {
'expo.navigation.cold_ttr': { median: 0.5, p90: 0.9, count: 10 },
'expo.navigation.tti': { median: 0.45, p90: 0.7, count: 12 },
},
},
]);
expect(result.pageInfoByPlatform).toEqual({
[AppPlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
});
});
it('returns null for stats when the underlying value is null', () => {
const routes = [
makeRoute('/home', AppPlatform.Ios, {
coldTtr: { count: 0, median: null, p90: null },
}),
];
const result = buildObserveNavigationRoutesJson(
routes,
['expo.navigation.cold_ttr'],
['median', 'p90', 'count'],
new Map()
);
expect(result.routes[0].metrics['expo.navigation.cold_ttr']).toEqual({
median: null,
p90: null,
count: 0,
});
});
});
@@ -1,4 +1,8 @@
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
import {
getMetricDisplayName,
resolveMetricName,
resolveNavigationMetricName,
} from '../metricNames';
describe(resolveMetricName, () => {
it('resolves short alias "tti" to full metric name', () => {
@@ -37,15 +41,46 @@ describe(resolveMetricName, () => {
});
});
describe(resolveNavigationMetricName, () => {
it('resolves short aliases to navigation metric full names', () => {
expect(resolveNavigationMetricName('cold_ttr')).toBe('expo.navigation.cold_ttr');
expect(resolveNavigationMetricName('warm_ttr')).toBe('expo.navigation.warm_ttr');
expect(resolveNavigationMetricName('nav_tti')).toBe('expo.navigation.tti');
});
it('passes through full navigation metric names unchanged', () => {
expect(resolveNavigationMetricName('expo.navigation.cold_ttr')).toBe(
'expo.navigation.cold_ttr'
);
expect(resolveNavigationMetricName('expo.navigation.warm_ttr')).toBe(
'expo.navigation.warm_ttr'
);
expect(resolveNavigationMetricName('expo.navigation.tti')).toBe('expo.navigation.tti');
});
it('throws on unknown alias or non-navigation metric', () => {
expect(() => resolveNavigationMetricName('unknown')).toThrow('Unknown navigation metric');
expect(() => resolveNavigationMetricName('expo.app_startup.tti')).toThrow(
'Unknown navigation metric'
);
});
});
describe(getMetricDisplayName, () => {
it('returns short display name for known metrics', () => {
it('returns short display name for known app-startup 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.tti')).toBe('Startup TTI');
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('Startup TTR');
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
});
it('returns short display name for known navigation metrics', () => {
expect(getMetricDisplayName('expo.navigation.cold_ttr')).toBe('Nav Cold TTR');
expect(getMetricDisplayName('expo.navigation.warm_ttr')).toBe('Nav Warm TTR');
expect(getMetricDisplayName('expo.navigation.tti')).toBe('Nav TTI');
});
it('returns the full metric name for unknown metrics', () => {
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
});
+2 -6
View File
@@ -1,6 +1,6 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
import { AppPlatform } from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import {
@@ -10,11 +10,7 @@ import {
UpdateIdsMap,
makeMetricsKey,
} from './formatMetrics';
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
import { appPlatformToObservePlatform } from './platforms';
export function validateDateFlag(value: string, flagName: string): void {
const parsed = new Date(value);
@@ -0,0 +1,88 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveEventsOrderByDirection,
AppObserveNavigationRoute,
AppObserveNavigationRoutesOrderBy,
AppObserveNavigationRoutesOrderByField,
AppPlatform,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import { appPlatformToObservePlatform } from './platforms';
export interface NavigationRouteWithPlatform {
platform: AppPlatform;
route: AppObserveNavigationRoute;
}
export interface FetchNavigationRoutesOptions {
startTime: string;
endTime: string;
platforms: AppPlatform[];
limit: number;
after?: string;
appVersion?: string;
updateId?: string;
buildNumber?: string;
routeNames?: string[];
orderBy?: AppObserveNavigationRoutesOrderBy;
}
export interface FetchNavigationRoutesResult {
routes: NavigationRouteWithPlatform[];
pageInfoByPlatform: Map<AppPlatform, PageInfo>;
}
export async function fetchObserveNavigationRoutesAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchNavigationRoutesOptions
): Promise<FetchNavigationRoutesResult> {
const orderBy: AppObserveNavigationRoutesOrderBy = options.orderBy ?? {
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
direction: AppObserveEventsOrderByDirection.Desc,
};
const queries = options.platforms.map(async appPlatform => {
const observePlatform = appPlatformToObservePlatform[appPlatform];
try {
const result = await ObserveQuery.navigationRoutesAsync(graphqlClient, {
appId,
filter: {
platform: observePlatform,
startTime: options.startTime,
endTime: options.endTime,
...(options.appVersion && { appVersion: options.appVersion }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
...(options.routeNames?.length && { routeNames: options.routeNames }),
},
first: options.limit,
...(options.after && { after: options.after }),
orderBy,
});
return { appPlatform, ...result };
} catch (error: any) {
Log.warn(`Failed to fetch navigation routes on ${observePlatform}: ${error.message}`);
return null;
}
});
const results = await Promise.all(queries);
const routes: NavigationRouteWithPlatform[] = [];
const pageInfoByPlatform = new Map<AppPlatform, PageInfo>();
for (const result of results) {
if (!result) {
continue;
}
pageInfoByPlatform.set(result.appPlatform, result.pageInfo);
for (const route of result.routes) {
routes.push({ platform: result.appPlatform, route });
}
}
return { routes, pageInfoByPlatform };
}
+51
View File
@@ -0,0 +1,51 @@
import { Flags } from '@oclif/core';
import { allowedPlatformFlagValues } from './platforms';
export const ObserveProjectIdFlag = {
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
};
export const ObservePlatformFlag = {
platform: Flags.option({
description: 'Filter by platform',
options: allowedPlatformFlagValues,
})(),
};
export const ObserveTimeRangeFlags = {
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show results from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
};
export const ObserveAfterFlag = {
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
};
export const ObserveAppVersionFlag = {
'app-version': Flags.string({
description: 'Filter by app version',
}),
};
export const ObserveUpdateIdFlag = {
'update-id': Flags.string({
description: 'Filter by EAS update ID',
}),
};
@@ -0,0 +1,239 @@
import chalk from 'chalk';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import renderTextTable from '../utils/renderTextTable';
import { NavigationRouteWithPlatform } from './fetchNavigationRoutes';
import { buildTimeRangeDescription } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
export type NavigationStatKey = 'median' | 'p90' | 'count';
export const NAVIGATION_STAT_ALIASES: Record<string, NavigationStatKey> = {
med: 'median',
median: 'median',
p90: 'p90',
count: 'count',
event_count: 'count',
eventCount: 'count',
};
export const NAVIGATION_STAT_DISPLAY_NAMES: Record<NavigationStatKey, string> = {
median: 'Med',
p90: 'P90',
count: 'Count',
};
export function resolveNavigationStatKey(input: string): NavigationStatKey {
const resolved = NAVIGATION_STAT_ALIASES[input];
if (resolved) {
return resolved;
}
throw new EasCommandError(
`Unknown statistic: "${input}". Valid options: ${Object.keys(NAVIGATION_STAT_ALIASES).join(
', '
)}`
);
}
type NavigationRouteField = 'coldTtr' | 'warmTtr' | 'tti';
const FULL_METRIC_NAME_TO_FIELD: Record<string, NavigationRouteField> = {
'expo.navigation.cold_ttr': 'coldTtr',
'expo.navigation.warm_ttr': 'warmTtr',
'expo.navigation.tti': 'tti',
};
export const NAVIGATION_METRIC_NAMES = Object.keys(FULL_METRIC_NAME_TO_FIELD);
export function isNavigationMetric(metricName: string): boolean {
return metricName in FULL_METRIC_NAME_TO_FIELD;
}
function formatStatValue(stat: NavigationStatKey, value: number | null | undefined): string {
if (value == null) {
return '-';
}
if (stat === 'count') {
return String(value);
}
return `${value.toFixed(2)}s`;
}
function formatMergedCell(
stat: NavigationStatKey,
statValue: number | null | undefined,
count: number | null | undefined
): string {
const formatted = formatStatValue(stat, statValue);
const countStr = count != null ? String(count) : '-';
return `${formatted} (${countStr})`;
}
export interface NavigationRouteValuesJson {
routeName: string;
platform: AppPlatform;
metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>>;
}
export interface ObserveNavigationRoutesJsonOutput {
routes: NavigationRouteValuesJson[];
pageInfoByPlatform: Record<string, { hasNextPage: boolean; endCursor: string | null }>;
}
function metricStat(
node: NavigationRouteWithPlatform,
metricName: string
): { count: number; median: number | null; p90: number | null } {
const field = FULL_METRIC_NAME_TO_FIELD[metricName];
if (!field) {
throw new EasCommandError(`Unknown navigation metric: "${metricName}"`);
}
const stat = node.route[field];
return {
count: stat.count,
median: stat.median ?? null,
p90: stat.p90 ?? null,
};
}
export function buildObserveNavigationRoutesJson(
routes: NavigationRouteWithPlatform[],
metricNames: string[],
stats: NavigationStatKey[],
pageInfoByPlatform: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>
): ObserveNavigationRoutesJsonOutput {
const jsonRoutes: NavigationRouteValuesJson[] = routes.map(node => {
const metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>> = {};
for (const metricName of metricNames) {
const values = metricStat(node, metricName);
const statValues: Partial<Record<NavigationStatKey, number | null>> = {};
for (const stat of stats) {
statValues[stat] = values[stat] ?? null;
}
metrics[metricName] = statValues;
}
return {
routeName: node.route.routeName,
platform: node.platform,
metrics,
};
});
const pageInfoByPlatformOutput: Record<
string,
{ hasNextPage: boolean; endCursor: string | null }
> = {};
for (const [platform, pageInfo] of pageInfoByPlatform) {
pageInfoByPlatformOutput[platform] = {
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor ?? null,
};
}
return { routes: jsonRoutes, pageInfoByPlatform: pageInfoByPlatformOutput };
}
export interface BuildNavigationRoutesTableOptions {
daysBack?: number;
startTime?: string;
endTime?: string;
pageInfoByPlatform?: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>;
}
export function buildObserveNavigationRoutesTable(
routes: NavigationRouteWithPlatform[],
metricNames: string[],
stats: NavigationStatKey[],
options?: BuildNavigationRoutesTableOptions
): string {
if (routes.length === 0) {
return chalk.yellow('No navigation routes found.');
}
const displayStats = stats.filter(s => s !== 'count');
const hasCount = stats.includes('count');
const statsDesc =
displayStats.length > 0
? displayStats.map(s => NAVIGATION_STAT_DISPLAY_NAMES[s]).join(', ')
: 'Navigation count';
const timeDesc = buildTimeRangeDescription({
daysBack: options?.daysBack,
startTime: options?.startTime,
endTime: options?.endTime,
});
const countSuffix = hasCount && displayStats.length > 0 ? ' (navigation count)' : '';
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
const byPlatform = new Map<AppPlatform, NavigationRouteWithPlatform[]>();
for (const node of routes) {
if (!byPlatform.has(node.platform)) {
byPlatform.set(node.platform, []);
}
byPlatform.get(node.platform)!.push(node);
}
const metricHeaders: string[] = [];
for (const metricName of metricNames) {
const displayName = getMetricDisplayName(metricName);
if (displayStats.length > 0 && hasCount) {
for (const stat of displayStats) {
metricHeaders.push(
displayStats.length > 1
? `${displayName} ${NAVIGATION_STAT_DISPLAY_NAMES[stat]}`
: displayName
);
}
} else {
for (const stat of displayStats.length > 0
? displayStats
: (['count'] as NavigationStatKey[])) {
metricHeaders.push(
displayStats.length === 0
? `${displayName} Count`
: `${displayName} ${NAVIGATION_STAT_DISPLAY_NAMES[stat]}`
);
}
}
}
const headers = ['Route', ...metricHeaders];
const sections: string[] = [chalk.bold(summaryLine)];
for (const [platform, platformRoutes] of byPlatform) {
sections.push('');
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
const rows: string[][] = platformRoutes.map(node => {
const cells: string[] = [];
for (const metricName of metricNames) {
const values = metricStat(node, metricName);
if (displayStats.length > 0 && hasCount) {
for (const stat of displayStats) {
cells.push(formatMergedCell(stat, values[stat], values.count));
}
} else {
for (const stat of displayStats.length > 0
? displayStats
: (['count'] as NavigationStatKey[])) {
cells.push(formatStatValue(stat, values[stat]));
}
}
}
return [node.route.routeName, ...cells];
});
sections.push(renderTextTable(headers, rows));
const pageInfo = options?.pageInfoByPlatform?.get(platform);
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
sections.push(
`Next page (${appPlatformDisplayNames[platform]}): --after ${pageInfo.endCursor}`
);
}
}
return sections.join('\n');
}
+26 -2
View File
@@ -8,14 +8,24 @@ export const METRIC_ALIASES: Record<string, string> = {
bundle_load: 'expo.app_startup.bundle_load_time',
};
export const NAVIGATION_METRIC_ALIASES: Record<string, string> = {
cold_ttr: 'expo.navigation.cold_ttr',
warm_ttr: 'expo.navigation.warm_ttr',
nav_tti: 'expo.navigation.tti',
};
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
const KNOWN_FULL_NAVIGATION_NAMES = new Set(Object.values(NAVIGATION_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.tti': 'Startup TTI',
'expo.app_startup.ttr': 'Startup TTR',
'expo.app_startup.bundle_load_time': 'Bundle Load',
'expo.navigation.cold_ttr': 'Nav Cold TTR',
'expo.navigation.warm_ttr': 'Nav Warm TTR',
'expo.navigation.tti': 'Nav TTI',
};
export function resolveMetricName(input: string): string {
@@ -30,6 +40,20 @@ export function resolveMetricName(input: string): string {
);
}
export function resolveNavigationMetricName(input: string): string {
if (NAVIGATION_METRIC_ALIASES[input]) {
return NAVIGATION_METRIC_ALIASES[input];
}
if (KNOWN_FULL_NAVIGATION_NAMES.has(input)) {
return input;
}
throw new EasCommandError(
`Unknown navigation metric: "${input}". Use a full metric name (e.g. expo.navigation.cold_ttr) or a short alias: ${Object.keys(
NAVIGATION_METRIC_ALIASES
).join(', ')}`
);
}
export function getMetricDisplayName(metricName: string): string {
return METRIC_SHORT_NAMES[metricName] ?? metricName;
}
@@ -51,3 +51,8 @@ export function appPlatformsFromFlag(flag: PlatformFlagValue | undefined): AppPl
}
return [defaultAppPlatform];
}
export const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
+10 -32
View File
@@ -1,12 +1,11 @@
import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import { Env } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import fs from 'fs-extra';
import Joi from 'joi';
import path from 'path';
import { isExpoInstalled } from './projectUtils';
import Log from '../log';
import { spawnExpoCommand } from '../utils/expoCli';
export type PublicExpoConfig = Omit<
ExpoConfig,
@@ -41,8 +40,6 @@ export async function createOrModifyExpoConfigAsync(
}
}
let wasExpoConfigWarnPrinted = false;
async function getExpoConfigInternalAsync(
projectDir: string,
opts: ExpoConfigOptionsInternal = {}
@@ -56,35 +53,16 @@ async function getExpoConfigInternalAsync(
let exp: ExpoConfig;
if (isExpoInstalled(projectDir)) {
try {
const { stdout } = await spawnAsync(
'npx',
['expo', 'config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])],
{
cwd: projectDir,
env: {
...process.env,
...opts.env,
EXPO_NO_DOTENV: '1',
},
}
);
exp = JSON.parse(stdout);
} catch (err: any) {
if (!wasExpoConfigWarnPrinted) {
Log.warn(
`Failed to read the app config from the project using "npx expo config" command: ${err.message}.`
);
Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.');
wasExpoConfigWarnPrinted = true;
const { stdout } = await spawnExpoCommand(
projectDir,
['config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])],
{
env: {
EXPO_NO_DOTENV: '1',
},
}
exp = getConfig(projectDir, {
skipSDKVersionRequirement: true,
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
...(opts.skipPlugins ? { skipPlugins: true } : {}),
}).exp;
}
);
exp = JSON.parse(stdout);
} else {
exp = getConfig(projectDir, {
skipSDKVersionRequirement: true,
@@ -1,11 +1,13 @@
import { ExportedConfig, IOSConfig, compileModsAsync } from '@expo/config-plugins';
import { JSONObject } from '@expo/json-file';
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
import spawnAsync from '@expo/spawn-async';
import Log from '../../log';
import Sentry from '../../sentry';
import { spawnExpoCommand } from '../../utils/expoCli';
import { readPlistAsync } from '../../utils/plist';
import { Client } from '../../vcs/vcs';
import { isExpoInstalled } from '../projectUtils';
import { hasIgnoredIosProjectAsync } from '../workflow';
interface Target {
@@ -13,59 +15,105 @@ interface Target {
targetName: string;
}
let wasExpoConfigPluginsWarnPrinted = false;
export async function getManagedApplicationTargetEntitlementsAsync(
projectDir: string,
env: Record<string, string>,
vcsClient: Client
): Promise<JSONObject> {
const originalProcessEnv: NodeJS.ProcessEnv = process.env;
try {
process.env = {
...process.env,
...env,
};
let expWithMods: ExportedConfig;
let expoConfigError: Error | undefined;
if (isExpoInstalled(projectDir)) {
try {
const { stdout } = await spawnAsync(
'npx',
['expo', 'config', '--json', '--type', 'introspect'],
const { stdout } = await spawnExpoCommand(
projectDir,
['config', '--json', '--type', 'introspect'],
{
cwd: projectDir,
env: {
...process.env,
...env,
EXPO_NO_DOTENV: '1',
},
}
);
expWithMods = JSON.parse(stdout);
} catch (err: any) {
if (!wasExpoConfigPluginsWarnPrinted) {
Log.warn(
`Failed to read the app config from the project using "npx expo config" command: ${err.message}.`
);
Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.');
wasExpoConfigPluginsWarnPrinted = true;
const expWithMods: ExportedConfig = JSON.parse(stdout);
return expWithMods.ios?.entitlements ?? {};
} catch (error: any) {
try {
Sentry.withScope(scope => {
if (process.env.EAS_BUILD_PROJECT_ID) {
scope.setTag('app_id', process.env.EAS_BUILD_PROJECT_ID);
}
if (process.env.EAS_BUILD_ID) {
scope.setTag('build_id', process.env.EAS_BUILD_ID);
}
scope.setTag('config_resolution', 'ios_entitlements_introspection');
scope.setExtra('message', 'iOS entitlements config fallback');
scope.setExtra('stdout', error.stdout);
scope.setExtra('stderr', error.stderr);
Sentry.captureException(error);
});
} catch {
// do nothing
}
const { exp } = await getPrebuildConfigAsync(projectDir, { platforms: ['ios'] });
expWithMods = await compileModsAsync(exp, {
projectRoot: projectDir,
platforms: ['ios'],
introspect: true,
ignoreExistingNativeFiles: await hasIgnoredIosProjectAsync(projectDir, vcsClient),
});
expoConfigError = error;
Log.warn(
`Failed to read the app config from the project using the local Expo CLI: ${formatError(error)}`
);
Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.');
}
}
try {
return await resolveManagedApplicationTargetEntitlementsWithBundledConfigAsync(
projectDir,
env,
vcsClient
);
} catch (fallbackError: any) {
if (expoConfigError) {
throw new Error(
`Failed to resolve iOS entitlements from Expo config. The local Expo CLI failed with: ${formatError(
expoConfigError
)}. The bundled config fallback also failed with: ${formatError(fallbackError)}`,
{ cause: fallbackError }
);
}
throw new Error(
`Failed to resolve iOS entitlements from Expo config using the bundled config fallback: ${formatError(
fallbackError
)}`,
{ cause: fallbackError }
);
}
}
async function resolveManagedApplicationTargetEntitlementsWithBundledConfigAsync(
projectDir: string,
env: Record<string, string>,
vcsClient: Client
): Promise<JSONObject> {
const originalProcessEnv = process.env;
try {
process.env = {
...process.env,
...env,
};
const { exp } = await getPrebuildConfigAsync(projectDir, { platforms: ['ios'] });
const expWithMods = await compileModsAsync(exp, {
projectRoot: projectDir,
platforms: ['ios'],
introspect: true,
ignoreExistingNativeFiles: await hasIgnoredIosProjectAsync(projectDir, vcsClient),
});
return expWithMods.ios?.entitlements ?? {};
} finally {
process.env = originalProcessEnv;
}
}
function formatError(error: Error & { stderr?: string; stdout?: string }): string {
return error.stderr?.trim() || error.stdout?.trim() || error.message;
}
export async function getNativeTargetEntitlementsAsync(
projectDir: string,
target: Target
+17 -2
View File
@@ -10,7 +10,7 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { AccountFragment } from '../graphql/generated';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { learnMore } from '../log';
import { expoCommandAsync } from '../utils/expoCli';
import { expoCommandAsync, resolveExpoCli } from '../utils/expoCli';
/**
* Return a useful name describing the project config.
@@ -45,7 +45,22 @@ export function isExpoNotificationsInstalled(projectDir: string): boolean {
export function isExpoInstalled(projectDir: string): boolean {
const packageJson = getPackageJson(projectDir);
return !!(packageJson.dependencies && 'expo' in packageJson.dependencies);
if (!!(packageJson.dependencies && 'expo' in packageJson.dependencies)) {
// NOTE(@kitten): We usually don't apply strict checks for installed packages, but
// `isExpoInstalled` is often used to check if we should call the Expo CLI, so we're
// also checking if we can resolve `expo` here
try {
return !!resolveExpoCli(projectDir);
} catch (e: any) {
if (e.code === 'MODULE_NOT_FOUND') {
return false;
} else {
throw e;
}
}
} else {
return false;
}
}
export function isExpoUpdatesInstalledAsDevDependency(projectDir: string): boolean {
+16
View File
@@ -24,6 +24,22 @@ export function formatRemoteSessionInstructions(
}
return lines.join('\n');
}
case 'ArgentRunSessionRemoteConfig': {
const lines = [
'🔑 Open the following URL to access the Argent tools for this session:',
'',
remoteConfig.toolsUrl,
];
if (remoteConfig.webPreviewUrl) {
lines.push(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
remoteConfig.webPreviewUrl
);
}
return lines.join('\n');
}
case 'ServeSimRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
@@ -294,7 +294,7 @@ describe(SessionManager, () => {
username: 'USERNAME',
});
await sessionManager.showLoginPromptAsync();
await sessionManager.showLoginPromptAsync({ browser: false });
expect(sessionManagerRetryUsernamePasswordAuthWithOTPAsyncSpy).toHaveBeenCalledWith(
'hello',
@@ -313,7 +313,7 @@ describe(SessionManager, () => {
);
});
it('calls regular login if the sso flag is false', async () => {
it('calls regular login by default', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ username: 'USERNAME', password: 'PASSWORD' }))
@@ -321,14 +321,19 @@ describe(SessionManager, () => {
throw new Error("shouldn't happen");
});
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
sessionSecret: 'SESSION_SECRET',
id: 'USER_ID',
username: 'USERNAME',
});
const sessionManager = new SessionManager(analytics);
// Regular login
await sessionManager.showLoginPromptAsync({ sso: false });
await sessionManager.showLoginPromptAsync();
expect(fetchSessionSecretAndUserAsync).toHaveBeenCalled();
});
it('calls regular login if the sso flag is undefined', async () => {
it('calls regular login when browser is false', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ username: 'USERNAME', password: 'PASSWORD' }))
@@ -338,8 +343,21 @@ describe(SessionManager, () => {
const sessionManager = new SessionManager(analytics);
// Regular login
await sessionManager.showLoginPromptAsync({ sso: undefined });
await sessionManager.showLoginPromptAsync({ browser: false });
expect(fetchSessionSecretAndUserAsync).toHaveBeenCalled();
});
it('calls regular login if the sso flag is false and browser is false', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ username: 'USERNAME', password: 'PASSWORD' }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
const sessionManager = new SessionManager(analytics);
await sessionManager.showLoginPromptAsync({ sso: false, browser: false });
expect(fetchSessionSecretAndUserAsync).toHaveBeenCalled();
});
+33 -16
View File
@@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import { boolish } from 'getenv';
import type { CommonSpawnOptions } from 'node:child_process';
import resolveFrom, { silent as silentResolveFrom } from 'resolve-from';
import semver from 'semver';
@@ -77,18 +78,20 @@ export const shouldUseVersionedExpoCLIWithExplicitPlatforms = memoize(
shouldUseVersionedExpoCLIWithExplicitPlatformsExpensive
);
export async function expoCommandAsync(
export function resolveExpoCli(projectDir: string): string {
return (
silentResolveFrom(projectDir, 'expo/bin/cli') ?? resolveFrom(projectDir, 'expo/bin/cli.js')
);
}
export function spawnExpoCommand(
projectDir: string,
args: string[],
{
silent = false,
extraEnv = {},
}: { silent?: boolean; extraEnv?: Record<string, string | undefined> } = {}
): Promise<void> {
opts?: CommonSpawnOptions
): spawnAsync.SpawnPromise<spawnAsync.SpawnResult> {
let expoCliPath;
try {
expoCliPath =
silentResolveFrom(projectDir, 'expo/bin/cli') ?? resolveFrom(projectDir, 'expo/bin/cli.js');
expoCliPath = resolveExpoCli(projectDir);
} catch (e: any) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new Error(
@@ -101,25 +104,39 @@ export async function expoCommandAsync(
}
const spawnPromise = spawnAsync(expoCliPath, args, {
stdio: ['inherit', 'pipe', 'pipe'], // inherit stdin so user can install a missing expo-cli from inside this command
cwd: projectDir,
...opts,
env: {
...process.env,
...extraEnv,
...opts?.env,
},
});
const {
child: { stdout, stderr },
} = spawnPromise;
if (!(stdout && stderr)) {
if (!spawnPromise.child.stdout && !spawnPromise.child.stderr) {
throw new Error('Failed to spawn expo-cli');
}
return spawnPromise;
}
export async function expoCommandAsync(
projectDir: string,
args: string[],
{
silent = false,
extraEnv = {},
}: { silent?: boolean; extraEnv?: Record<string, string | undefined> } = {}
): Promise<void> {
const spawnPromise = spawnExpoCommand(projectDir, args, {
stdio: ['inherit', 'pipe', 'pipe'], // inherit stdin so user can install a missing expo-cli from inside this command
env: extraEnv,
});
if (!silent) {
stdout.on('data', data => {
spawnPromise.child.stdout?.on('data', data => {
for (const line of data.toString().trim().split('\n')) {
Log.log(`${chalk.gray('[expo-cli]')} ${line}`);
}
});
stderr.on('data', data => {
spawnPromise.child.stderr?.on('data', data => {
for (const line of data.toString().trim().split('\n')) {
Log.warn(`${chalk.gray('[expo-cli]')} ${line}`);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "18.12.0",
"version": "19.0.0",
"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.12.0",
"@expo/eas-build-job": "19.0.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.12.3",
"version": "19.0.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,11 +26,11 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "18.12.3",
"@expo/eas-build-job": "18.12.0",
"@expo/logger": "18.5.0",
"@expo/build-tools": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/logger": "19.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "18.5.0",
"@expo/turtle-spawn": "19.0.0",
"bunyan": "^1.8.15",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "18.5.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.12.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "18.12.0",
"@expo/logger": "18.5.0",
"@expo/eas-build-job": "19.0.0",
"@expo/logger": "19.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fs-extra": "^11.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "18.5.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "18.5.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.5.0",
"@expo/logger": "19.0.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.12.3",
"version": "19.0.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,18 +19,18 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.12.3",
"@expo/build-tools": "19.0.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.12.0",
"@expo/logger": "18.5.0",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/logger": "19.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.12.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/steps": "19.0.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
+125 -155
View File
@@ -1,75 +1,57 @@
import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
Platform,
Workflow,
errors,
} from '@expo/eas-build-job';
import { Datadog } from '@expo/build-tools';
import { BuildPhase, BuildPhaseResult, Job, errors } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { build } from '../build';
import { createBuildContext } from '../context';
import { createBuildLoggerWithSecretsFilter } from '../logger';
import BuildService, { getExpoPackageVersionAsync } from '../service';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('fs');
jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
return {
...actual,
GCS: { uploadWithSignedUrl: jest.fn() },
Datadog: {
setup: jest.fn(),
flushAsync: jest.fn(async () => {}),
},
};
});
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../build', () => ({
build: jest.fn(),
}));
jest.mock('../context', () => ({
createBuildContext: jest.fn(),
}));
jest.mock('../external/analytics', () => ({
Analytics: jest.fn().mockImplementation(() => ({
flushEventsAsync: jest.fn(),
logEvent: jest.fn(),
})),
}));
jest.mock('../logger', () => {
const logger = {
child: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
level: jest.fn(),
warn: jest.fn(),
};
logger.child.mockReturnValue(logger);
return {
__esModule: true,
default: logger,
createBuildLoggerWithSecretsFilter: jest.fn(async () => ({
cleanUp: jest.fn(),
logBuffer: { getLogs: jest.fn(() => []), getPhaseLogs: jest.fn(() => []) },
logger,
})),
};
});
jest.mock('../sentry', () => ({
__esModule: true,
default: {
capture: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
createBuildContext: jest.fn(() => ({ job: {} })),
}));
jest.mock('../config', () => {
const config = jest.requireActual('../config').default;
const actual = jest.requireActual('../config').default;
return {
__esModule: true,
default: {
...config,
buildId: 'build-id',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
},
default: { ...actual, buildId: 'build-id' },
};
});
jest.mock('../logger', () => ({
__esModule: true,
default: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
level: jest.fn(),
},
createBuildLoggerWithSecretsFilter: jest.fn(),
}));
const buildMock = jest.mocked(build);
const createBuildContextMock = jest.mocked(createBuildContext);
const createBuildLoggerWithSecretsFilterMock = jest.mocked(createBuildLoggerWithSecretsFilter);
const datadogSetupMock = jest.mocked(Datadog.setup);
const datadogFlushAsyncMock = jest.mocked(Datadog.flushAsync);
describe(getExpoPackageVersionAsync, () => {
const projectRoot = '/test-project';
@@ -123,135 +105,123 @@ describe(getExpoPackageVersionAsync, () => {
});
});
describe(BuildService, () => {
const projectRoot = '/test-project';
const job = {
appId: 'app-id',
cache: {
clear: false,
disabled: false,
paths: [],
},
mode: BuildMode.BUILD,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://example.com/project.tar.gz',
},
projectRootDirectory: '.',
secrets: {
robotAccessToken: 'robot-token',
},
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.GENERIC,
};
describe('BuildService.reportBuildPhaseStats', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(path.join(projectRoot, 'node_modules/expo/package.json')),
} as any);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as any);
jest.mocked(build).mockRejectedValue(new Error('raw build failure'));
jest.mocked(createBuildContext).mockReturnValue({
env: { PATH: '/usr/bin' },
getReactNativeProjectDirectory: () => projectRoot,
job,
logger: {},
} as any);
});
it('sends expo_package_version to Datadog error logs without changing sdk_version', async () => {
vol.fromJSON({
[path.join(projectRoot, 'node_modules/expo/package.json')]: JSON.stringify({
version: '55.0.17',
}),
});
it('sends phase stats to the launcher websocket', () => {
const send = jest.fn();
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
service.setWS({ send } as any);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
buildProfile: 'production',
reactNativeVersion: '0.83.0',
sdkVersion: '55.0.0',
},
projectId: 'project-id',
const stats = {
buildPhase: BuildPhase.RUN_FASTLANE,
result: BuildPhaseResult.SUCCESS,
durationMs: 1234,
};
service.reportBuildPhaseStats(stats);
expect(send).toHaveBeenCalledWith({
type: 'build-phase-stats',
...stats,
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: '55.0.17',
sdk_version: '55.0.0',
}),
}),
})
);
});
it('sends null expo_package_version to Datadog error logs when expo package version resolution fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
it('does not send phase stats when there is no launcher websocket', () => {
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
sdkVersion: '55.0.0',
},
projectId: 'project-id',
service.reportBuildPhaseStats({
buildPhase: BuildPhase.PREPARE_PROJECT,
result: BuildPhaseResult.SUCCESS,
durationMs: 1,
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/logs',
'POST',
expect(datadogSetupMock).not.toHaveBeenCalled();
});
});
describe('BuildService Datadog setup', () => {
const buildLogger = { child: jest.fn(() => buildLogger) } as any;
beforeEach(() => {
jest.clearAllMocks();
buildMock.mockResolvedValue({});
createBuildContextMock.mockReturnValue({ job: {} } as any);
createBuildLoggerWithSecretsFilterMock.mockResolvedValue({
logger: buildLogger,
cleanUp: jest.fn(async () => {}),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] } as any,
outputStream: {} as any,
});
});
it('configures Datadog for build jobs', async () => {
const service = new BuildService();
service.checkForHangingWorker = jest.fn(async () => {});
await (service as any).startBuildInternal({
job: {
platform: 'ios',
secrets: { robotAccessToken: 'token-abc' },
} as Job,
metadata: {},
projectId: 'project-id',
initiatingUserId: 'user-id',
});
expect(datadogSetupMock).toHaveBeenCalledWith({
expoApiV2BaseUrl: expect.any(String),
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
expect(createBuildContextMock).toHaveBeenCalledWith(
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: null,
sdk_version: '55.0.0',
}),
}),
reportBuildPhaseStatsFn: expect.any(Function),
})
);
expect(datadogFlushAsyncMock).toHaveBeenCalled();
});
it('does not try to resolve expo_package_version when build context setup fails', async () => {
jest.mocked(createBuildContext).mockImplementation(() => {
throw new Error('context setup failure');
});
it('configures Datadog for non-platform jobs with the turtle build id', async () => {
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
service.checkForHangingWorker = jest.fn(async () => {});
await (service as any).startBuildInternal({
job: {
secrets: { robotAccessToken: 'token-abc' },
} as Job,
metadata: {},
projectId: 'project-id',
initiatingUserId: 'user-id',
job,
metadata: {
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(spawn).not.toHaveBeenCalled();
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/logs',
'POST',
expect(datadogSetupMock).toHaveBeenCalledWith({
expoApiV2BaseUrl: expect.any(String),
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
expect(createBuildContextMock).toHaveBeenCalledWith(
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: null,
sdk_version: '55.0.0',
}),
}),
reportBuildPhaseStatsFn: expect.any(Function),
})
);
});
it('leaves Datadog unconfigured when the robot access token is unavailable', async () => {
const service = new BuildService();
service.checkForHangingWorker = jest.fn(async () => {});
await (service as any).startBuildInternal({
job: {
platform: 'ios',
secrets: {},
} as Job,
metadata: {},
projectId: 'project-id',
initiatingUserId: 'user-id',
});
expect(datadogSetupMock).not.toHaveBeenCalled();
});
});
+25 -1
View File
@@ -1,4 +1,12 @@
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
import {
Artifacts,
BuildContext,
Builders,
Sentry,
parseGradleProfile,
formatGradleProfileReport,
runGenericJobAsync,
} from '@expo/build-tools';
import {
Android,
BuildJob,
@@ -13,6 +21,7 @@ import {
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import omit from 'lodash/omit';
import path from 'path';
import config from './config';
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
@@ -83,6 +92,21 @@ export async function build({
analytics.logEvent(Event.WORKER_BUILD_SUCCESS, {});
if (job.platform === Platform.ANDROID) {
try {
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
});
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
}
}
return artifacts;
} catch (err: any) {
if ('mode' in job && ![BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)) {
+12 -55
View File
@@ -1,6 +1,7 @@
import {
Artifacts,
BuildContext,
Datadog,
Hook,
findAndUploadXcodeBuildLogsAsync,
runHookIfPresent,
@@ -34,7 +35,6 @@ import logger, { createBuildLoggerWithSecretsFilter } from './logger';
import sentry from './sentry';
import State from './state';
import { WebSocketServer } from './utils/WebSocketServer';
import { turtleFetch } from './utils/turtleFetch';
export const HANGING_WORKER_CHECK_TIMEOUT_MS = 5 * 60 * 1000;
@@ -123,6 +123,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 Datadog.flushAsync();
await this.logsCleanUp?.();
if (this.ws) {
logger.info('Send build result - error');
@@ -144,6 +145,7 @@ export default class BuildService {
buildArtifactsName: artifacts.BUILD_ARTIFACTS ?? null,
});
const isSocketClosed: boolean = !this.ws;
await Datadog.flushAsync();
await this.logsCleanUp?.();
if (this.ws) {
logger.info('Send build result - success');
@@ -196,6 +198,7 @@ export default class BuildService {
}
const isSocketClosed: boolean = !this.ws;
await Datadog.flushAsync();
await this.logsCleanUp?.();
if (this.ws) {
logger.info('Send build result - aborted');
@@ -287,6 +290,14 @@ export default class BuildService {
this.logsCleanUp = cleanUp;
const analytics = new Analytics(initiatingUserId, metadata?.trackingContext ?? {});
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken) {
Datadog.setup({
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
turtleBuildId: this.buildId,
robotAccessToken,
});
}
const ctx = createBuildContext({
job,
@@ -334,60 +345,6 @@ export default class BuildService {
},
});
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken) {
const expoPackageVersionResult = this.buildContext
? await asyncResult(getExpoPackageVersionAsync(this.buildContext))
: null;
const expoPackageVersion =
expoPackageVersionResult?.ok === true ? expoPackageVersionResult.value : null;
let rawErrorMessage: string = '';
if (maybeRawError?.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
}
if (maybeRawError?.stdout) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
}
if (!rawErrorMessage) {
rawErrorMessage = maybeRawError?.message ?? err.message;
}
try {
await turtleFetch(
new URL('turtle-builds/logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
level: 'error',
tags: {
build_phase: err.buildPhase ?? null,
error_code: err.errorCode,
platform: job.platform,
workflow: job.type,
sdk_version: metadata?.sdkVersion ?? null,
expo_package_version: expoPackageVersion,
react_native_version: metadata?.reactNativeVersion ?? null,
app_id: job.appId ?? null,
build_profile: metadata?.buildProfile ?? null,
app_name: metadata?.appName ?? null,
app_identifier: metadata?.appIdentifier ?? null,
distribution: metadata?.distribution ?? null,
cli_version: metadata?.cliVersion ?? null,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
} catch (fetchError: any) {
logger.warn({ err: fetchError }, 'Failed to send build error log');
}
}
await this.finishError(err, maybeArtifacts);
}
}
Generated
+67 -47
View File
@@ -2856,24 +2856,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:18.12.3, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:19.0.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.12.0"
"@expo/downloader": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:18.5.0"
"@expo/logger": "npm:19.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.2.0"
"@expo/repack-app": "npm:~0.4.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.12.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/steps": "npm:19.0.0"
"@expo/template-file": "npm:19.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@expo/xcpretty": "npm:^4.3.1"
"@google-cloud/storage": "npm:^7.11.2"
"@sentry/node": "npm:7.77.0"
@@ -3096,7 +3096,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:18.5.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:19.0.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -3112,11 +3112,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:18.12.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:19.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:18.5.0"
"@expo/logger": "npm:19.0.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
jest: "npm:^29.7.0"
@@ -3129,12 +3131,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:18.12.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:19.0.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:18.12.0"
"@expo/eas-build-job": "npm:19.0.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3271,7 +3273,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:18.5.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:19.0.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3482,12 +3484,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:18.12.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:19.0.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:18.12.0"
"@expo/logger": "npm:18.5.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/logger": "npm:19.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3512,7 +3514,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:18.5.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:19.0.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3535,11 +3537,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:18.5.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:19.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:18.5.0"
"@expo/logger": "npm:19.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -3555,18 +3557,18 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.12.3"
"@expo/build-tools": "npm:19.0.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.12.0"
"@expo/logger": "npm:18.5.0"
"@expo/downloader": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/logger": "npm:19.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.12.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/steps": "npm:19.0.0"
"@expo/template-file": "npm:19.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -7493,6 +7495,15 @@ __metadata:
languageName: node
linkType: hard
"@types/debug@npm:4.1.13":
version: 4.1.13
resolution: "@types/debug@npm:4.1.13"
dependencies:
"@types/ms": "npm:*"
checksum: 10c0/e5e124021bbdb23a82727eee0a726ae0fc8a3ae1f57253cbcc47497f259afb357de7f6941375e773e1abbfa1604c1555b901a409d762ec2bb4c1612131d4afb7
languageName: node
linkType: hard
"@types/diff@npm:6.0.0":
version: 6.0.0
resolution: "@types/diff@npm:6.0.0"
@@ -7835,6 +7846,13 @@ __metadata:
languageName: node
linkType: hard
"@types/ms@npm:*":
version: 2.1.0
resolution: "@types/ms@npm:2.1.0"
checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225
languageName: node
linkType: hard
"@types/mute-stream@npm:^0.0.4":
version: 0.0.4
resolution: "@types/mute-stream@npm:0.0.4"
@@ -10330,7 +10348,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:18.12.0"
"@expo/steps": "npm:19.0.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10541,6 +10559,18 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:4.4.3, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6
languageName: node
linkType: hard
"debug@npm:^4.3.5":
version: 4.3.7
resolution: "debug@npm:4.3.7"
@@ -10553,18 +10583,6 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6
languageName: node
linkType: hard
"decamelize-keys@npm:^1.1.0":
version: 1.1.0
resolution: "decamelize-keys@npm:1.1.0"
@@ -10943,11 +10961,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:18.12.3"
"@expo/eas-build-job": "npm:18.12.0"
"@expo/logger": "npm:18.5.0"
"@expo/build-tools": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/logger": "npm:19.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@types/bunyan": "npm:^1.8.11"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
@@ -11001,11 +11019,11 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:18.12.0"
"@expo/eas-json": "npm:18.12.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/eas-json": "npm:19.0.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:18.5.0"
"@expo/logger": "npm:19.0.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -11017,7 +11035,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.12.0"
"@expo/steps": "npm:19.0.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"
@@ -11030,6 +11048,7 @@ __metadata:
"@tsconfig/node20": "npm:20.1.8"
"@types/cli-progress": "npm:3.11.5"
"@types/dateformat": "npm:3.0.1"
"@types/debug": "npm:4.1.13"
"@types/diff": "npm:6.0.0"
"@types/envinfo": "npm:7.8.3"
"@types/express": "npm:4.17.15"
@@ -11058,6 +11077,7 @@ __metadata:
chalk: "npm:4.1.2"
cli-progress: "npm:3.12.0"
dateformat: "npm:4.6.3"
debug: "npm:4.4.3"
diff: "npm:7.0.0"
dotenv: "npm:16.3.1"
env-paths: "npm:2.2.0"