Compare commits

...
Author SHA1 Message Date
Expo CI c72cee372f v21.0.0 2026-07-13 17:21:41 +00:00
Szymon Dziedzic 73b2a716da [eas-cli] Bump node-forge (#3970)
* [eas-cli] Bump node-forge

* [eas-cli] Add changelog for node-forge bump
2026-07-13 18:54:28 +02:00
Gabe Debes 55191f9697 [build-tools] Add eas/posthog_wait_for_query workflow function (#3949)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric

* [build-tools] Add eas/posthog_upload_sourcemaps workflow function

* [build-tools] use PosthogUtils namespace in sourcemap upload

* [build-tools] name missing credentials in sourcemap upload

* [build-tools] use credentials-error factory in sourcemap upload

* [build-tools] use PosthogUtils.missingCredentialsError in sourcemap upload

* [build-tools] pass abort signal to the sourcemap upload subprocess

* [build-tools] Add eas/posthog_annotation workflow function

* [build-tools] use PosthogUtils namespace in annotation

* [build-tools] name missing credentials in annotation

* [build-tools] use credentials-error factory in annotation

* [build-tools] use PosthogUtils.missingCredentialsError in annotation

* [build-tools] thread abort signal through annotation request

* [build-tools] Add eas/posthog_wait_for_query workflow function

* [build-tools] respect abort signal while waiting for query

* [build-tools] name missing credentials when waiting for query

* [build-tools] share poll helpers and thread signal (query)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for query
2026-07-13 09:32:03 -07:00
Szymon Dziedzic a08beb6392 [codex] fix form-data dependabot alert (#3962)
* fix form-data dependabot alert

* add changelog for form-data security fix
2026-07-13 18:29:07 +02:00
Szymon Dziedzic 643cc9e387 [codex] fix shell-quote dependabot alert (#3959)
* fix shell-quote dependabot alert

* add changelog for shell-quote security fix
2026-07-13 18:06:12 +02:00
Gabe Debes 7c44d5515a [build-tools] Add eas/posthog_annotation workflow function (#3948)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric

* [build-tools] Add eas/posthog_upload_sourcemaps workflow function

* [build-tools] use PosthogUtils namespace in sourcemap upload

* [build-tools] name missing credentials in sourcemap upload

* [build-tools] use credentials-error factory in sourcemap upload

* [build-tools] use PosthogUtils.missingCredentialsError in sourcemap upload

* [build-tools] pass abort signal to the sourcemap upload subprocess

* [build-tools] Add eas/posthog_annotation workflow function

* [build-tools] use PosthogUtils namespace in annotation

* [build-tools] name missing credentials in annotation

* [build-tools] use credentials-error factory in annotation

* [build-tools] use PosthogUtils.missingCredentialsError in annotation

* [build-tools] thread abort signal through annotation request
2026-07-13 09:00:07 -07:00
Gabe Debes 6fd172ee30 [build-tools] Add eas/posthog_upload_sourcemaps workflow function (#3947)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric

* [build-tools] Add eas/posthog_upload_sourcemaps workflow function

* [build-tools] use PosthogUtils namespace in sourcemap upload

* [build-tools] name missing credentials in sourcemap upload

* [build-tools] use credentials-error factory in sourcemap upload

* [build-tools] use PosthogUtils.missingCredentialsError in sourcemap upload

* [build-tools] pass abort signal to the sourcemap upload subprocess
2026-07-13 08:53:19 -07:00
Gabe Debes 4edf0dc547 [build-tools] Add eas/posthog_wait_for_metric workflow function (#3945)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric
2026-07-13 08:46:03 -07:00
Gabe Debes f82bdb57e6 [build-tools] Add eas/posthog_flag_rollout workflow function (#3944)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests
2026-07-13 08:39:43 -07:00
Szymon Dziedzic 9641735781 [codex] fix fast-xml-parser dependabot alert (#3960)
* fix fast-xml-parser dependabot alert

* add changelog for fast-xml-parser security fix
2026-07-13 17:33:25 +02:00
Gabe Debes 1ce05e3b28 [build-tools] Add eas/posthog_capture_event workflow function (#3934)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests
2026-07-13 08:33:06 -07:00
Szymon Dziedzic b2f34e5c03 [codex] fix fast-xml-builder dependabot alert (#3966)
* fix fast-xml-builder dependabot alert

* add changelog for fast-xml-builder security fix
2026-07-13 17:04:09 +02:00
Stanisław Chmiela 8204d18951 [build-tools] Add simulator details to screen recording metadata (#4000)
## Summary

- include the simulator device name and human-readable runtime name in Device Run Session screen recording metadata
- carry those fields through the finish-step output and construct the artifact display name only during upload
- normalize CoreSimulator runtime identifiers such as `iOS-18-6` to `iOS 18.6`
- add focused coverage for runtime normalization and the uploaded metadata payload

## Why

The website needs device and runtime details to identify and present EAS-generated simulator screen recordings without parsing artifact names or CoreSimulator identifiers.

## Validation

- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/build-tools jest-unit --runInBand src/utils/__tests__/IosSimulatorUtils.test.ts src/steps/functions/__tests__/uploadDeviceRunSessionScreenRecordings.test.ts`
2026-07-13 15:22:40 +02:00
Szymon Dziedzic 69024620f9 Bump cross-spawn in steps fixture (#3977)
* Bump cross-spawn in steps fixture

* Add changelog entry for cross-spawn bump
2026-07-13 14:51:04 +02:00
Szymon Dziedzic c9442988c7 Bump Koa dependency (#3974)
* Bump Koa dependency

* Add changelog entry for Koa bump
2026-07-13 13:31:49 +02:00
Szymon Dziedzic 2a258e80a9 Bump jws in root lockfile (#3978)
* Bump jws in root lockfile

* Add changelog entry for jws bump
2026-07-13 13:11:51 +02:00
Stanisław Chmiela 4b1f785abe [build-tools] Handle detached Android emulator exits (#3998)
## Summary

Observe the detached Android emulator process promise immediately after startup so expected exits during retry cleanup do not surface as unhandled promise rejections.

The emulator is managed through adb after launch. When a startup attempt times out and cleanup kills the emulator, the underlying SpawnPromise can reject while the retry loop is handling the readiness failure. Observing the promise keeps Sentry from capturing that cleanup path as an unhandled SIGKILL while preserving the returned promise for callers that still await it later.

## Test plan

- yarn run -T oxfmt packages/build-tools/src/utils/AndroidEmulatorUtils.ts
- yarn workspace @expo/build-tools typecheck
2026-07-10 17:39:39 +02:00
Stanisław Chmiela 2e2e8ffacc [build-tools] Fix Argent tool server state discovery (#3997)
# Why

Argent now writes per-install tool-server state files such as `~/.argent/tool-server-<hash>.json`. The remote session step only watched the legacy `tool-server.json`, so it could time out even when the newly launched tool server was running.

# How

- expose the PID of detached processes
- add a process ancestry helper that works on macOS and Linux
- run Argent in the foreground beneath the detached `bunx` process and pass `--force`
- scan legacy and hashed Argent state files, selecting only the state whose PID descends from the launched `bunx` process
- validate the state schema includes the tool-server PID
- cover legacy, hashed, matching, and unrelated state files

# Test Plan

- `yarn workspace @expo/build-tools jest-unit packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession.test.ts --runInBand`
- `yarn workspace @expo/build-tools build`
- `yarn workspace @expo/worker build`
- `yarn workspace eas-cli build`
- `yarn workspace @expo/build-tools typecheck`
- `yarn lint`
- `yarn fmt:check`
2026-07-10 15:57:48 +02:00
Stanisław Chmiela 708bddd2ad [build-tools] Add screen recording metadata marker (#3996)
Didn't realize haven't pushed this commit before merging.

https://github.com/expo/eas-cli/pull/3994
2026-07-10 15:29:55 +02:00
Stanisław Chmiela 6d02728a25 [build-tools] Wire record-sim into EAS functions (#3994)
## Summary

- add separate `eas/start_ios_simulator_recordings` and `eas/finish_ios_simulator_recordings` build functions
- poll booted iOS Simulators and record each session with `record-sim`
- retry failed recorder processes up to three times per simulator boot
- upload completed MP4 files as screen-recording device run session artifacts with recording metadata
- extend device run session artifact uploads to accept metadata

## Why

Device run sessions need simulator screen recordings that can start independently from remote sessions and be finalized and uploaded when the build workflow finishes.

## Impact

Workflows can explicitly start simulator recording polling and later finish and upload recordings. Missing `record-sim` binaries or device run session IDs disable recording or upload without failing the workflow.

## Validation

- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/build-tools jest-unit steps/utils/__tests__/deviceRunSessionArtifacts.test.ts --runInBand`
- `yarn oxfmt --check` on all changed files
- `git diff --check origin/main...HEAD`
2026-07-10 15:22:59 +02:00
Douglas Lowder 9a94201ce5 [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding (#3989)
# Why

`syncCapabilitiesForEntitlementsAsync` in bundleIdCapabilities.ts wrapped `bundleId.updateBundleIdCapabilityAsync` in a try/catch whose block only rethrew for the specific error "bundle '…' cannot be deleted. Delete all the Apps". Every other Apple API error was silently swallowed while the function still returned the intended enabled/disabled lists — leading to undebuggable signing failures downstream. (Reported in issue #3986 against 18.12.3 and 20.5.1.)

# How

Rethrow every non-matching error with the same actionable context we already gave the "cannot be deleted" case — the Apple developer console URL and the EXPO_NO_CAPABILITY_SYNC=1 escape hatch — and preserve the underlying Apple error via `.cause` so EXPO_DEBUG=1 still surfaces the raw response. Log the failing request payload for all failures, not just the matched one, so future variants are debuggable from CLI output.
    
Also fix the "developer-mdn.apple.com" typo in the existing error message (the correct host is developer.apple.com).

Note that capability sync will not work correctly until the fix in https://github.com/expo/third-party/pull/161 is merged and released.

# Test Plan

- Expand the existing "throw useful error message" test to cover generic Apple errors
- Correct expected URL in test assertion
2026-07-10 05:57:41 -07:00
Szymon Dziedzic 6189ae9ed3 [eas-cli] Expose simulator session artifacts in get (#3991) 2026-07-10 13:27:52 +02:00
Stanisław Chmiela 9d373d9778 [build-tools] Fix iOS Simulator apsd disable for iOS 26.5 (#3988)
## Summary

Fixes the iOS Simulator `apsd` shutdown helper to use the launchd domain requested by iOS 26.5:

- disables and boots out `user/foreground/com.apple.apsd` first
- falls back to `system/com.apple.apsd` for compatibility
- verifies whether the service is still `state = running`
- treats `launchctl print` returning service-not-found as a successful stopped state

## Root cause

The previous implementation used `system/com.apple.apsd`. On iOS 26.5, `launchctl` warns to switch to `user/foreground/com.apple.apsd`, and workflow logs showed `bootout system/com.apple.apsd` exiting non-zero. That made a successful or partially successful disable look like a failure and could leave `apsd` running.

## Validation

- `yarn test packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts`
- `yarn typecheck` in the original checkout
- `git diff --check`

I also verified locally on a disposable iOS 26.5 simulator clone that `disable` + `bootout` with `user/foreground/com.apple.apsd` stops the service, and subsequent `launchctl print` returns service-not-found.
2026-07-09 16:41:34 +00:00
Byron Karlen 43662ca9f5 [eas-cli] Remove unused eas onboarding command (#3993) 2026-07-09 10:41:04 -06:00
Jon Samp cc42fdc28d Improves workflow:create command (#3943)
* improves create command

* save

* fable review

* removes question

* dont prompt for application bundle id when not needed

* [eas-cli] Exit non-zero when workflow:create fails

* [eas-cli] Simplify project configuration in workflow:create

* [eas-cli] Document why workflow:create skips owner/slug consistency check
2026-07-08 14:51:20 -05:00
Douglas Lowder 0727ae467a [eas-cli] Accept navigation metric aliases as positional args to observe:metrics (#3973)
# Why

When running `observe:metrics` in non-interactive mode, navigation metric names could not be passed into the command; only app startup metrics were accepted.

# How

- Included the navigation metrics in the validation check
- Renamed the short names (keys) for the navigation metrics to avoid confusion with app startup metric short names

# Test Plan

- Tested locally
- Unit tests added and updated
2026-07-08 05:17:35 -07:00
Gabe Debes fc0ebff305 [eas-cli] Open the PostHog dashboard via a signed-in deep link (#3975)
# Why

`eas integrations:posthog:dashboard` should open the linked PostHog project already signed in, instead of a static URL that hits a login wall.

# How

Mints a one-time, signed-in link via the new `createPostHogDeepLink` mutation and opens it. The link carries a single-use token, so it isn't printed by default; pass `--show-link` to print it. `--json` / `--non-interactive` return the stable static URL, which minting failures also fall back to.

# Test Plan

Unit tests + verified end-to-end with `easd`

<img width="1548" height="484" alt="carbon (5)" src="https://github.com/user-attachments/assets/c00961e5-da49-456c-af1e-c4a2700eece4" />
2026-07-07 15:39:51 -07:00
David Mokos f89e9ddef0 [cli] add agent detection to telemetry (#3983)
Ports the Expo CLI agent telemetry integration from https://github.com/expo/expo/pull/47248 to EAS CLI.

Adds `agent-cli-detector` to EAS CLI telemetry so events recorded from known coding agents include lightweight agent context. The CLI uses `[email protected]`, which exposes a CommonJS entry point, so detection stays synchronous and does not participate in telemetry initialization.

When a known agent is detected, telemetry event context includes:

```ts
context.agent = {
  id: string,
  sessionId: string | undefined,
}
```

When no known agent is detected, `context.agent` is omitted to keep RudderStack and BigQuery payloads sparse. CLI version is already available in telemetry, so analysis can distinguish newer CLI versions with no detected agent from older CLI versions without this integration.

Tests cover detected and undetected telemetry payloads. Locally verified with `yarn workspace eas-cli test src/analytics/__tests__/AnalyticsManager-test.ts --runInBand`, `yarn workspace eas-cli typecheck`, `yarn lint-changelog`, and targeted `oxlint`.
2026-07-07 16:53:26 +02:00
Szymon Dziedzic b9d59e2628 Bump glob in root lockfile (#3979)
* Bump glob in root lockfile

* Add changelog entry for glob bump
2026-07-07 15:20:18 +02:00
Szymon Dziedzic 502f713467 Remove stale changelog PR template instruction (#3984) 2026-07-07 15:09:12 +02:00
Stanisław Chmiela 48bdd42bc3 [build-tools] Wait for remote sessions to stop cleanly (#3946)
## Summary

- replace the remote device session infinite waits with polling for the device run session to become `STOPPED`
- let agent-device, Argent, and serve-sim session steps return normally after the stop signal so the worker can report success and the launcher can close the VM
- make Argent artifact polling stoppable, drain one final artifact list on shutdown, and wait briefly for pending uploads

## Why

Stopping a device run session currently cancels the backing turtle job run, which tears down the VM immediately. That skips graceful shutdown work such as finishing recordings and uploading session artifacts. This change makes the worker observe the session stop state and finish cleanup before exiting.

## Validation

- `yarn oxfmt <modified files>`
- `yarn --cwd packages/build-tools test -- remoteDeviceRunSession`
- `yarn --cwd packages/build-tools test -- argentArtifacts`
- `yarn --cwd packages/build-tools typecheck`
- `git diff --check`

## Note

This relies on the backend/API behavior changing so `ensureDeviceRunSessionStopped` marks the device run session `STOPPED` without canceling the turtle job run, and allows artifact uploads while the stopped session is still cleaning up.
2026-07-07 14:52:58 +02:00
Stanisław Chmiela 81d09514fc [build-tools] Wire agent-device artifact kinds (#3980)
## Summary

- Preserve `artifactType` from agent-device artifact inventory responses.
- Send that value to the device run session artifact upload-session mutation as `kind`.
- Make the local device run session artifact upload helper require an explicit `kind: string | undefined` so untyped uploads are intentional.
- Regenerate EAS CLI GraphQL schema/types with the repository codegen script and update the one fixture affected by regenerated schema drift.

## Why

agent-device now emits semantic artifact types, and universe now stores them on device run session artifacts. This wires the build-tools upload path between those two changes so users can see producer-owned artifact kinds instead of relying on filenames.

## Validation

CI should pass.
2026-07-07 14:17:19 +02:00
96 changed files with 7595 additions and 3458 deletions

No files matched your search

-1
View File
@@ -1,4 +1,3 @@
<!-- 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
+24
View File
@@ -6,15 +6,39 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_flag_rollout` workflow function to update PostHog feature flags from workflow runs. ([#3944](https://github.com/expo/eas-cli/pull/3944) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_wait_for_metric` workflow function to gate workflow runs on a PostHog metric. ([#3945](https://github.com/expo/eas-cli/pull/3945) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_upload_sourcemaps` workflow function to upload source maps to PostHog. ([#3947](https://github.com/expo/eas-cli/pull/3947) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_annotation` workflow function to create PostHog annotations from workflow runs. ([#3948](https://github.com/expo/eas-cli/pull/3948) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_wait_for_query` workflow function to gate workflow runs on a PostHog query. ([#3949](https://github.com/expo/eas-cli/pull/3949) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding. ([#3989](https://github.com/expo/eas-cli/pull/3989) by [@douglowder](https://github.com/douglowder))
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Accept a navigation metric name in observe:metrics when passed in on the command line. ([#3973](https://github.com/expo/eas-cli/pull/3973) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] add agent detection to analytics events. ([#3983](https://github.com/expo/eas-cli/pull/3983) by [@davidmokos](https://github.com/davidmokos))
- Bump `glob` 10.x to a patched version in the root lockfile. ([#3979](https://github.com/expo/eas-cli/pull/3979) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Bump `jws` to a patched version in the root lockfile. ([#3978](https://github.com/expo/eas-cli/pull/3978) by [@szdziedzic](https://github.com/szdziedzic))
- [worker] Bump `koa` to `3.1.2`. ([#3974](https://github.com/expo/eas-cli/pull/3974) by [@szdziedzic](https://github.com/szdziedzic))
- [steps] Bump `cross-spawn` to a patched version in the TypeScript custom function fixture. ([#3977](https://github.com/expo/eas-cli/pull/3977) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `node-forge` to `1.4.0`. ([#3970](https://github.com/expo/eas-cli/pull/3970) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `fast-xml-builder` to `1.2.1` to resolve [Dependabot alert 400](https://github.com/expo/eas-cli/security/dependabot/400). ([#3966](https://github.com/expo/eas-cli/pull/3966) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `form-data` to patched releases to resolve [Dependabot alert 465](https://github.com/expo/eas-cli/security/dependabot/465). ([#3962](https://github.com/expo/eas-cli/pull/3962) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `fast-xml-parser` to `4.5.7` to resolve [Dependabot alert 286](https://github.com/expo/eas-cli/security/dependabot/286). ([#3960](https://github.com/expo/eas-cli/pull/3960) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `shell-quote` to `1.9.0` to resolve [Dependabot alert 440](https://github.com/expo/eas-cli/security/dependabot/440). ([#3959](https://github.com/expo/eas-cli/pull/3959) by [@szdziedzic](https://github.com/szdziedzic))
## [20.5.1](https://github.com/expo/eas-cli/releases/tag/v20.5.1) - 2026-07-01
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.5.1",
"version": "21.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.5.1",
"version": "21.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,17 +38,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.5.1",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/logger": "21.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": "20.5.1",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/steps": "21.0.0",
"@expo/template-file": "21.0.0",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@ngrok/ngrok": "1.7.0",
@@ -1,6 +1,7 @@
import { BuildFunction } from '@expo/steps';
import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion';
import { createCapturePosthogEventFunction } from './functions/capturePosthogEvent';
import { createCheckoutBuildFunction } from './functions/checkout';
import { configureAndroidVersionFunction } from './functions/configureAndroidVersion';
import { configureEASUpdateIfInstalledFunction } from './functions/configureEASUpdateIfInstalled';
@@ -13,6 +14,7 @@ import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { createFinishIosSimulatorRecordingsBuildFunction } from './functions/finishIosSimulatorRecordings';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
@@ -37,6 +39,7 @@ import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
@@ -44,10 +47,16 @@ import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroi
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
import { createPosthogAnnotationFunction } from './functions/createPosthogAnnotation';
import { createWaitForPosthogQueryFunction } from './functions/waitForPosthogQuery';
import { CustomBuildContext } from '../customBuildContext';
export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
@@ -86,11 +95,20 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
createCapturePosthogEventFunction(),
createRolloutPosthogFlagFunction(),
createWaitForPosthogMetricFunction(),
createUploadPosthogSourcemapsFunction(),
createPosthogAnnotationFunction(),
createWaitForPosthogQueryFunction(),
calculateEASUpdateRuntimeVersionFunction(),
@@ -0,0 +1,176 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createCapturePosthogEventFunction } from '../capturePosthogEvent';
jest.mock('@expo/logger');
jest.mock('node-fetch');
describe(createCapturePosthogEventFunction, () => {
const fetchMock = jest.mocked(fetch);
const captureEvent = createCapturePosthogEventFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string>
): ReturnType<typeof captureEvent.createBuildStepFromFunctionCall> {
return captureEvent.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: captureEvent.id,
});
}
it('sends the event to the ingestion endpoint using the env key', async () => {
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
const step = createStep(
{ event: 'workflow_finished', distinct_id: 'account-1' },
{
EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test',
EXPO_PUBLIC_POSTHOG_HOST: 'https://us.posthog.com',
}
);
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/i/v0/e/');
const body = JSON.parse(init?.body as string);
expect(body).toMatchObject({
api_key: 'phc_test',
event: 'workflow_finished',
distinct_id: 'account-1',
});
expect(body.properties?.$process_person_profile).toBeUndefined();
});
it('sends an anonymous system event when no distinct_id is given', async () => {
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await step.executeAsync();
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.distinct_id).toBe('eas-workflow');
expect(body.properties.$process_person_profile).toBe(false);
});
it('skips when no api key is available and ignore_error is set', async () => {
const step = createStep(
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
{}
);
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error\.$/)
);
});
it('logs and does not throw on a non-2xx response when ignore_error is set', async () => {
fetchMock.mockResolvedValue({
status: 400,
ok: false,
text: async () => '',
} as unknown as Response);
const step = createStep(
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Sending PostHog event "workflow_finished" failed with status 400. Ignoring error.'
);
});
it('does not let properties override the anonymous marker', async () => {
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
const step = createStep(
{ event: 'workflow_finished', properties: { $process_person_profile: true, foo: 'bar' } },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await step.executeAsync();
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.properties.$process_person_profile).toBe(false);
expect(body.properties.foo).toBe('bar');
});
it('throws on a network error by default', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await expect(step.executeAsync()).rejects.toThrow(
'Sending PostHog event "workflow_finished" failed.'
);
});
it('logs and does not throw on a network error when ignore_error is set', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep(
{ event: 'workflow_finished', ignore_error: true },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Sending PostHog event "workflow_finished" failed. Ignoring error.'
);
});
it('throws with the PostHog error detail on failure by default', async () => {
fetchMock.mockResolvedValue({
status: 400,
ok: false,
text: async () => JSON.stringify({ detail: 'Invalid event name' }),
} as unknown as Response);
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: Invalid event name/);
});
it('falls back to the raw error body when PostHog omits a detail', async () => {
fetchMock.mockResolvedValue({
status: 400,
ok: false,
text: async () => JSON.stringify({ error: 'nope' }),
} as unknown as Response);
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: {"error":"nope"}/);
});
it('throws on missing key by default', async () => {
const step = createStep({ event: 'workflow_finished' }, {});
await expect(step.executeAsync()).rejects.toThrow(/PostHog API key not provided/);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,138 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createPosthogAnnotationFunction } from '../createPosthogAnnotation';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
} as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createPosthogAnnotationFunction, () => {
const fetchMock = jest.mocked(fetch);
const annotation = createPosthogAnnotationFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof annotation.createBuildStepFromFunctionCall> {
return annotation.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: annotation.id,
});
}
it('posts the annotation with an explicit date_marker', async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
const step = createStep({ content: 'Released abc123', date_marker: '2026-07-03T00:00:00Z' });
await step.executeAsync();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/api/projects/123/annotations/');
expect(JSON.parse(init?.body as string)).toEqual({
content: 'Released abc123',
date_marker: '2026-07-03T00:00:00Z',
});
});
it('defaults date_marker to now when not given', async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
const step = createStep({ content: 'Deploy' });
await step.executeAsync();
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.content).toBe('Deploy');
expect(typeof body.date_marker).toBe('string');
});
it('throws when credentials are missing by default', async () => {
const step = createStep({ content: 'Deploy' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('warns and skips when credentials are missing and ignore_error is set', async () => {
const step = createStep({ content: 'Deploy', ignore_error: true }, {});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
it('throws on a network error by default', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep({ content: 'Deploy' });
await expect(step.executeAsync()).rejects.toThrow(/Creating the PostHog annotation failed/);
});
it('warns and continues on a network error when ignore_error is set', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep({ content: 'Deploy', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
it('throws on a 403 without swallowing it', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ content: 'Deploy' });
await expect(step.executeAsync()).rejects.toThrow(/annotation:write/);
});
it('throws with the PostHog error detail on a non-2xx response', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ detail: 'Invalid date_marker' }, { ok: false, status: 400 })
);
const step = createStep({ content: 'Deploy' });
await expect(step.executeAsync()).rejects.toThrow(
/failed with status 400: Invalid date_marker/
);
});
it('warns and continues on a non-2xx response when ignore_error is set', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ content: 'Deploy', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
});
@@ -0,0 +1,443 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createRolloutPosthogFlagFunction } from '../rolloutPosthogFlag';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
} as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createRolloutPosthogFlagFunction, () => {
const fetchMock = jest.mocked(fetch);
const rolloutFlag = createRolloutPosthogFlagFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof rolloutFlag.createBuildStepFromFunctionCall> {
return rolloutFlag.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: rolloutFlag.id,
});
}
it('enables a flag by key', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await step.executeAsync();
const [patchUrl, patchInit] = fetchMock.mock.calls[1];
expect(patchUrl).toBe('https://us.posthog.com/api/projects/123/feature_flags/42/');
expect(patchInit?.method).toBe('PATCH');
expect(JSON.parse(patchInit?.body as string)).toEqual({ active: true });
});
it('sets rollout_percentage only on the catch-all group, preserving others', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
results: [
{
id: 7,
key: 'gradual',
filters: {
groups: [
{ properties: [{ key: 'is_internal' }], rollout_percentage: 100 },
{ properties: [], rollout_percentage: 5 },
],
},
},
],
})
)
.mockResolvedValueOnce(jsonResponse({ id: 7 }));
const step = createStep({ flag: 'gradual', rollout_percentage: '50' });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups).toEqual([
{ properties: [{ key: 'is_internal' }], rollout_percentage: 100 },
{ properties: [], rollout_percentage: 50 },
]);
});
it('creates a catch-all group when the flag has none', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 8, key: 'bare', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 8 }));
const step = createStep({ flag: 'bare', rollout_percentage: '25' });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups).toEqual([{ properties: [], rollout_percentage: 25 }]);
});
it('falls back to the first group with a warning when no catch-all exists', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
results: [
{
id: 9,
key: 'scoped',
filters: { groups: [{ properties: [{ key: 'is_internal' }] }] },
},
],
})
)
.mockResolvedValueOnce(jsonResponse({ id: 9 }));
const step = createStep({ flag: 'scoped', rollout_percentage: '10' });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups).toEqual([
{ properties: [{ key: 'is_internal' }], rollout_percentage: 10 },
]);
expect(warnMock).toHaveBeenCalledWith(expect.stringMatching(/no catch-all/));
});
it('throws when the flag key does not exist', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [{ id: 1, key: 'other-flag' }] }));
const step = createStep({ flag: 'missing-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(
/No PostHog feature flag with key "missing-flag"/
);
});
it('throws on a 403 even when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
});
it('throws when none of active, rollout_percentage, or payload is provided', async () => {
const step = createStep({ flag: 'my-flag' });
await expect(step.executeAsync()).rejects.toThrow(
/"active", "rollout_percentage", or "payload"/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('sets a boolean-flag payload under the "true" key as a JSON string', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', payload: { color: 'blue', count: 3 } });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.payloads).toEqual({ true: '{"color":"blue","count":3}' });
});
it('keys the payload under a given variant', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', payload: { v: 1 }, variant: 'control' });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.payloads).toEqual({ control: '{"v":1}' });
});
it('sets rollout_percentage and payload together in one filters object', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
results: [{ id: 7, key: 'my-flag', filters: { groups: [{ properties: [] }] } }],
})
)
.mockResolvedValueOnce(jsonResponse({ id: 7 }));
const step = createStep({ flag: 'my-flag', rollout_percentage: 25, payload: { a: 1 } });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups[0].rollout_percentage).toBe(25);
expect(patchBody.filters.payloads).toEqual({ true: '{"a":1}' });
});
it('surfaces the PostHog error detail on a failed update', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(
jsonResponse(
{ type: 'validation_error', detail: 'Variant control does not exist' },
{ ok: false, status: 400 }
)
);
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(
/failed with status 400: Variant control does not exist/
);
});
it('falls back to the raw body when the error body is not JSON', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce({
ok: false,
status: 502,
text: async () => 'Bad Gateway',
} as unknown as Response);
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(/failed with status 502: Bad Gateway/);
});
it('warns and skips when the flag is not found and ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [] }));
const step = createStep({ flag: 'missing-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/No PostHog feature flag with key "missing-flag"/)
);
});
it('throws on an out-of-range rollout_percentage', async () => {
const step = createStep({ flag: 'my-flag', rollout_percentage: '150' });
await expect(step.executeAsync()).rejects.toThrow(/between 0 and 100/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws when credentials are missing by default', async () => {
const step = createStep({ flag: 'my-flag', active: 'true' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('warns and skips on missing credentials when ignore_error is set', async () => {
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true }, {});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error\.$/)
);
});
it('throws on a failed update by default', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(/failed with status 500/);
});
it('warns and skips on a failed update when ignore_error is set', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Updating PostHog flag "my-flag" failed with status 500: {}. Ignoring error.'
);
});
it('warns and skips on a lookup network error when ignore_error is set', async () => {
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Looking up PostHog flag "my-flag" failed. Ignoring error.'
);
});
it('warns and skips on a non-object lookup response when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse('not-json-object'));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.anything() },
expect.stringMatching(/unexpected response/)
);
});
it('warns and skips on an unexpected lookup response when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ nope: true }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.anything() },
expect.stringMatching(/unexpected response/)
);
});
it('ignores malformed entries in the lookup results', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({ results: ['junk', null, { id: 42, key: 'my-flag', filters: {} }] })
)
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws on a failed lookup by default', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(
/Looking up PostHog flag "my-flag" failed with status 500/
);
});
it('warns and skips on a failed lookup when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Looking up PostHog flag "my-flag" failed with status 500: {}. Ignoring error.'
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('warns and skips on an update network error when ignore_error is set', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockRejectedValueOnce(new Error('ECONNRESET'));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Updating PostHog flag "my-flag" failed. Ignoring error.'
);
});
it('throws on a 403 update even when ignore_error is set', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
await expect(step.executeAsync()).rejects.toThrow(/feature_flag:write/);
});
it('debugs the underlying error before throwing on a network failure by default', async () => {
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));
const step = createStep({ flag: 'my-flag', active: 'true' });
const debugMock = jest.spyOn(step.ctx.logger, 'debug');
await expect(step.executeAsync()).rejects.toThrow(/Looking up PostHog flag "my-flag" failed/);
expect(debugMock).toHaveBeenCalled();
});
it('throws on an invalid JSON lookup response by default', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('unexpected token');
},
} as unknown as Response);
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(/returned an unexpected response/);
});
it('warns and skips on an invalid JSON lookup response when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('unexpected token');
},
} as unknown as Response);
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'PostHog flag lookup for "my-flag" returned an unexpected response. Ignoring error.'
);
});
it('throws on a non-integer rollout_percentage', async () => {
const step = createStep({ flag: 'my-flag', rollout_percentage: 25.5 });
await expect(step.executeAsync()).rejects.toThrow(/must be an integer between 0 and 100/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('falls back to the default host when POSTHOG_CLI_HOST is unset', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep(
{ flag: 'my-flag', active: 'true' },
{ POSTHOG_CLI_API_KEY: 'phx_test', POSTHOG_CLI_PROJECT_ID: '123' }
);
await step.executeAsync();
const [searchUrl] = fetchMock.mock.calls[0];
expect(searchUrl).toBe(
'https://us.posthog.com/api/projects/123/feature_flags/?limit=200&search=my-flag'
);
});
});
@@ -1,8 +1,12 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
waitForArgentToolServerStateAsync,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
@@ -70,3 +74,72 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
);
});
});
describe(waitForArgentToolServerStateAsync, () => {
let stateDir: string;
beforeEach(async () => {
stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-state-test-'));
});
afterEach(async () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
});
function stateJson({ port, pid, token }: { port: number; pid: number; token?: string }): string {
return JSON.stringify({ port, pid, token });
}
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
'reads a matching process from %s',
async fileName => {
await fs.promises.writeFile(
path.join(stateDir, fileName),
stateJson({ port: 4321, pid: process.pid, token: 'secret' })
);
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
})
).resolves.toEqual({ port: 4321, pid: process.pid, token: 'secret' });
}
);
it('selects the state whose pid belongs to the launched process tree', async () => {
await fs.promises.writeFile(
path.join(stateDir, 'tool-server-111111111111.json'),
stateJson({ port: 1111, pid: Number.MAX_SAFE_INTEGER })
);
await fs.promises.writeFile(
path.join(stateDir, 'tool-server-222222222222.json'),
stateJson({ port: 2222, pid: process.pid })
);
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
})
).resolves.toEqual({ port: 2222, pid: process.pid });
});
it('does not return a state file outside the launched process tree', async () => {
const filePath = path.join(stateDir, 'tool-server-012345abcdef.json');
await fs.promises.writeFile(filePath, stateJson({ port: 1111, pid: Number.MAX_SAFE_INTEGER }));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 10,
pollIntervalMs: 1,
})
).rejects.toThrow(`state file belonging to process ${process.pid}`);
});
});
@@ -0,0 +1,67 @@
import { BuildRuntimePlatform } from '@expo/steps';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from '../uploadDeviceRunSessionScreenRecordings';
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
uploadDeviceRunSessionArtifactAsync: jest.fn(),
}));
describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
it('uploads simulator device metadata', async () => {
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
await fs.writeFile(recordingPath, 'recording');
await fs.writeFile(
path.join(recordingDirectory, 'session.json'),
JSON.stringify({
recording: path.basename(recordingPath),
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
})
);
try {
const globalContext = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
{} as CustomBuildContext
).createBuildStepFromFunctionCall(globalContext, {
env: { DEVICE_RUN_SESSION_ID: 'device-run-session-id' },
callInputs: {
recordings_json: [
{
udid: 'simulator-udid',
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
directory: recordingDirectory,
},
],
},
});
await buildStep.executeAsync();
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'iPhone 16 screen recording',
metadata: {
__eas_screen_recording: '1',
udid: 'simulator-udid',
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
firstFrameAt: '2026-07-10T10:00:00.000Z',
},
})
);
} finally {
await fs.rm(recordingDirectory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,144 @@
import { PipeMode } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createUploadPosthogSourcemapsFunction } from '../uploadPosthogSourcemaps';
jest.mock('@expo/logger');
jest.mock('@expo/turtle-spawn');
const spawnMock = jest.mocked(spawn);
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createUploadPosthogSourcemapsFunction, () => {
const uploadSourcemaps = createUploadPosthogSourcemapsFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown> = {},
env: Record<string, string> = ENV
): ReturnType<typeof uploadSourcemaps.createBuildStepFromFunctionCall> {
return uploadSourcemaps.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: uploadSourcemaps.id,
});
}
it('runs @posthog/cli hermes upload with the resolved credentials and host', async () => {
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
ReturnType<typeof spawn>
>);
const step = createStep();
await step.executeAsync();
expect(spawnMock).toHaveBeenCalledTimes(1);
const [command, args, options] = spawnMock.mock.calls[0];
expect(command).toBe('npx');
expect(args).toEqual([
'-y',
'@posthog/cli',
'--host',
'https://us.posthog.com',
'hermes',
'upload',
'--directory',
'dist',
]);
expect(options?.env).toMatchObject({
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
});
expect(options?.mode).toBe(PipeMode.COMBINED_AS_STDOUT);
});
it('passes a custom host through to --host', async () => {
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
ReturnType<typeof spawn>
>);
const step = createStep({}, { ...ENV, POSTHOG_CLI_HOST: 'https://eu.i.posthog.com' });
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual([
'-y',
'@posthog/cli',
'--host',
'https://eu.i.posthog.com',
'hermes',
'upload',
'--directory',
'dist',
]);
});
it('uploads the given directory', async () => {
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
ReturnType<typeof spawn>
>);
const step = createStep({ directory: 'build-output' });
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual([
'-y',
'@posthog/cli',
'--host',
'https://us.posthog.com',
'hermes',
'upload',
'--directory',
'build-output',
]);
});
it('throws when credentials are missing by default', async () => {
const step = createStep({}, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(spawnMock).not.toHaveBeenCalled();
});
it('warns and skips when credentials are missing and ignore_error is set', async () => {
const step = createStep({ ignore_error: true }, {});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(spawnMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
it('throws when the upload fails by default', async () => {
spawnMock.mockRejectedValue(new Error('upload failed'));
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/Uploading source maps to PostHog failed/);
});
it('warns and continues when the upload fails and ignore_error is set', async () => {
spawnMock.mockRejectedValue(new Error('upload failed'));
const step = createStep({ ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
});
@@ -0,0 +1,263 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createWaitForPosthogMetricFunction } from '../waitForPosthogMetric';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
} as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createWaitForPosthogMetricFunction, () => {
const fetchMock = jest.mocked(fetch);
const waitForMetric = createWaitForPosthogMetricFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof waitForMetric.createBuildStepFromFunctionCall> {
return waitForMetric.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: waitForMetric.id,
});
}
it('clears when the metric already meets the condition (blocking query)', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[3]] }));
const step = createStep({
query: 'select count() from events',
operator: 'lt',
threshold: '5',
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/api/projects/123/query/');
expect(JSON.parse(init?.body as string)).toMatchObject({
query: { kind: 'HogQLQuery' },
refresh: 'blocking',
});
expect(step.outputById.value.value).toBe('3');
});
it('throws when credentials are missing', async () => {
const step = createStep({ query: 'q', operator: 'lt', threshold: '5' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on an invalid operator', async () => {
const step = createStep({ query: 'q', operator: 'between', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(/Invalid "operator"/);
});
it('rejects an Object.prototype key as an operator', async () => {
const step = createStep({ query: 'q', operator: 'toString', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(/Invalid "operator"/);
});
it('throws with the PostHog error detail when the query fails to run', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ detail: 'Malformed HogQL query' }, { ok: false, status: 400 })
);
const step = createStep({ query: 'bad', operator: 'lt', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(
/failed with status 400: Malformed HogQL query/
);
});
it('retries after a transient 500 and clears when the metric recovers', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps polling while the query returns no numeric value', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [['not-a-number']] }))
.mockResolvedValueOnce(jsonResponse({ results: [[2]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('reports a query-shape problem when no numeric value ever arrives', async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [['not-a-number']] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 0.15,
});
await expect(step.executeAsync()).rejects.toThrow(/never returned a numeric value/);
});
it('includes the last observed value in the timeout error', async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [[10]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 0.15,
});
await expect(step.executeAsync()).rejects.toThrow(/last value: 10/);
});
it('throws on a non-positive interval', async () => {
const step = createStep({ query: 'q', operator: 'lt', threshold: '5', interval_seconds: 0 });
await expect(step.executeAsync()).rejects.toThrow(/greater than 0/);
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
['lte', 3, 3],
['gt', 10, 5],
['gte', 5, 5],
['eq', 3, 3],
] as const)('clears with the %s operator', async (operator, value, threshold) => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[value]] }));
const step = createStep({ query: 'q', operator, threshold });
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('treats a non-numeric non-string cell as no value and keeps polling', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('retries after a network error and clears when the query recovers', async () => {
fetchMock
.mockRejectedValueOnce(new Error('ECONNRESET'))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(warnMock).toHaveBeenCalledWith(
'Running the PostHog query failed with a network error; will retry on the next poll.'
);
});
it('retries after an invalid JSON response and clears when the query recovers', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('unexpected token');
},
} as unknown as Response)
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws on a 403 without retrying', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ query: 'q', operator: 'lt', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('keeps polling through malformed response shapes until a numeric value arrives', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [[Infinity]] }))
.mockResolvedValueOnce(jsonResponse('not an object'))
.mockResolvedValueOnce(jsonResponse({ results: [5] }))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,109 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createWaitForPosthogQueryFunction } from '../waitForPosthogQuery';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return { ok, status, json: async () => data } as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createWaitForPosthogQueryFunction, () => {
const fetchMock = jest.mocked(fetch);
const waitForQuery = createWaitForPosthogQueryFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof waitForQuery.createBuildStepFromFunctionCall> {
return waitForQuery.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: waitForQuery.id,
});
}
it('resolves when the query returns true', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
const step = createStep({ query: 'SELECT count() > 0 FROM events' });
await expect(step.executeAsync()).resolves.not.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(init?.body as string).query).toEqual({
kind: 'HogQLQuery',
query: 'SELECT count() > 0 FROM events',
});
});
it('resolves when the query returns a nonzero number', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({ query: 'q' });
await expect(step.executeAsync()).resolves.not.toThrow();
});
it('keeps polling until the query turns true', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [[0]] }))
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 });
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps polling after a transient failure and clears when the query recovers', async () => {
fetchMock
.mockRejectedValueOnce(new Error('ECONNRESET'))
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 });
await expect(step.executeAsync()).resolves.not.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws when the query never returns true within the timeout', async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [[false]] }));
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 0.1 });
await expect(step.executeAsync()).rejects.toThrow(/did not return true within/);
});
it('throws when credentials are missing', async () => {
const step = createStep({ query: 'q' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on a non-positive interval', async () => {
const step = createStep({ query: 'q', interval_seconds: 0 });
await expect(step.executeAsync()).rejects.toThrow(/greater than 0/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on a 403 without retrying', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ query: 'q' });
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,82 @@
import { UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createCapturePosthogEventFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_capture_event',
name: 'Capture PostHog event',
__metricsId: 'eas/posthog_capture_event',
inputProviders: [
BuildStepInput.createProvider({
id: 'event',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'distinct_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'properties',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'host',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const event = inputs.event.value as string;
const client = PosthogClient.forIngestion({
apiKeyOverride: inputs.api_key.value as string | undefined,
hostOverride: inputs.host.value as string | undefined,
env,
});
if (!client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_MISSING_API_KEY',
'PostHog API key not provided. Set the "api_key" input or the EXPO_PUBLIC_POSTHOG_API_KEY environment variable.'
),
});
return;
}
logger.info(`Sending PostHog event "${event}"`);
try {
await client.captureEventAsync({
event,
distinctId: inputs.distinct_id.value as string | undefined,
properties: inputs.properties.value as Record<string, unknown> | undefined,
signal,
});
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
logger.info(`Sent PostHog event "${event}"`);
},
});
}
@@ -0,0 +1,78 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createPosthogAnnotationFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_annotation',
name: 'Create a PostHog annotation',
__metricsId: 'eas/posthog_annotation',
inputProviders: [
BuildStepInput.createProvider({
id: 'content',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'date_marker',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: PosthogUtils.missingCredentialsError(result.missing),
});
return;
}
const client = result.client;
const content = inputs.content.value as string;
const dateMarker =
(inputs.date_marker.value as string | undefined) ?? new Date().toISOString();
logger.info(`Creating PostHog annotation "${content}"`);
try {
await client.requestAsync('POST', '/annotations/', {
action: 'Creating the PostHog annotation',
forbiddenScope: 'annotation:write',
body: { content, date_marker: dateMarker },
signal,
});
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
logger.info('Created PostHog annotation');
},
});
}
@@ -0,0 +1,23 @@
import { BuildFunction, BuildRuntimePlatform, BuildStepOutput } from '@expo/steps';
import { IosSimulatorRecordingUtils } from '../utils/IosSimulatorRecordingUtils';
export function createFinishIosSimulatorRecordingsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'finish_ios_simulator_recordings',
name: 'Finish iOS Simulator recordings',
__metricsId: 'eas/finish_ios_simulator_recordings',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
outputProviders: [
BuildStepOutput.createProvider({
id: 'recordings_json',
required: true,
}),
],
fn: async ({ logger }, { outputs }) => {
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
outputs.recordings_json.set(JSON.stringify(recordings));
},
});
}
@@ -0,0 +1,279 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { z } from 'zod';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
const SEARCH_LIMIT = 200;
const FeatureFlagGroupSchema = z.object({ properties: z.unknown().optional() }).passthrough();
const FeatureFlagSchema = z
.object({
id: z.number(),
key: z.string(),
filters: z
.object({
groups: z.array(FeatureFlagGroupSchema).optional(),
payloads: z.record(z.string(), z.unknown()).optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const FlagSearchResponseSchema = z.object({ results: z.array(z.unknown()) });
type FeatureFlagGroup = z.infer<typeof FeatureFlagGroupSchema>;
type FeatureFlagFilters = NonNullable<z.infer<typeof FeatureFlagSchema>['filters']>;
function isCatchAllGroup(group: FeatureFlagGroup): boolean {
const { properties } = group;
return (
properties === undefined ||
properties === null ||
(Array.isArray(properties) && properties.length === 0)
);
}
export function createRolloutPosthogFlagFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_flag_rollout',
name: 'Roll out a PostHog feature flag',
__metricsId: 'eas/posthog_flag_rollout',
inputProviders: [
BuildStepInput.createProvider({
id: 'flag',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'active',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'rollout_percentage',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
}),
BuildStepInput.createProvider({
id: 'payload',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: false,
}),
BuildStepInput.createProvider({
id: 'variant',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const flagKey = inputs.flag.value as string;
const active = inputs.active.value as boolean | undefined;
const rolloutPercentage = inputs.rollout_percentage.value as number | undefined;
const payload = inputs.payload.value as Record<string, unknown> | undefined;
const variant = inputs.variant.value as string | undefined;
if (active === undefined && rolloutPercentage === undefined && payload === undefined) {
throw new UserError(
'EAS_POSTHOG_FLAG_NO_CHANGE',
'You need to provide "active", "rollout_percentage", or "payload" to change the feature flag.'
);
}
if (
rolloutPercentage !== undefined &&
(!Number.isInteger(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100)
) {
throw new UserError(
'EAS_POSTHOG_FLAG_INVALID_ROLLOUT',
`"rollout_percentage" must be an integer between 0 and 100, got ${rolloutPercentage}.`
);
}
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: PosthogUtils.missingCredentialsError(result.missing),
});
return;
}
const client = result.client;
await rolloutPosthogFlagAsync({
logger,
client,
flagKey,
active,
rolloutPercentage,
payload,
variant,
ignoreError,
signal,
});
},
});
}
async function rolloutPosthogFlagAsync({
logger,
client,
flagKey,
active,
rolloutPercentage,
payload,
variant,
ignoreError,
signal,
}: {
logger: bunyan;
client: PosthogClient;
flagKey: string;
active: boolean | undefined;
rolloutPercentage: number | undefined;
payload: Record<string, unknown> | undefined;
variant: string | undefined;
ignoreError: boolean;
signal: AbortSignal | undefined;
}): Promise<void> {
let searchResponse;
try {
searchResponse = await client.requestAsync(
'GET',
`/feature_flags/?limit=${SEARCH_LIMIT}&search=${encodeURIComponent(flagKey)}`,
{
action: `Looking up PostHog flag "${flagKey}"`,
forbiddenScope: 'feature_flag:read',
signal,
}
);
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
let results: unknown[];
try {
results = FlagSearchResponseSchema.parse(await searchResponse.json()).results;
} catch (error) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_FLAG_UNEXPECTED_RESPONSE',
`PostHog flag lookup for "${flagKey}" returned an unexpected response.`,
{ cause: error }
),
});
return;
}
const flag = results
.flatMap(result => {
const parsed = FeatureFlagSchema.safeParse(result);
return parsed.success ? [parsed.data] : [];
})
.find(candidate => candidate.key === flagKey);
if (!flag) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_FLAG_NOT_FOUND',
`No PostHog feature flag with key "${flagKey}" was found. Check the "flag" input.`
),
});
return;
}
const patch: Record<string, unknown> = {};
if (active !== undefined) {
patch.active = active;
}
let filters = flag.filters;
if (rolloutPercentage !== undefined) {
filters = buildRolloutFilters({ logger, flagKey, filters, rolloutPercentage });
}
if (payload !== undefined) {
filters = {
...filters,
payloads: { ...filters?.payloads, [variant ?? 'true']: JSON.stringify(payload) },
};
}
if (rolloutPercentage !== undefined || payload !== undefined) {
patch.filters = filters;
}
try {
await client.requestAsync('PATCH', `/feature_flags/${flag.id}/`, {
action: `Updating PostHog flag "${flagKey}"`,
forbiddenScope: 'feature_flag:write',
body: patch,
signal,
});
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
logger.info(`Updated PostHog feature flag "${flagKey}".`);
}
function buildRolloutFilters({
logger,
flagKey,
filters,
rolloutPercentage,
}: {
logger: bunyan;
flagKey: string;
filters: FeatureFlagFilters | undefined;
rolloutPercentage: number;
}): FeatureFlagFilters {
const groups = filters?.groups ?? [];
if (groups.length === 0) {
return { ...filters, groups: [{ properties: [], rollout_percentage: rolloutPercentage }] };
}
const catchAllIndex = groups.findIndex(isCatchAllGroup);
const hasCatchAll = catchAllIndex >= 0;
const targetIndex = hasCatchAll ? catchAllIndex : 0;
if (!hasCatchAll) {
logger.warn(
`Flag "${flagKey}" has no catch-all release-condition group; setting rollout_percentage on the first group, which is condition-scoped.`
);
} else if (groups.length > 1) {
logger.warn(
`Flag "${flagKey}" has ${groups.length} release-condition groups; setting rollout_percentage only on the catch-all group to preserve the others.`
);
}
const groupsWithRollout = groups.map((group, index) =>
index === targetIndex ? { ...group, rollout_percentage: rolloutPercentage } : group
);
return { ...filters, groups: groupsWithRollout };
}
@@ -25,6 +25,7 @@ import {
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
@@ -53,7 +54,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
@@ -121,10 +122,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
},
});
}
@@ -14,6 +14,9 @@ import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import {
getDeviceRunSessionIdOrThrow,
@@ -24,20 +27,23 @@ import {
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({
port: z.number(),
pid: z.number(),
token: z.string().optional(),
});
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
@@ -53,7 +59,7 @@ export function createStartArgentRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
@@ -74,8 +80,6 @@ export function createStartArgentRemoteSessionBuildFunction(
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
@@ -84,6 +88,8 @@ export function createStartArgentRemoteSessionBuildFunction(
);
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bunx',
args: [
@@ -94,20 +100,24 @@ export function createStartArgentRemoteSessionBuildFunction(
'0',
'--idle-timeout',
'0',
'--detach',
'--force',
],
env,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -120,51 +130,69 @@ export function createStartArgentRemoteSessionBuildFunction(
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
void pollArgentArtifactsForUploadAsync(ctx, {
const artifactPollAbortController = new AbortController();
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
const publicToolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync(ctx, {
try {
const publicToolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
env,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
} finally {
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
}
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the tool-server and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
@@ -199,12 +227,64 @@ export function warnIfArgentPackageVersionCannotBeVerified({
}
}
function parseArgentToolServerState(raw: string): { port: number; token?: string } {
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
function parseArgentToolServerState(raw: string): ArgentToolServerState {
let json: unknown;
try {
json = JSON.parse(raw);
} catch (err) {
throw new SystemError('Expected tool-server state to contain valid JSON.', { cause: err });
}
const result = ArgentToolServerStateSchema.safeParse(json);
if (!result.success) {
throw new SystemError(
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
`Expected tool-server state to contain { "port": <number>, "pid": <number>, ... }: ${result.error.message}`
);
}
return result.data;
}
export async function waitForArgentToolServerStateAsync({
stateDir,
ancestorPid,
timeoutMs,
pollIntervalMs = 1_000,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
}): Promise<ArgentToolServerState> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
name => name.startsWith('tool-server') && name.endsWith('.json')
);
for (const stateFileName of stateFileNames) {
try {
const state = parseArgentToolServerState(
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
);
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
return state;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
throw err;
}
}
}
} catch (err) {
lastError = err;
}
await sleepAsync(pollIntervalMs);
}
throw new SystemError(
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}.`
);
}
@@ -0,0 +1,19 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { IosSimulatorRecordingUtils } from '../utils/IosSimulatorRecordingUtils';
export function createStartIosSimulatorRecordingsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_ios_simulator_recordings',
name: 'Start iOS Simulator recordings',
__metricsId: 'eas/start_ios_simulator_recordings',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
await IosSimulatorRecordingUtils.startAsync({
env,
logger,
});
},
});
}
@@ -7,6 +7,7 @@ import {
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const STARTUP_TIMEOUT_MS = 60_000;
@@ -20,7 +21,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
fn: async ({ logger }, { env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
@@ -44,10 +45,12 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the serve-sim tunnel stays reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
},
});
}
@@ -0,0 +1,111 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
const RecordingManifestSchema = z.object({
firstFrameWallClock: z.object({
iso8601: z.string(),
}),
recording: z.string(),
});
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'upload_device_run_session_screen_recordings',
name: 'Upload device run session screen recordings',
__metricsId: 'eas/upload_device_run_session_screen_recordings',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'recordings_json',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async ({ logger }, { env, inputs }) => {
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
if (!result.success) {
throw new SystemError('Invalid iOS Simulator recordings input.', {
cause: result.error,
});
}
const recordings = result.data;
if (recordings.length === 0) {
logger.info('No iOS Simulator recordings found; skipping uploads.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const limit = limitFactory(5);
await Promise.all(
recordings.map(recording =>
limit(async () => {
const displayName = `${recording.deviceName} screen recording`;
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const recordingPath = path.join(recording.directory, metadata.recording);
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
},
size,
stream: createReadStream(recordingPath),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload iOS Simulator screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.deviceName}.`
);
}
})
)
);
},
});
}
@@ -0,0 +1,89 @@
import { UserError } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createUploadPosthogSourcemapsFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_upload_sourcemaps',
name: 'Upload source maps to PostHog',
__metricsId: 'eas/posthog_upload_sourcemaps',
inputProviders: [
BuildStepInput.createProvider({
id: 'directory',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: PosthogUtils.missingCredentialsError(result.missing),
});
return;
}
const client = result.client;
const directory = inputs.directory.value as string;
const { host, env: cliEnv } = client.cliConfig();
logger.info(`Uploading source maps from "${directory}" to PostHog`);
try {
await spawn(
'npx',
['-y', '@posthog/cli', '--host', host, 'hermes', 'upload', '--directory', directory],
{
logger,
// @posthog/cli writes all its logs, progress and success included, to stderr; tag them as stdout so they don't surface as build errors.
mode: PipeMode.COMBINED_AS_STDOUT,
cwd: stepCtx.workingDirectory,
env: { ...env, ...cliEnv },
signal,
}
);
} catch (error) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_SOURCEMAPS_FAILED',
'Uploading source maps to PostHog failed.',
{ cause: error }
),
});
return;
}
logger.info('Uploaded source maps to PostHog');
},
});
}
@@ -0,0 +1,185 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
const OPERATORS = {
lt: { symbol: '<', test: (value: number, threshold: number) => value < threshold },
lte: { symbol: '<=', test: (value: number, threshold: number) => value <= threshold },
gt: { symbol: '>', test: (value: number, threshold: number) => value > threshold },
gte: { symbol: '>=', test: (value: number, threshold: number) => value >= threshold },
eq: { symbol: '=', test: (value: number, threshold: number) => value === threshold },
};
type Operator = keyof typeof OPERATORS;
export function createWaitForPosthogMetricFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_wait_for_metric',
name: 'Wait for a PostHog metric',
__metricsId: 'eas/posthog_wait_for_metric',
inputProviders: [
BuildStepInput.createProvider({
id: 'query',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'operator',
allowedValues: Object.keys(OPERATORS),
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'threshold',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
}),
BuildStepInput.createProvider({
id: 'timeout_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS,
}),
BuildStepInput.createProvider({
id: 'interval_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
],
outputProviders: [BuildStepOutput.createProvider({ id: 'value', required: false })],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger } = stepCtx;
const operator = inputs.operator.value as string;
if (!Object.keys(OPERATORS).includes(operator)) {
throw new UserError(
'EAS_POSTHOG_METRIC_INVALID_OPERATOR',
`Invalid "operator" "${operator}". Must be one of: ${Object.keys(OPERATORS).join(', ')}.`
);
}
const timeoutSeconds = inputs.timeout_seconds.value as number;
const intervalSeconds = inputs.interval_seconds.value as number;
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds,
intervalSeconds,
errorCode: 'EAS_POSTHOG_METRIC_INVALID_INTERVAL',
});
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
throw PosthogUtils.missingCredentialsError(result.missing);
}
const client = result.client;
const value = await waitForPosthogMetricAsync({
logger,
client,
query: inputs.query.value as string,
operator: operator as Operator,
threshold: inputs.threshold.value as number,
timeoutSeconds,
intervalSeconds,
signal,
});
outputs.value.set(String(value));
},
});
}
async function waitForPosthogMetricAsync({
logger,
client,
query,
operator,
threshold,
timeoutSeconds,
intervalSeconds,
signal,
}: {
logger: bunyan;
client: PosthogClient;
query: string;
operator: Operator;
threshold: number;
timeoutSeconds: number;
intervalSeconds: number;
signal: AbortSignal | undefined;
}): Promise<number> {
const deadline = Date.now() + timeoutSeconds * 1000;
const { symbol, test } = OPERATORS[operator];
const target = `${symbol} ${threshold}`;
let lastValue: number | undefined;
logger.info(
`Waiting for the PostHog metric to reach ${target}. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.`
);
for (;;) {
let value: number | undefined = undefined;
try {
value = await queryMetricAsync({ logger, client, query, signal });
} catch (error) {
if (!(error instanceof PosthogRetryableError)) {
throw error;
}
}
if (value !== undefined) {
lastValue = value;
if (test(value, threshold)) {
logger.info(`Metric is ${value}. Target ${target} met.`);
return value;
}
logger.info(`Metric is ${value}. Still waiting for ${target}.`);
} else {
logger.info('The query has not returned a numeric value yet. Still waiting.');
}
if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) {
throw new UserError(
'EAS_POSTHOG_METRIC_TIMEOUT',
lastValue !== undefined
? `The PostHog metric did not reach ${target} within ${timeoutSeconds}s (last value: ${lastValue}).`
: `The PostHog query never returned a numeric value within ${timeoutSeconds}s. Check that the query returns a single number and that PostHog is reachable.`
);
}
}
}
async function queryMetricAsync({
logger,
client,
query,
signal,
}: {
logger: bunyan;
client: PosthogClient;
query: string;
signal: AbortSignal | undefined;
}): Promise<number | undefined> {
const cell = await client.runQueryAsync(query, logger, signal);
return typeof cell === 'number' && Number.isFinite(cell) ? cell : undefined;
}
@@ -0,0 +1,120 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createWaitForPosthogQueryFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_wait_for_query',
name: 'Wait for a PostHog query',
__metricsId: 'eas/posthog_wait_for_query',
inputProviders: [
BuildStepInput.createProvider({
id: 'query',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'timeout_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS,
}),
BuildStepInput.createProvider({
id: 'interval_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const timeoutSeconds = inputs.timeout_seconds.value as number;
const intervalSeconds = inputs.interval_seconds.value as number;
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds,
intervalSeconds,
errorCode: 'EAS_POSTHOG_QUERY_INVALID_INTERVAL',
});
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
throw PosthogUtils.missingCredentialsError(result.missing);
}
const client = result.client;
await waitForPosthogQueryAsync({
logger,
client,
query: inputs.query.value as string,
timeoutSeconds,
intervalSeconds,
signal,
});
},
});
}
async function waitForPosthogQueryAsync({
logger,
client,
query,
timeoutSeconds,
intervalSeconds,
signal,
}: {
logger: bunyan;
client: PosthogClient;
query: string;
timeoutSeconds: number;
intervalSeconds: number;
signal: AbortSignal | undefined;
}): Promise<void> {
const deadline = Date.now() + timeoutSeconds * 1000;
logger.info(
`Waiting for the PostHog query to return true. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.`
);
for (;;) {
let cell: unknown;
try {
cell = await client.runQueryAsync(query, logger, signal);
} catch (error) {
if (!(error instanceof PosthogRetryableError)) {
throw error;
}
cell = undefined;
}
if (cell === true || (typeof cell === 'number' && cell !== 0)) {
logger.info('Query returned true.');
return;
}
logger.info('Query is not true yet. Still waiting.');
if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) {
throw new UserError(
'EAS_POSTHOG_QUERY_TIMEOUT',
`The PostHog query did not return true within ${timeoutSeconds}s. It must select a single boolean, e.g. "SELECT count() > 100 FROM events".`
);
}
}
}
@@ -0,0 +1,303 @@
import { type Env } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { access, mkdir, mkdtemp } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { Sentry } from '../../sentry';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
const RECORD_SIM_FINISH_TIMEOUT_MS = 30_000;
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
const RECORD_SIM_COMMAND = 'record-sim';
type IosSimulatorRecording = {
id: string;
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
outputDirectory: string;
startedAt: Date;
getOutput: () => string;
};
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
recordingProcess: ChildProcess;
completionPromise: Promise<void>;
};
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordSimCommand: string;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
pollingPromise: Promise<void>;
abortController: AbortController;
};
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
export namespace IosSimulatorRecordingUtils {
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
return;
}
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
if (!recordSimCommand) {
logger.warn(
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
);
return;
}
const recordingsRootDirectory = await mkdtemp(
path.join(os.tmpdir(), 'ios-simulator-recordings-')
);
const session: IosSimulatorRecordingSession = {
env,
logger,
recordSimCommand,
recordingsRootDirectory,
activeRecordings: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
pollingPromise: Promise.resolve(),
abortController: new AbortController(),
};
activeIosSimulatorRecordingSession = session;
logger.info('Started polling iOS Simulators for screen recordings.');
session.pollingPromise = pollIosSimulatorRecordingsAsync(session).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording poller failed', error);
logger.warn({ err: error }, 'iOS Simulator screen recording poller failed.');
});
}
export async function finishAsync({ logger }: { logger: bunyan }): Promise<
{
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
directory: string;
}[]
> {
const session = activeIosSimulatorRecordingSession;
if (!session) {
logger.info('No iOS Simulator screen recordings are running.');
return [];
}
activeIosSimulatorRecordingSession = null;
session.abortController.abort();
await session.pollingPromise;
await Promise.all(
[...session.activeRecordings.values()].map(async recording => {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
recording.recordingProcess.kill('SIGINT');
const finished = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
]);
if (finished) {
return;
}
logger.warn(`Forcing iOS Simulator recording process for ${recording.deviceName} to stop.`);
recording.recordingProcess.kill('SIGKILL');
const killed = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
]);
if (!killed) {
logger.warn(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
Sentry.capture(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
{
extras: {
output: recording.getOutput(),
},
}
);
}
})
);
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
return completedRecordings.map(recording => ({
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
directory: recording.outputDirectory,
}));
}
}
async function pollIosSimulatorRecordingsAsync(
session: IosSimulatorRecordingSession
): Promise<void> {
let listDevicesErrorCount = 0;
const signal = session.abortController.signal;
while (!signal.aborted) {
try {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env: session.env,
filter: 'booted',
});
if (signal.aborted) {
break;
}
listDevicesErrorCount = 0;
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
for (const udid of session.recordingFailureCounts.keys()) {
if (!bootedUdids.has(udid)) {
session.recordingFailureCounts.delete(udid);
}
}
for (const device of bootedDevices) {
if (
session.activeRecordings.has(device.udid) ||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
) {
continue;
}
await startIosSimulatorRecordingAsync(session, {
udid: device.udid,
deviceName: device.name,
runtimeDisplayName: device.runtimeDisplayName,
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
listDevicesErrorCount += 1;
if (listDevicesErrorCount === 1 || listDevicesErrorCount % 5 === 0) {
Sentry.capture('Could not poll iOS Simulators for screen recordings', error);
session.logger.warn(
{ err: error, failedSimulatorListCount: listDevicesErrorCount },
'Could not poll iOS Simulators for screen recordings.'
);
}
}
if (!signal.aborted) {
try {
await setTimeout(IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS, undefined, {
signal,
});
} catch (err) {
if (!signal.aborted) {
throw err;
}
}
}
}
}
async function startIosSimulatorRecordingAsync(
session: IosSimulatorRecordingSession,
{
udid,
deviceName,
runtimeDisplayName,
}: {
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
}
): Promise<void> {
const startedAt = new Date();
const recordingId = randomUUID();
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
await mkdir(outputDirectory, { recursive: true });
session.logger.info(`Starting screen recording for ${deviceName}.`);
const recordingSpawn = spawn(
session.recordSimCommand,
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
{
env: session.env,
stdio: ['ignore', 'pipe', 'pipe'],
}
);
const getOutput = captureProcessOutput(recordingSpawn.child);
const completionPromise = recordingSpawn
.then(() => undefined)
.catch((err: unknown) => {
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording process failed', error);
session.logger.warn(
{ err: error, recordSimOutput: getOutput() },
`Screen recording process failed for ${deviceName}.`
);
})
.finally(() => {
session.activeRecordings.delete(udid);
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
});
});
session.activeRecordings.set(udid, {
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
recordingProcess: recordingSpawn.child,
completionPromise,
startedAt,
getOutput,
});
}
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
try {
await spawn('which', [RECORD_SIM_COMMAND], { env });
return RECORD_SIM_COMMAND;
} catch {}
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
try {
await access(packagedRecordSimPath);
return packagedRecordSimPath;
} catch {
return null;
}
}
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
output = `${output}${chunk.toString()}`.slice(-16_384);
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
return () => output;
}
@@ -0,0 +1,226 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { type BuildStepEnv } from '@expo/steps';
import fetch, { Response } from 'node-fetch';
import { z } from 'zod';
import { PosthogUtils } from './PosthogUtils';
const POSTHOG_DEFAULT_HOST = 'https://us.posthog.com';
const SYSTEM_DISTINCT_ID = 'eas-workflow';
const QueryResponseSchema = z.object({ results: z.array(z.array(z.unknown())) });
export class PosthogRetryableError extends Error {}
export class PosthogClient {
private constructor(
private readonly host: string,
private readonly apiKey: string,
private readonly projectId: string
) {}
static fromEnv({
apiKeyOverride,
projectIdOverride,
env,
}: {
apiKeyOverride: string | undefined;
projectIdOverride: string | undefined;
env: BuildStepEnv;
}): { client: PosthogClient } | { client: undefined; missing: PosthogUtils.Credential[] } {
const apiKey = apiKeyOverride || env.POSTHOG_CLI_API_KEY;
const projectId = projectIdOverride || env.POSTHOG_CLI_PROJECT_ID;
if (!apiKey || !projectId) {
return {
client: undefined,
missing: [
...(apiKey ? [] : [PosthogUtils.API_CREDENTIALS.apiKey]),
...(projectId ? [] : [PosthogUtils.API_CREDENTIALS.projectId]),
],
};
}
return {
client: new PosthogClient(
(env.POSTHOG_CLI_HOST || POSTHOG_DEFAULT_HOST).replace(/\/+$/, ''),
apiKey,
projectId
),
};
}
static forIngestion({
apiKeyOverride,
hostOverride,
env,
}: {
apiKeyOverride: string | undefined;
hostOverride: string | undefined;
env: BuildStepEnv;
}): PosthogClient | undefined {
const apiKey = apiKeyOverride || env.EXPO_PUBLIC_POSTHOG_API_KEY || env.POSTHOG_API_KEY;
if (!apiKey) {
return undefined;
}
return new PosthogClient(
(hostOverride || env.EXPO_PUBLIC_POSTHOG_HOST || POSTHOG_DEFAULT_HOST).replace(/\/+$/, ''),
apiKey,
''
);
}
async captureEventAsync({
event,
distinctId,
properties,
signal,
}: {
event: string;
distinctId: string | undefined;
properties: Record<string, unknown> | undefined;
signal: AbortSignal | undefined;
}): Promise<void> {
let response: Response;
try {
response = await fetch(`${this.host}/i/v0/e/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: this.apiKey,
event,
distinct_id: distinctId ?? SYSTEM_DISTINCT_ID,
properties:
distinctId !== undefined
? properties
: { ...properties, $process_person_profile: false },
timestamp: new Date().toISOString(),
}),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
throw new UserError(
'EAS_POSTHOG_CAPTURE_FAILED',
`Sending PostHog event "${event}" failed.`,
{
cause: error,
}
);
}
if (!response.ok) {
throw new UserError(
'EAS_POSTHOG_CAPTURE_FAILED',
`Sending PostHog event "${event}" failed with ${await PosthogUtils.readErrorAsync(response)}.`
);
}
}
async requestAsync(
method: 'GET' | 'POST' | 'PATCH',
path: string,
{
action,
forbiddenScope,
body,
signal,
}: { action: string; forbiddenScope: string; body?: unknown; signal: AbortSignal | undefined }
): Promise<Response> {
let response: Response;
try {
response = await fetch(this.apiUrl(path), {
method,
headers: this.authHeaders(),
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
throw new UserError('EAS_POSTHOG_REQUEST_FAILED', `${action} failed.`, { cause: error });
}
if (response.status === 403) {
throw new UserError(
'EAS_POSTHOG_FORBIDDEN',
`${action} was forbidden (403). The personal API key likely lacks the "${forbiddenScope}" scope.`
);
}
if (!response.ok) {
throw new UserError(
'EAS_POSTHOG_REQUEST_FAILED',
`${action} failed with ${await PosthogUtils.readErrorAsync(response)}.`
);
}
return response;
}
async runQueryAsync(
query: string,
logger: bunyan,
signal: AbortSignal | undefined
): Promise<unknown> {
let response: Response;
try {
response = await fetch(this.apiUrl('/query/'), {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ query: { kind: 'HogQLQuery', query }, refresh: 'blocking' }),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
logger.debug(error);
logger.warn(
'Running the PostHog query failed with a network error; will retry on the next poll.'
);
throw new PosthogRetryableError();
}
if (response.status === 403) {
throw new UserError(
'EAS_POSTHOG_FORBIDDEN',
'Running the PostHog query was forbidden (403). The personal API key likely lacks the "query:read" scope.'
);
}
if (response.status >= 500 || response.status === 429) {
logger.warn(
`Running the PostHog query failed with status ${response.status}; will retry on the next poll.`
);
throw new PosthogRetryableError();
}
if (!response.ok) {
throw new UserError(
'EAS_POSTHOG_REQUEST_FAILED',
`Running the PostHog query failed with ${await PosthogUtils.readErrorAsync(response)}.`
);
}
let body: unknown;
try {
body = await response.json();
} catch (error) {
logger.debug(error);
logger.warn('The PostHog query response was not valid JSON; will retry on the next poll.');
throw new PosthogRetryableError();
}
const parsed = QueryResponseSchema.safeParse(body);
return parsed.success ? parsed.data.results[0]?.[0] : undefined;
}
cliConfig(): { host: string; env: Record<string, string> } {
return {
host: this.host,
env: { POSTHOG_CLI_API_KEY: this.apiKey, POSTHOG_CLI_PROJECT_ID: this.projectId },
};
}
private apiUrl(path: string): string {
return `${this.host}/api/projects/${encodeURIComponent(this.projectId)}${path}`;
}
private authHeaders(): Record<string, string> {
return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
}
}
@@ -0,0 +1,103 @@
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { Response } from 'node-fetch';
export namespace PosthogUtils {
export interface Credential {
label: string;
envVar: string;
input: string;
}
export const API_CREDENTIALS = {
apiKey: { label: 'personal API key', envVar: 'POSTHOG_CLI_API_KEY', input: 'api_key' },
projectId: { label: 'project id', envVar: 'POSTHOG_CLI_PROJECT_ID', input: 'project_id' },
} satisfies Record<string, Credential>;
export function missingCredentialsError(missing: Credential[]): UserError {
const labels = missing.map(credential => credential.label).join(', ');
const envVars = missing.map(credential => credential.envVar).join(', ');
const inputs = missing.map(credential => credential.input).join(', ');
return new UserError(
'EAS_POSTHOG_MISSING_CREDENTIALS',
`Missing PostHog credentials: ${labels}. Set the environment variables (${envVars}) or step inputs (${inputs}) on EAS, or re-run "eas integrations:posthog:connect" with error tracking enabled.`
);
}
export const DEFAULT_POLL_TIMEOUT_SECONDS = 600;
export const DEFAULT_POLL_INTERVAL_SECONDS = 30;
export function assertPollBoundsPositive({
timeoutSeconds,
intervalSeconds,
errorCode,
}: {
timeoutSeconds: number;
intervalSeconds: number;
errorCode: string;
}): void {
if (timeoutSeconds <= 0 || intervalSeconds <= 0) {
throw new UserError(
errorCode,
'"timeout_seconds" and "interval_seconds" must be greater than 0.'
);
}
}
export async function waitForNextPollAsync({
intervalSeconds,
deadline,
signal,
}: {
intervalSeconds: number;
deadline: number;
signal: AbortSignal | undefined;
}): Promise<boolean> {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return false;
}
await setTimeoutAsync(Math.min(intervalSeconds * 1000, remainingMs), undefined, { signal });
return true;
}
export function failOrLogError({
logger,
ignoreError,
error,
}: {
logger: bunyan;
ignoreError: boolean;
error: unknown;
}): void {
if (
!ignoreError ||
(error instanceof UserError && error.errorCode === 'EAS_POSTHOG_FORBIDDEN')
) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
logger.warn({ err: error }, `${message} Ignoring error.`);
}
export async function readErrorAsync(response: Response): Promise<string> {
const text = await response.text().catch(() => '');
if (text) {
try {
const body = JSON.parse(text) as unknown;
if (typeof body === 'object' && body !== null) {
const { detail } = body as Record<string, unknown>;
if (typeof detail === 'string' && detail !== '') {
return `status ${response.status}: ${detail} (${text})`;
}
}
} catch {
return `status ${response.status}: ${text}`;
}
return `status ${response.status}: ${text}`;
}
return `status ${response.status}`;
}
}
@@ -0,0 +1,369 @@
import { UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import fetch, { Response } from 'node-fetch';
import { PosthogClient, PosthogRetryableError } from '../PosthogClient';
jest.mock('@expo/logger');
jest.mock('node-fetch');
const fetchMock = jest.mocked(fetch);
const logger = createLogger({ name: 'test' });
function res({
ok = true,
status = 200,
json,
text,
}: {
ok?: boolean;
status?: number;
json?: unknown;
text?: string;
}): Response {
return {
ok,
status,
json: async () => json,
text: async () => text ?? (json !== undefined ? JSON.stringify(json) : ''),
} as unknown as Response;
}
const API_ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
afterEach(() => jest.resetAllMocks());
describe('PosthogClient.fromEnv', () => {
it('builds a client from env vars', () => {
expect(
PosthogClient.fromEnv({
apiKeyOverride: undefined,
projectIdOverride: undefined,
env: API_ENV,
}).client
).toBeInstanceOf(PosthogClient);
});
it('prefers overrides over env', async () => {
fetchMock.mockResolvedValue(res({ json: { results: [[1]] } }));
const { client } = PosthogClient.fromEnv({
apiKeyOverride: 'phx_override',
projectIdOverride: '999',
env: {},
});
await client?.runQueryAsync('select 1', logger, undefined);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/api/projects/999/query/');
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer phx_override');
});
it('exposes host and CLI env for subprocess uploads', () => {
const { client } = PosthogClient.fromEnv({
apiKeyOverride: 'phx_k',
projectIdOverride: '55',
env: { POSTHOG_CLI_HOST: 'https://eu.posthog.com' },
});
expect(client?.cliConfig()).toEqual({
host: 'https://eu.posthog.com',
env: { POSTHOG_CLI_API_KEY: 'phx_k', POSTHOG_CLI_PROJECT_ID: '55' },
});
});
it('reports both missing credentials when neither is provided', () => {
const result = PosthogClient.fromEnv({
apiKeyOverride: undefined,
projectIdOverride: undefined,
env: {},
});
expect(result.client).toBeUndefined();
expect(result).toMatchObject({
missing: [{ label: 'personal API key' }, { label: 'project id' }],
});
});
it('reports only the missing project id when the key is present', () => {
const result = PosthogClient.fromEnv({
apiKeyOverride: 'phx',
projectIdOverride: undefined,
env: {},
});
expect(result.client).toBeUndefined();
expect(result).toMatchObject({ missing: [{ label: 'project id' }] });
});
it('reports only the missing api key when the project id is present', () => {
const result = PosthogClient.fromEnv({
apiKeyOverride: undefined,
projectIdOverride: '1',
env: {},
});
expect(result.client).toBeUndefined();
expect(result).toMatchObject({ missing: [{ label: 'personal API key' }] });
});
it('falls back to the default host', async () => {
fetchMock.mockResolvedValue(res({ json: { results: [[1]] } }));
const { client } = PosthogClient.fromEnv({
apiKeyOverride: 'phx',
projectIdOverride: '1',
env: {},
});
await client?.runQueryAsync('select 1', logger, undefined);
expect(fetchMock.mock.calls[0][0]).toBe('https://us.posthog.com/api/projects/1/query/');
});
});
describe('PosthogClient.forIngestion', () => {
it('builds a client and normalizes a trailing-slash host', async () => {
fetchMock.mockResolvedValue(res({ ok: true, status: 200 }));
const client = PosthogClient.forIngestion({
apiKeyOverride: 'phc_x',
hostOverride: 'https://eu.posthog.com//',
env: {},
});
await client?.captureEventAsync({
event: 'e',
distinctId: 'u',
properties: undefined,
signal: undefined,
});
expect(fetchMock.mock.calls[0][0]).toBe('https://eu.posthog.com/i/v0/e/');
});
it('falls back to a non-public POSTHOG_API_KEY', () => {
expect(
PosthogClient.forIngestion({
apiKeyOverride: undefined,
hostOverride: undefined,
env: { POSTHOG_API_KEY: 'phc_secret' },
})
).toBeInstanceOf(PosthogClient);
});
it('returns undefined when no key is available', () => {
expect(
PosthogClient.forIngestion({ apiKeyOverride: undefined, hostOverride: undefined, env: {} })
).toBeUndefined();
});
});
describe('captureEventAsync', () => {
const client = PosthogClient.forIngestion({
apiKeyOverride: 'phc_x',
hostOverride: undefined,
env: {},
});
it('sends an anonymous system event when no distinct_id is given', async () => {
fetchMock.mockResolvedValue(res({ ok: true, status: 200 }));
await client?.captureEventAsync({
event: 'e',
distinctId: undefined,
properties: { a: 1 },
signal: undefined,
});
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.distinct_id).toBe('eas-workflow');
expect(body.properties).toEqual({ a: 1, $process_person_profile: false });
});
it('throws a UserError on a non-2xx response', async () => {
fetchMock.mockResolvedValue(res({ ok: false, status: 400, text: 'bad' }));
await expect(
client?.captureEventAsync({
event: 'e',
distinctId: 'u',
properties: undefined,
signal: undefined,
})
).rejects.toThrow(/failed with status 400: bad/);
});
it('throws a UserError on a network error', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
await expect(
client?.captureEventAsync({
event: 'e',
distinctId: 'u',
properties: undefined,
signal: undefined,
})
).rejects.toThrow(/Sending PostHog event "e" failed\.$/);
});
it('passes the signal and propagates without wrapping when aborted', async () => {
const controller = new AbortController();
controller.abort();
fetchMock.mockRejectedValue(new Error('The operation was aborted'));
await expect(
client?.captureEventAsync({
event: 'e',
distinctId: 'u',
properties: undefined,
signal: controller.signal,
})
).rejects.toThrow('The operation was aborted');
expect(fetchMock.mock.calls[0][1]?.signal).toBe(controller.signal);
});
});
describe('requestAsync', () => {
const { client } = PosthogClient.fromEnv({
apiKeyOverride: undefined,
projectIdOverride: undefined,
env: API_ENV,
});
it('sends an authenticated request and returns the response', async () => {
fetchMock.mockResolvedValue(res({ ok: true, status: 200, json: { id: 1 } }));
const response = await client?.requestAsync('PATCH', '/feature_flags/1/', {
action: 'Updating flag',
forbiddenScope: 'feature_flag:write',
body: { active: true },
signal: undefined,
});
expect(response?.ok).toBe(true);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/api/projects/123/feature_flags/1/');
expect(init?.method).toBe('PATCH');
expect(JSON.parse(init?.body as string)).toEqual({ active: true });
});
it('throws a forbidden UserError with the named scope on 403', async () => {
fetchMock.mockResolvedValue(res({ ok: false, status: 403 }));
await expect(
client?.requestAsync('GET', '/feature_flags/', {
action: 'Looking up flag',
forbiddenScope: 'feature_flag:read',
signal: undefined,
})
).rejects.toMatchObject({
errorCode: 'EAS_POSTHOG_FORBIDDEN',
message: expect.stringContaining('"feature_flag:read"'),
});
});
it('throws with the error detail on a non-2xx response', async () => {
fetchMock.mockResolvedValue(res({ ok: false, status: 400, json: { detail: 'nope' } }));
await expect(
client?.requestAsync('GET', '/feature_flags/', {
action: 'Looking up flag',
forbiddenScope: 'feature_flag:read',
signal: undefined,
})
).rejects.toThrow(/Looking up flag failed with status 400: nope/);
});
it('throws on a network error', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
await expect(
client?.requestAsync('GET', '/feature_flags/', {
action: 'Looking up flag',
forbiddenScope: 'feature_flag:read',
signal: undefined,
})
).rejects.toThrow(/Looking up flag failed\.$/);
});
it('passes the signal and propagates without wrapping when aborted', async () => {
const controller = new AbortController();
controller.abort();
fetchMock.mockRejectedValue(new Error('The operation was aborted'));
await expect(
client?.requestAsync('GET', '/feature_flags/', {
action: 'Looking up flag',
forbiddenScope: 'feature_flag:read',
signal: controller.signal,
})
).rejects.toThrow('The operation was aborted');
expect(fetchMock.mock.calls[0][1]?.signal).toBe(controller.signal);
});
});
describe('runQueryAsync', () => {
const { client } = PosthogClient.fromEnv({
apiKeyOverride: undefined,
projectIdOverride: undefined,
env: API_ENV,
});
it('returns the top-left cell', async () => {
fetchMock.mockResolvedValue(res({ json: { results: [[42]] } }));
await expect(client?.runQueryAsync('select 1', logger, undefined)).resolves.toBe(42);
});
it('throws a retryable error on a network error', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
PosthogRetryableError
);
});
it('passes the signal to fetch and propagates without retrying when aborted', async () => {
const controller = new AbortController();
controller.abort();
fetchMock.mockRejectedValue(new Error('The operation was aborted'));
await expect(client?.runQueryAsync('q', logger, controller.signal)).rejects.toThrow(
'The operation was aborted'
);
expect(fetchMock.mock.calls[0][1]?.signal).toBe(controller.signal);
});
it('throws a retryable error on 5xx and 429', async () => {
fetchMock.mockResolvedValueOnce(res({ ok: false, status: 500 }));
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
PosthogRetryableError
);
fetchMock.mockResolvedValueOnce(res({ ok: false, status: 429 }));
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
PosthogRetryableError
);
});
it('throws a retryable error on an invalid JSON body', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new Error('bad json');
},
} as unknown as Response);
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
PosthogRetryableError
);
});
it('returns undefined for a malformed result shape', async () => {
fetchMock.mockResolvedValue(res({ json: { results: 'nope' } }));
await expect(client?.runQueryAsync('q', logger, undefined)).resolves.toBeUndefined();
});
it('throws a forbidden UserError on 403', async () => {
fetchMock.mockResolvedValue(res({ ok: false, status: 403 }));
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toMatchObject({
errorCode: 'EAS_POSTHOG_FORBIDDEN',
message: expect.stringContaining('"query:read"'),
});
});
it('throws with the error detail on a non-2xx response', async () => {
fetchMock.mockResolvedValue(res({ ok: false, status: 400, text: 'Malformed HogQL' }));
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toThrow(
/failed with status 400: Malformed HogQL/
);
});
});
it('is a UserError subclass so the step surfaces it to the user', async () => {
const { client } = PosthogClient.fromEnv({
apiKeyOverride: undefined,
projectIdOverride: undefined,
env: API_ENV,
});
fetchMock.mockResolvedValue(res({ ok: false, status: 403 }));
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(UserError);
});
@@ -0,0 +1,173 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan, createLogger } from '@expo/logger';
import { Response } from 'node-fetch';
import { PosthogUtils } from '../PosthogUtils';
jest.mock('@expo/logger');
const logger = createLogger({ name: 'test' }) as bunyan;
function errorResponse({ status, text }: { status: number; text?: string }): Response {
return { status, text: async () => text ?? '' } as unknown as Response;
}
describe(PosthogUtils.readErrorAsync, () => {
it('surfaces a JSON detail followed by the raw body', async () => {
await expect(
PosthogUtils.readErrorAsync(errorResponse({ status: 400, text: '{"detail":"boom"}' }))
).resolves.toBe('status 400: boom ({"detail":"boom"})');
});
it('falls back to the raw body for detail-less JSON', async () => {
await expect(
PosthogUtils.readErrorAsync(errorResponse({ status: 400, text: '{"other":1}' }))
).resolves.toBe('status 400: {"other":1}');
});
it('ignores an empty-string detail', async () => {
await expect(
PosthogUtils.readErrorAsync(errorResponse({ status: 400, text: '{"detail":""}' }))
).resolves.toBe('status 400: {"detail":""}');
});
it('falls back to the raw text for a non-JSON body', async () => {
await expect(
PosthogUtils.readErrorAsync(errorResponse({ status: 502, text: 'Bad Gateway' }))
).resolves.toBe('status 502: Bad Gateway');
});
it('uses just the status when the body is empty', async () => {
await expect(
PosthogUtils.readErrorAsync(errorResponse({ status: 500, text: '' }))
).resolves.toBe('status 500');
});
it('uses just the status when reading the body throws', async () => {
const response = {
status: 500,
text: async () => {
throw new Error('read fail');
},
} as unknown as Response;
await expect(PosthogUtils.readErrorAsync(response)).resolves.toBe('status 500');
});
});
describe(PosthogUtils.missingCredentialsError, () => {
it('names both missing credentials with their env vars and inputs', () => {
expect(
PosthogUtils.missingCredentialsError([
PosthogUtils.API_CREDENTIALS.apiKey,
PosthogUtils.API_CREDENTIALS.projectId,
])
).toMatchObject({
errorCode: 'EAS_POSTHOG_MISSING_CREDENTIALS',
message:
'Missing PostHog credentials: personal API key, project id. Set the environment variables (POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID) or step inputs (api_key, project_id) on EAS, or re-run "eas integrations:posthog:connect" with error tracking enabled.',
});
});
it('names only the missing credential', () => {
expect(
PosthogUtils.missingCredentialsError([PosthogUtils.API_CREDENTIALS.projectId]).message
).toBe(
'Missing PostHog credentials: project id. Set the environment variables (POSTHOG_CLI_PROJECT_ID) or step inputs (project_id) on EAS, or re-run "eas integrations:posthog:connect" with error tracking enabled.'
);
});
});
describe(PosthogUtils.assertPollBoundsPositive, () => {
it('throws with the given error code when a bound is not positive', () => {
expect(() =>
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds: 0,
intervalSeconds: 30,
errorCode: 'EAS_POSTHOG_TEST_INTERVAL',
})
).toThrow(/greater than 0/);
expect(() =>
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds: 600,
intervalSeconds: -1,
errorCode: 'EAS_POSTHOG_TEST_INTERVAL',
})
).toThrow(/greater than 0/);
});
it('does nothing when both bounds are positive', () => {
expect(() =>
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds: 600,
intervalSeconds: 30,
errorCode: 'EAS_POSTHOG_TEST_INTERVAL',
})
).not.toThrow();
});
});
describe(PosthogUtils.waitForNextPollAsync, () => {
it('returns false without sleeping when the deadline has passed', async () => {
await expect(
PosthogUtils.waitForNextPollAsync({
intervalSeconds: 30,
deadline: Date.now() - 1,
signal: undefined,
})
).resolves.toBe(false);
});
it('sleeps for the clamped interval and returns true when time remains', async () => {
await expect(
PosthogUtils.waitForNextPollAsync({
intervalSeconds: 0.01,
deadline: Date.now() + 1000,
signal: undefined,
})
).resolves.toBe(true);
});
it('rejects when the signal aborts during the sleep', async () => {
const controller = new AbortController();
const promise = PosthogUtils.waitForNextPollAsync({
intervalSeconds: 30,
deadline: Date.now() + 60_000,
signal: controller.signal,
});
controller.abort();
await expect(promise).rejects.toThrow();
});
});
describe(PosthogUtils.failOrLogError, () => {
it('throws the error when ignoreError is false', () => {
const error = new Error('boom');
expect(() => PosthogUtils.failOrLogError({ logger, ignoreError: false, error })).toThrow(
'boom'
);
});
it('warns and swallows the error when ignoreError is true', () => {
const warnMock = jest.spyOn(logger, 'warn');
const error = new Error('boom');
PosthogUtils.failOrLogError({ logger, ignoreError: true, error });
expect(warnMock).toHaveBeenCalledWith({ err: error }, 'boom Ignoring error.');
});
it('rethrows a forbidden error even when ignoreError is true', () => {
const error = new UserError('EAS_POSTHOG_FORBIDDEN', 'no scope');
expect(() => PosthogUtils.failOrLogError({ logger, ignoreError: true, error })).toThrow(
'no scope'
);
});
it('stringifies a non-Error value when swallowing', () => {
const warnMock = jest.spyOn(logger, 'warn');
PosthogUtils.failOrLogError({ logger, ignoreError: true, error: 'plain string' });
expect(warnMock).toHaveBeenCalledWith({ err: 'plain string' }, 'plain string Ignoring error.');
});
});
@@ -49,12 +49,22 @@ describe(listAgentDeviceArtifactsAsync, () => {
artifacts: [
{
id: 'artifact-id',
artifactType: 'agent-device-test-report',
filename: 'report.json',
mimeType: 'application/json',
sizeBytes: 123,
createdAt: '2026-07-02T12:00:00.000Z',
expiresAt: '2026-07-02T12:15:00.000Z',
},
{
id: 'untyped-artifact-id',
artifactType: null,
filename: 'untyped-report.json',
mimeType: 'application/json',
sizeBytes: 456,
createdAt: '2026-07-02T12:00:00.000Z',
expiresAt: '2026-07-02T12:15:00.000Z',
},
],
})
)
@@ -68,8 +78,14 @@ describe(listAgentDeviceArtifactsAsync, () => {
expect(artifacts).toEqual([
{
id: 'artifact-id',
artifactType: 'agent-device-test-report',
filename: 'report.json',
},
{
id: 'untyped-artifact-id',
artifactType: null,
filename: 'untyped-report.json',
},
]);
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
headers: { Authorization: 'Bearer daemon-token' },
@@ -113,6 +129,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
logger,
artifact: {
id: 'artifact-id',
artifactType: 'agent-device-test-report',
filename: 'report.json',
},
});
@@ -125,10 +142,43 @@ describe(uploadAgentDeviceArtifactAsync, () => {
artifactId: 'artifact-id',
name: 'report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
size: data.length,
stream: expect.anything(),
});
});
it('uploads null artifact types as an explicit undefined kind', async () => {
const data = Buffer.from('artifact-data');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
artifact: {
id: 'artifact-id',
artifactType: null,
filename: 'report.json',
},
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
kind: undefined,
})
);
});
});
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
@@ -4,9 +4,14 @@ import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { listArgentArtifactsAsync, uploadArgentArtifactAsync } from '../argentArtifacts';
import {
listArgentArtifactsAsync,
pollArgentArtifactsForUploadAsync,
uploadArgentArtifactAsync,
} from '../argentArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
@@ -17,6 +22,21 @@ async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
}
}
async function waitForAssertionAsync(assertion: () => void): Promise<void> {
const deadline = Date.now() + 1_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
assertion();
return;
} catch (err) {
lastError = err;
await new Promise(resolve => setTimeout(resolve, 10));
}
}
throw lastError;
}
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
@@ -62,6 +82,7 @@ describe(listArgentArtifactsAsync, () => {
]);
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
headers: { Authorization: 'Bearer tools-token' },
signal: expect.any(AbortSignal),
});
});
});
@@ -98,6 +119,7 @@ describe(uploadArgentArtifactAsync, () => {
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
headers: { Authorization: 'Bearer tools-token' },
signal: expect.any(AbortSignal),
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
deviceRunSessionId: 'drs-id',
@@ -109,3 +131,150 @@ describe(uploadArgentArtifactAsync, () => {
});
});
});
describe(pollArgentArtifactsForUploadAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
});
it('stops polling when aborted, performs a final drain, and waits for uploads', async () => {
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
const abortController = new AbortController();
let listCallCount = 0;
jest.mocked(fetch).mockImplementation(async url => {
const urlString = String(url);
if (urlString.endsWith('/artifacts')) {
listCallCount += 1;
return new Response(
JSON.stringify({
artifacts:
listCallCount === 1
? [
{
id: 'artifact-a',
filename: 'a.json',
mimeType: 'application/json',
},
]
: [
{
id: 'artifact-a',
filename: 'a.json',
mimeType: 'application/json',
},
{
id: 'artifact-b',
filename: 'b.json',
mimeType: 'application/json',
},
],
})
);
}
if (urlString.endsWith('/artifacts/artifact-a')) {
return new Response(Readable.from([Buffer.from('artifact-a-data')]));
}
if (urlString.endsWith('/artifacts/artifact-b')) {
return new Response(Readable.from([Buffer.from('artifact-b-data')]));
}
throw new Error(`Unexpected URL ${urlString}`);
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId: 'drs-id',
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
logger,
signal: abortController.signal,
});
await waitForAssertionAsync(() => {
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(1);
});
abortController.abort();
await pollingPromise;
expect(listCallCount).toBe(2);
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(2);
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenLastCalledWith(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-b',
name: 'b.json (artifact-b)',
filename: 'b.json',
size: 'artifact-b-data'.length,
stream: expect.anything(),
});
});
it('throws when pending uploads do not finish before the cleanup timeout', async () => {
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
const abortController = new AbortController();
const originalSetTimeout = global.setTimeout;
const setTimeoutSpy = jest
.spyOn(global, 'setTimeout')
.mockImplementation(((
callback: Parameters<typeof setTimeout>[0],
timeout?: number,
...args: unknown[]
) =>
originalSetTimeout(
callback,
timeout === 30_000 ? 0 : timeout,
...args
)) as typeof setTimeout);
try {
jest.mocked(fetch).mockImplementation(async url => {
const urlString = String(url);
if (urlString.endsWith('/artifacts')) {
return new Response(
JSON.stringify({
artifacts: [
{
id: 'artifact-a',
filename: 'a.json',
mimeType: 'application/json',
},
],
})
);
}
if (urlString.endsWith('/artifacts/artifact-a')) {
return new Response(Readable.from([Buffer.from('artifact-a-data')]));
}
throw new Error(`Unexpected URL ${urlString}`);
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(() => new Promise<void>(() => {}));
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId: 'drs-id',
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
logger,
signal: abortController.signal,
});
await waitForAssertionAsync(() => {
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(1);
});
abortController.abort();
await expect(pollingPromise).rejects.toThrow(
'Timed out after 30s waiting for Argent artifact uploads.'
);
} finally {
setTimeoutSpy.mockRestore();
}
});
});
@@ -46,6 +46,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
stream,
});
@@ -57,6 +59,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
input: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
})
@@ -1,5 +1,6 @@
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
@@ -7,8 +8,10 @@ import { turtleFetch } from '../../../utils/turtleFetch';
import {
fetchServeSimTurnArgsAsync,
turnIceServersToServeSimArgs,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
jest.mock('node:timers/promises');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
@@ -32,6 +35,47 @@ function createCtxMock(): CustomBuildContext {
} as unknown as CustomBuildContext;
}
function createStatusCtxMock(
results: (
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
| { error: Error }
| { data: unknown }
)[]
): CustomBuildContext {
const query = jest.fn(() => {
const result = results.shift();
if (!result) {
throw new Error('No mocked status result available');
}
return {
toPromise: async () => {
if ('error' in result) {
throw result.error;
}
if ('data' in result) {
return { data: result.data };
}
return {
data: {
deviceRunSessions: {
byId: {
id: 'drs-id',
status: result.status,
},
},
},
};
},
};
});
return {
graphqlClient: {
query,
},
} as unknown as CustomBuildContext;
}
function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
@@ -152,3 +196,86 @@ describe(fetchServeSimTurnArgsAsync, () => {
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
});
describe(waitForDeviceRunSessionStoppedAsync, () => {
beforeEach(() => {
jest.mocked(Sentry).capture.mockReset();
jest.mocked(setTimeoutAsync).mockReset();
jest.mocked(setTimeoutAsync).mockResolvedValue(undefined);
});
it('continues polling until the device run session is stopped', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }, { status: 'STOPPED' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
});
it('throws when the device run session errors', async () => {
const ctx = createStatusCtxMock([{ status: 'ERRORED' }]);
await expect(
waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
})
).rejects.toThrow('Device run session drs-id errored.');
});
it('logs and retries transient polling errors', async () => {
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'STOPPED' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
);
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not poll device run session status',
expect.any(Error),
{ level: 'warning' }
);
});
it('logs and retries when the status response is missing', async () => {
const ctx = createStatusCtxMock([
{ data: { deviceRunSessions: { byId: null } } },
{ status: 'STOPPED' },
]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
);
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not poll device run session status',
expect.objectContaining({
message: 'Device run session drs-id status response was missing.',
}),
{ level: 'warning' }
);
});
});
@@ -18,6 +18,7 @@ const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const AgentDeviceArtifactSchema = z.object({
id: z.string(),
artifactType: z.string().nullish(),
filename: z.string(),
});
const AgentDeviceArtifactsListResponseSchema = z.object({
@@ -152,6 +153,7 @@ export async function uploadAgentDeviceArtifactAsync(
artifactId: artifact.id,
name: `${artifact.filename} (${artifact.id})`,
filename: artifact.filename,
kind: artifact.artifactType ?? undefined,
size,
stream: createReadStream(temporaryArtifactPath),
});
@@ -6,15 +6,17 @@ import fetch from 'node-fetch';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { sleepAsync } from '../../utils/retry';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
const ArgentArtifactSchema = z.object({
id: z.string(),
@@ -35,39 +37,56 @@ export async function pollArgentArtifactsForUploadAsync(
toolsUrl,
toolsAuthToken,
logger,
signal,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
logger: bunyan;
signal: AbortSignal;
}
): Promise<never> {
): Promise<void> {
logger.info('Started polling Argent tool-server for artifacts.');
const seenArtifactIds = new Set<string>();
const pendingUploads = new Set<Promise<void>>();
let listArtifactsErrorCount = 0;
for (;;) {
const queueArtifactUpload = (artifact: ArgentArtifact): void => {
if (seenArtifactIds.has(artifact.id)) {
return;
}
seenArtifactIds.add(artifact.id);
const uploadPromise = uploadArgentArtifactAsync(ctx, {
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
artifact,
logger,
}).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload Argent remote session artifact', error);
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
});
pendingUploads.add(uploadPromise);
void uploadPromise.finally(() => {
pendingUploads.delete(uploadPromise);
});
};
const listArtifactsForUploadAsync = async ({
signal,
}: {
signal?: AbortSignal;
} = {}): Promise<ArgentArtifact[]> => {
try {
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken, signal });
listArtifactsErrorCount = 0;
for (const artifact of artifacts) {
if (seenArtifactIds.has(artifact.id)) {
continue;
}
seenArtifactIds.add(artifact.id);
void uploadArgentArtifactAsync(ctx, {
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
artifact,
logger,
}).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload Argent remote session artifact', error);
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
});
}
return artifacts;
} catch (err) {
if (signal?.aborted) {
return [];
}
const error = err instanceof Error ? err : new Error(String(err));
listArtifactsErrorCount += 1;
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
@@ -77,20 +96,47 @@ export async function pollArgentArtifactsForUploadAsync(
'Could not list Argent remote session artifacts.'
);
}
return [];
}
await sleepAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
};
const listAndQueueArtifactUploadsAsync = async ({
signal,
}: {
signal?: AbortSignal;
} = {}): Promise<void> => {
const artifacts = await listArtifactsForUploadAsync({ signal });
for (const artifact of artifacts) {
queueArtifactUpload(artifact);
}
};
while (!signal.aborted) {
await listAndQueueArtifactUploadsAsync({ signal });
await sleepUntilAbortedAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS, signal);
}
logger.info('Argent artifact polling stopped; draining remaining artifacts.');
await listAndQueueArtifactUploadsAsync();
await waitForPendingUploadsAsync({
pendingUploads,
timeoutMs: ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS,
logger,
});
}
export async function listArgentArtifactsAsync({
toolsUrl,
toolsAuthToken,
signal,
}: {
toolsUrl: string;
toolsAuthToken?: string;
signal?: AbortSignal;
}): Promise<ArgentArtifact[]> {
const response = await fetch(new URL('/artifacts', toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
signal: createTimeoutSignal({ signal, timeoutMs: ARGENT_ARTIFACT_FETCH_TIMEOUT_MS }),
});
if (!response.ok) {
throw new SystemError(
@@ -138,6 +184,7 @@ export async function uploadArgentArtifactAsync(
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
kind: undefined,
size,
stream: createReadStream(temporaryArtifactPath),
});
@@ -159,6 +206,10 @@ async function downloadArgentArtifactToFileAsync({
}): Promise<void> {
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
signal: createTimeoutSignal({
signal: undefined,
timeoutMs: ARGENT_ARTIFACT_FETCH_TIMEOUT_MS,
}),
});
if (!response.ok) {
throw new SystemError(
@@ -172,3 +223,62 @@ async function downloadArgentArtifactToFileAsync({
}
await pipeline(response.body, createWriteStream(destinationPath));
}
async function sleepUntilAbortedAsync(timeoutMs: number, signal: AbortSignal): Promise<void> {
try {
await setTimeoutAsync(timeoutMs, undefined, { signal });
} catch (err) {
if (!signal.aborted) {
throw err;
}
}
}
function createTimeoutSignal({
signal,
timeoutMs,
}: {
signal: AbortSignal | undefined;
timeoutMs: number;
}): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
async function waitForPendingUploadsAsync({
pendingUploads,
timeoutMs,
logger,
}: {
pendingUploads: Set<Promise<void>>;
timeoutMs: number;
logger: bunyan;
}): Promise<void> {
if (pendingUploads.size === 0) {
return;
}
logger.info(`Waiting for ${pendingUploads.size} Argent artifact upload(s) to finish.`);
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
Promise.allSettled([...pendingUploads]),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(
new SystemError(
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for Argent artifact uploads.`,
{
trackingCode: 'SIMULATOR_ARTIFACT_SLOW_UPLOAD',
}
)
);
}, timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
@@ -27,6 +27,8 @@ export async function uploadDeviceRunSessionArtifactAsync(
artifactId,
name,
filename,
kind,
metadata,
size,
stream,
}: {
@@ -34,6 +36,8 @@ export async function uploadDeviceRunSessionArtifactAsync(
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
stream: NodeJS.ReadableStream;
}
@@ -43,6 +47,8 @@ export async function uploadDeviceRunSessionArtifactAsync(
artifactId,
name,
filename,
kind,
metadata,
size,
});
const response = await fetch(uploadSession.url, {
@@ -65,12 +71,16 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
artifactId,
name,
filename,
kind,
metadata,
size,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
}
) {
@@ -80,6 +90,8 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
})
@@ -8,6 +8,7 @@ import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
@@ -30,6 +31,19 @@ const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
}
`);
const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
query DeviceRunSessionStatus($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
if (!deviceRunSessionId) {
@@ -96,6 +110,75 @@ export async function selectXcodeDeveloperDirectoryAsync({
});
}
export async function waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
);
let pollErrorCount = 0;
while (!signal?.aborted) {
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
const status = result.data?.deviceRunSessions?.byId?.status;
if (!status) {
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
}
pollErrorCount = 0;
if (status === 'STOPPED') {
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
return;
}
if (status === 'ERRORED') {
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
}
} catch (err) {
if (err instanceof SystemError) {
throw err;
}
const error = err instanceof Error ? err : new Error(String(err));
pollErrorCount += 1;
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
logger.warn(
{ err: error, failedStatusPollCount: pollErrorCount },
'Could not poll device run session status; will retry.'
);
}
}
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
}
}
async function sleepUntilAbortedAsync(
timeoutMs: number,
signal: AbortSignal | undefined
): Promise<void> {
try {
await setTimeoutAsync(timeoutMs, undefined, signal ? { signal } : undefined);
} catch (err) {
if (!signal?.aborted) {
throw err;
}
}
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
@@ -210,6 +293,8 @@ export async function uploadRemoteSessionConfigAsync({
}
export type DetachedProcessHandle = {
/** PID of the directly spawned process, if the OS assigned one. */
pid: number | undefined;
getOutput: () => string;
};
@@ -242,7 +327,7 @@ export function spawnDetached({
promise.child.stdout?.on('data', appendChunk);
promise.child.stderr?.on('data', appendChunk);
return { getOutput: () => output };
return { pid: promise.child.pid, getOutput: () => output };
}
export async function startServeSimWithTunnelAsync(
@@ -329,6 +329,9 @@ export namespace AndroidEmulatorUtils {
await emulatorPromise;
}
emulatorPromise.child.unref();
// The emulator is detached and managed through adb. Observe its process promise
// immediately so expected exits during retry cleanup do not become unhandled rejections.
void asyncResult(emulatorPromise);
const serialId = await retryAsync(
async () => {
@@ -32,7 +32,11 @@ export namespace IosSimulatorUtils {
lastBootedAt?: string;
};
type SimulatorDevice = XcrunSimctlDevice & { runtime: string; displayName: string };
type SimulatorDevice = XcrunSimctlDevice & {
runtime: string;
runtimeDisplayName: string;
displayName: string;
};
type XcrunSimctlListDevicesJsonOutput = {
devices: {
@@ -60,6 +64,7 @@ export namespace IosSimulatorUtils {
...devices.map(device => ({
...device,
runtime,
runtimeDisplayName: formatRuntimeDisplayName(runtime),
displayName: `${device.name} (${device.udid}) on ${runtime}`,
}))
);
@@ -226,16 +231,32 @@ export namespace IosSimulatorUtils {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env }
);
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env }
);
const launchctlDomains = ['user/foreground', 'system'];
let lastError: unknown;
for (const domain of launchctlDomains) {
const service = `${domain}/com.apple.apsd`;
try {
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'disable', service], { env });
try {
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'bootout', service], {
env,
});
} catch (err) {
// bootout can fail when apsd is already gone; verify the service state below.
lastError = err;
}
if (!(await isLaunchctlServiceLoadedAsync({ udid, env, serviceLabel: 'com.apple.apsd' }))) {
return;
}
} catch (err) {
lastError = err;
}
}
throw lastError ?? new SystemError('Unable to disable apsd in the Simulator.');
}
export async function collectLogsAsync({
@@ -358,6 +379,31 @@ export namespace IosSimulatorUtils {
}
}
function formatRuntimeDisplayName(runtimeIdentifier: string): string {
const match = /^com\.apple\.CoreSimulator\.SimRuntime\.([^-]+)-(.+)$/.exec(runtimeIdentifier);
return match ? `${match[1]} ${match[2].replaceAll('-', '.')}` : runtimeIdentifier;
}
async function isLaunchctlServiceLoadedAsync({
udid,
env,
serviceLabel,
}: {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
serviceLabel: string;
}): Promise<boolean> {
try {
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list', serviceLabel], { env });
return true;
} catch (err) {
if (err instanceof Error && 'status' in err && (err as { status: unknown }).status === 113) {
return false;
}
throw err;
}
}
function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null {
const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m);
if (!matches) {
@@ -23,6 +23,39 @@ describe('IosSimulatorUtils', () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
it('adds a human-readable runtime display name', async () => {
mockedSpawn.mockResolvedValue({
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-6': [
{
dataPath: '/tmp/eas-test-simulator-data',
dataPathSize: 1,
logPath: '/tmp/eas-test-simulator-logs',
udid: 'test-udid',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-16',
state: 'Booted',
name: 'iPhone 16',
},
],
},
}),
stderr: '',
} as any);
await expect(
IosSimulatorUtils.getAvailableDevicesAsync({ env: process.env, filter: 'booted' })
).resolves.toEqual([
expect.objectContaining({
name: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
}),
]);
});
});
describe(IosSimulatorUtils.startAsync, () => {
it('waits for boot completion without writing accessibility prefs', async () => {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
@@ -178,7 +211,75 @@ describe('IosSimulatorUtils', () => {
});
describe(IosSimulatorUtils.disableApsdAsync, () => {
it('disables and boots out apsd in the simulator', async () => {
it('disables and boots out apsd in the simulator foreground user domain', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args.join(' ') === 'simctl spawn test-udid launchctl list com.apple.apsd') {
throw Object.assign(new Error('apsd not loaded'), { status: 113 });
}
return { stdout: '', stderr: '' } as any;
}) as any);
await IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'user/foreground/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'user/foreground/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'list', 'com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env: process.env }
);
});
it('succeeds when bootout fails after apsd is already stopped', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (
args.join(' ') ===
'simctl spawn test-udid launchctl bootout user/foreground/com.apple.apsd'
) {
throw Object.assign(new Error('bootout failed'), { status: 3 });
}
if (args.join(' ') === 'simctl spawn test-udid launchctl list com.apple.apsd') {
throw Object.assign(new Error('apsd not loaded'), { status: 113 });
}
return { stdout: '', stderr: '' } as any;
}) as any);
await expect(
IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
})
).resolves.toBeUndefined();
});
it('falls back to the system domain when the foreground user domain keeps apsd running', async () => {
let listCount = 0;
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args.join(' ') === 'simctl spawn test-udid launchctl list com.apple.apsd') {
listCount += 1;
if (listCount === 2) {
throw Object.assign(new Error('apsd not loaded'), { status: 113 });
}
}
return { stdout: '', stderr: '' } as any;
}) as any);
await IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
@@ -189,11 +290,19 @@ describe('IosSimulatorUtils', () => {
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env: process.env }
);
});
it('throws a SystemError when apsd is still running after all disable attempts', async () => {
mockedSpawn.mockImplementation((async () => {
return { stdout: '', stderr: '' } as any;
}) as any);
await expect(
IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
})
).rejects.toThrow(SystemError);
});
});
});
@@ -30,3 +30,29 @@ export async function getParentAndDescendantProcessPidsAsync(ppid: number): Prom
}
return [...children];
}
export async function isProcessDescendantOfAsync(
pid: number,
ancestorPid: number
): Promise<boolean> {
let currentPid = pid;
while (currentPid > 0) {
if (currentPid === ancestorPid) {
return true;
}
try {
const result = await spawn('ps', ['-p', String(currentPid), '-o', 'ppid='], {
stdio: 'pipe',
});
const parentPid = Number(result.stdout.toString().trim());
if (!Number.isInteger(parentPid) || parentPid <= 0 || parentPid === currentPid) {
return false;
}
currentPid = parentPid;
} catch {
return false;
}
}
return false;
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "20.5.1",
"version": "21.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": "20.5.1"
"@expo/steps": "21.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "20.0.0",
"version": "21.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": "20.0.0",
"version": "21.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",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "20.5.1",
"version": "21.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "20.0.0",
"@expo/logger": "21.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/turtle-spawn": "21.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+128 -175
View File
File diff suppressed because it is too large. Load diff
+980 -1092
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.5.1",
"version": "21.0.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "20.5.1",
"@expo/eas-json": "20.5.1",
"@expo/eas-build-job": "21.0.0",
"@expo/eas-json": "21.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "20.0.0",
"@expo/logger": "21.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.5.1",
"@expo/steps": "21.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -72,6 +72,7 @@
"@sentry/node": "7.77.0",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"agent-cli-detector": "0.1.2",
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
@@ -106,7 +107,7 @@
"minizlib": "3.0.1",
"nanoid": "3.3.8",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
"node-forge": "1.4.0",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"ora": "5.1.0",
@@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
import { Actor, getActorDisplayName } from '../user/User';
import UserSettings from '../user/UserSettings';
import { easCliVersion } from '../utils/easCli';
import { getAgentTelemetryContext } from './agent';
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'Mac',
@@ -225,10 +226,12 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
private getRudderStackContext(): Record<string, any> {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
const agent = getAgentTelemetryContext();
return {
os: { name: platform, version: os.release() },
device: { type: platform, model: platform },
app: { name: 'eas cli', version: easCliVersion ?? undefined },
...(agent ? { agent } : {}),
};
}
}
@@ -0,0 +1,99 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
import UserSettings from '../../user/UserSettings';
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
import { getAgentTelemetryContext } from '../agent';
const mockIdentify = jest.fn();
const mockTrack = jest.fn();
const mockFlush = jest.fn();
jest.mock('@expo/rudder-sdk-node', () =>
jest.fn().mockImplementation(() => ({
identify: mockIdentify,
track: mockTrack,
flush: mockFlush,
}))
);
jest.mock('../../user/UserSettings', () => ({
__esModule: true,
default: {
deleteKeyAsync: jest.fn(),
getAsync: jest.fn(),
setAsync: jest.fn(),
},
}));
jest.mock('../agent', () => ({
getAgentTelemetryContext: jest.fn(),
}));
const getAgentTelemetryContextMock = jest.mocked(getAgentTelemetryContext);
const userSettingsMock = jest.mocked(UserSettings);
const originalHttpsProxy = process.env.https_proxy;
const originalDisableEasAnalytics = process.env.DISABLE_EAS_ANALYTICS;
beforeEach(() => {
mockIdentify.mockClear();
mockTrack.mockClear();
mockFlush.mockClear();
jest.mocked(RudderAnalytics).mockClear();
getAgentTelemetryContextMock.mockReset();
getAgentTelemetryContextMock.mockReturnValue(null);
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) => {
if (key === 'analyticsDeviceId') {
return 'persistent-device-id';
}
return defaultValue;
});
userSettingsMock.setAsync.mockResolvedValue({});
userSettingsMock.deleteKeyAsync.mockResolvedValue({});
delete process.env.https_proxy;
delete process.env.DISABLE_EAS_ANALYTICS;
});
afterAll(() => {
if (originalHttpsProxy === undefined) {
delete process.env.https_proxy;
} else {
process.env.https_proxy = originalHttpsProxy;
}
if (originalDisableEasAnalytics === undefined) {
delete process.env.DISABLE_EAS_ANALYTICS;
} else {
process.env.DISABLE_EAS_ANALYTICS = originalDisableEasAnalytics;
}
});
it('omits agent context when no agent is detected', async () => {
const analytics = await createAnalyticsAsync();
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.not.objectContaining({ agent: expect.anything() }),
})
);
});
it('adds detected agent context to analytics events', async () => {
getAgentTelemetryContextMock.mockReturnValue({ id: 'codex', sessionId: 'zzz' });
const analytics = await createAnalyticsAsync();
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
agent: {
id: 'codex',
sessionId: 'zzz',
},
}),
})
);
});
@@ -0,0 +1,58 @@
const detectAgentMock = jest.fn();
const logDebugMock = jest.fn();
jest.mock('agent-cli-detector', () => ({
detectAgent: detectAgentMock,
}));
jest.mock('../../log', () => ({
__esModule: true,
default: {
debug: logDebugMock,
},
}));
beforeEach(() => {
jest.resetModules();
detectAgentMock.mockReset();
logDebugMock.mockReset();
});
it('returns detected agent telemetry context', async () => {
detectAgentMock.mockReturnValue({
detected: true,
agent: { id: 'codex', name: 'Codex', sessionId: 'session-id' },
});
const { getAgentTelemetryContext } = await import('../agent');
expect(getAgentTelemetryContext()).toEqual({ id: 'codex', sessionId: 'session-id' });
});
it('returns null when no known agent is detected', async () => {
detectAgentMock.mockReturnValue({ detected: false, agent: null });
const { getAgentTelemetryContext } = await import('../agent');
expect(getAgentTelemetryContext()).toBeNull();
});
it('caches the detected agent telemetry context', async () => {
detectAgentMock.mockReturnValue({
detected: true,
agent: { id: 'codex', name: 'Codex', sessionId: undefined },
});
const { getAgentTelemetryContext } = await import('../agent');
expect(getAgentTelemetryContext()).toEqual({ id: 'codex', sessionId: undefined });
expect(getAgentTelemetryContext()).toEqual({ id: 'codex', sessionId: undefined });
expect(detectAgentMock).toHaveBeenCalledTimes(1);
});
it('returns null and logs debug details when detection throws', async () => {
detectAgentMock.mockImplementation(() => {
throw new Error('boom');
});
const { getAgentTelemetryContext } = await import('../agent');
expect(getAgentTelemetryContext()).toBeNull();
expect(logDebugMock).toHaveBeenCalledWith('Failed to detect coding agent:', 'boom');
});
+32
View File
@@ -0,0 +1,32 @@
import { detectAgent } from 'agent-cli-detector';
import Log from '../log';
export type AgentTelemetryContext = {
id: string;
sessionId: string | undefined;
};
let agentTelemetryContext: AgentTelemetryContext | null | undefined;
export function getAgentTelemetryContext(): AgentTelemetryContext | null {
if (agentTelemetryContext === undefined) {
agentTelemetryContext = resolveAgentTelemetryContext();
}
return agentTelemetryContext;
}
function resolveAgentTelemetryContext(): AgentTelemetryContext | null {
try {
const { agent, detected } = detectAgent();
if (!detected || agent == null) {
return null;
}
return { id: agent.id, sessionId: agent.sessionId };
} catch (error: any) {
Log.debug('Failed to detect coding agent:', error?.message ?? error);
return null;
}
}
@@ -58,7 +58,6 @@ describe(getProjectIdAsync, () => {
],
isExpoAdmin: false,
featureGates: {},
preferences: {},
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
});
@@ -0,0 +1,83 @@
import {
PLACEHOLDER_WORKFLOW_CONTENTS,
WorkflowStarterName,
howToRunWorkflow,
workflowStarters,
} from '../creation';
function starterFor(name: WorkflowStarterName): (typeof workflowStarters)[number] {
const starter = workflowStarters.find(s => s.name === name);
if (!starter) {
throw new Error(`No starter for ${name}`);
}
return starter;
}
describe('workflow creation templates', () => {
test('uses short default file names', () => {
expect(starterFor(WorkflowStarterName.CUSTOM).defaultFileName).toBe('custom.yml');
expect(starterFor(WorkflowStarterName.BUILD).defaultFileName).toBe('build.yml');
expect(starterFor(WorkflowStarterName.UPDATE).defaultFileName).toBe('update.yml');
expect(starterFor(WorkflowStarterName.DEPLOY).defaultFileName).toBe('deploy.yml');
});
test('development builds template has no trigger and uses the expected build profiles', () => {
const { template } = starterFor(WorkflowStarterName.BUILD);
expect(template.on).toBeUndefined();
expect(template.name).toBe('Create development builds');
expect(template.jobs).toEqual({
android_development_build: {
name: 'Build Android',
type: 'build',
params: { platform: 'android', profile: 'development' },
},
ios_device_development_build: {
name: 'Build iOS device',
type: 'build',
params: { platform: 'ios', profile: 'development' },
},
ios_simulator_development_build: {
name: 'Build iOS simulator',
type: 'build',
params: { platform: 'ios', profile: 'development-ios-simulator' },
},
});
});
test('publish update template has no trigger and is named "Publish update"', () => {
const { template } = starterFor(WorkflowStarterName.UPDATE);
expect(template.on).toBeUndefined();
expect(template.name).toBe('Publish update');
expect(template.jobs.publish_update).toMatchObject({
name: 'Publish update',
type: 'update',
});
});
test('placeholder workflow contains name, on, and jobs fields', () => {
expect(PLACEHOLDER_WORKFLOW_CONTENTS).toContain('name:');
expect(PLACEHOLDER_WORKFLOW_CONTENTS).toContain('on:');
expect(PLACEHOLDER_WORKFLOW_CONTENTS).toContain('jobs:');
});
});
describe('howToRunWorkflow', () => {
test('references the workflow file name without the .eas/workflows prefix', () => {
const message = howToRunWorkflow('build.yml', starterFor(WorkflowStarterName.BUILD));
expect(message).toContain('eas workflow:run build.yml');
expect(message).not.toContain('.eas/workflows/');
});
test('mentions automatic triggers when the template has push branches', () => {
const message = howToRunWorkflow('custom.yml', starterFor(WorkflowStarterName.CUSTOM));
expect(message).toContain('eas workflow:run custom.yml');
expect(message).toContain('automatically');
expect(message).toContain('main');
});
test('does not mention automatic triggers for manually-run templates', () => {
const message = howToRunWorkflow('update.yml', starterFor(WorkflowStarterName.UPDATE));
expect(message).toContain('eas workflow:run update.yml');
expect(message).not.toContain('automatically');
});
});
@@ -1,11 +1,17 @@
import { ExpoConfig } from '@expo/config';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, Platform } from '@expo/eas-json';
import { AndroidBuildProfile, IosBuildProfile } from '@expo/eas-json/build/build/types';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import BuildConfigure from '../../commands/build/configure';
import UpdateConfigure from '../../commands/update/configure';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { RequestedPlatform } from '../../platform';
import {
ensureEASUpdateIsConfiguredAsync,
ensureEASUpdateIsConfiguredInEasJsonAsync,
} from '../../update/configure';
import { Client } from '../../vcs/vcs';
export const DEVELOPMENT_BUILD_PROFILE_NAME = 'development';
export const DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME = 'development-ios-simulator';
export async function buildProfileNamesFromProjectAsync(projectDir: string): Promise<Set<string>> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
@@ -15,101 +21,35 @@ export async function buildProfileNamesFromProjectAsync(projectDir: string): Pro
);
return buildProfileNames;
}
export async function getBuildProfileAsync(
projectDir: string,
platform: Platform,
profileName: string
): Promise<BuildProfile<Platform>> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
profileName
);
return buildProfile;
}
export async function buildProfilesFromProjectAsync(
projectDir: string
): Promise<Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }>> {
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
const buildProfiles: Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }> =
new Map();
for (const profileName of buildProfileNames) {
const iosBuildProfile = (await getBuildProfileAsync(
projectDir,
Platform.IOS,
profileName
)) as IosBuildProfile;
const androidBuildProfile = (await getBuildProfileAsync(
projectDir,
Platform.ANDROID,
profileName
)) as AndroidBuildProfile;
buildProfiles.set(profileName, { android: androidBuildProfile, ios: iosBuildProfile });
}
return buildProfiles;
}
export function isBuildProfileForDevelopment(
buildProfile: BuildProfile<Platform>,
platform: Platform
): boolean {
if (buildProfile.developmentClient) {
return true;
}
if (platform === Platform.ANDROID) {
return (buildProfile as BuildProfile<Platform.ANDROID>).gradleCommand === ':app:assembleDebug';
}
if (platform === Platform.IOS) {
return (buildProfile as BuildProfile<Platform.IOS>).buildConfiguration === 'Debug';
}
return false;
}
export function isIosBuildProfileForSimulator(buildProfile: BuildProfile<Platform.IOS>): boolean {
return buildProfile.simulator ?? false;
}
export async function addAndroidDevelopmentBuildProfileToEasJsonAsync(
projectDir: string,
buildProfileName: string
): Promise<void> {
export async function ensureDevelopmentBuildProfilesExistAsync(projectDir: string): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
const addedProfiles: string[] = [];
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.build = {
...easJsonRawObject.build,
[buildProfileName]: {
easJsonRawObject.build = easJsonRawObject.build ?? {};
if (!easJsonRawObject.build[DEVELOPMENT_BUILD_PROFILE_NAME]) {
easJsonRawObject.build[DEVELOPMENT_BUILD_PROFILE_NAME] = {
developmentClient: true,
distribution: 'internal',
android: {
gradleCommand: ':app:assembleDebug',
},
},
};
return easJsonRawObject;
});
await easJsonAccessor.writeAsync();
}
export async function addIosDevelopmentBuildProfileToEasJsonAsync(
projectDir: string,
buildProfileName: string,
simulator: boolean
): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.build = {
...easJsonRawObject.build,
[buildProfileName]: {
};
addedProfiles.push(DEVELOPMENT_BUILD_PROFILE_NAME);
}
if (!easJsonRawObject.build[DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME]) {
easJsonRawObject.build[DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME] = {
developmentClient: true,
distribution: 'internal',
ios: {
buildConfiguration: 'Debug',
simulator,
simulator: true,
},
},
};
};
addedProfiles.push(DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME);
}
return easJsonRawObject;
});
await easJsonAccessor.writeAsync();
if (addedProfiles.length > 0) {
Log.log(`Added the following build profiles to eas.json: ${addedProfiles.join(', ')}`);
}
}
export async function addProductionBuildProfileToEasJsonIfNeededAsync(
@@ -140,7 +80,7 @@ export async function addProductionBuildProfileToEasJsonIfNeededAsync(
return profileAdded;
}
export async function hasBuildConfigureBeenRunAsync({
async function hasBuildConfigureBeenRunAsync({
projectDir,
expoConfig,
}: {
@@ -161,7 +101,7 @@ export async function hasBuildConfigureBeenRunAsync({
return true;
}
export async function hasUpdateConfigureBeenRunAsync({
async function hasUpdateConfigureBeenRunAsync({
projectDir,
expoConfig,
}: {
@@ -182,88 +122,48 @@ export async function hasUpdateConfigureBeenRunAsync({
}
}
/**
* Runs update:configure if needed. Returns a boolean (proceed with workflow creation, or not)
*/
export async function configureEasUpdateIfNeededAsync({
projectDir,
expoConfig,
projectId,
vcsClient,
}: {
projectDir: string;
expoConfig: ExpoConfig;
projectId: string;
vcsClient: Client;
}): Promise<void> {
if (await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig })) {
return;
}
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp: expoConfig,
projectId,
projectDir,
vcsClient,
platform: RequestedPlatform.All,
env: undefined,
forceNativeConfigSync: true,
manifestHostOverride: easJsonCliConfig?.updateManifestHostOverride ?? null,
});
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
Log.withTick('Configured EAS Update');
}
export async function runUpdateConfigureIfNeededAsync({
export async function configureEasBuildIfNeededAsync({
projectDir,
expoConfig,
vcsClient,
}: {
projectDir: string;
expoConfig: ExpoConfig;
}): Promise<boolean> {
if (
await hasUpdateConfigureBeenRunAsync({
projectDir,
expoConfig,
})
) {
return true;
}
const nextStep = (
await promptAsync({
type: 'select',
name: 'nextStep',
message:
'You have chosen to create a workflow that requires EAS Update configuration. What would you like to do?',
choices: [
{ title: 'Configure EAS Update and then proceed', value: 'configure' },
{ title: 'EAS Update is already configured, proceed', value: 'proceed' },
{ title: 'Choose a different workflow template', value: 'repeat' },
],
})
).nextStep;
switch (nextStep) {
case 'configure':
Log.newLine();
await UpdateConfigure.run([]);
return true;
case 'proceed':
return true;
default:
return false;
}
}
/**
* Runs build:configure if needed. Returns a boolean (proceed with workflow creation, or not)
*/
export async function runBuildConfigureIfNeededAsync({
projectDir,
expoConfig,
}: {
projectDir: string;
expoConfig: ExpoConfig;
}): Promise<boolean> {
if (
await hasBuildConfigureBeenRunAsync({
projectDir,
expoConfig,
})
) {
return true;
}
const nextStep = (
await promptAsync({
type: 'select',
name: 'nextStep',
message:
'You have chosen to create a workflow that requires EAS Build configuration. What would you like to do?',
choices: [
{ title: 'Configure EAS Build and then proceed', value: 'configure' },
{ title: 'EAS Build is already configured, proceed', value: 'proceed' },
{ title: 'Choose a different workflow template', value: 'repeat' },
],
})
).nextStep;
switch (nextStep) {
case 'configure':
Log.newLine();
await BuildConfigure.run(['-p', 'all']);
return true;
case 'proceed':
return true;
default:
return false;
vcsClient: Client;
}): Promise<void> {
if (await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig })) {
return;
}
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
}
@@ -1,19 +1,21 @@
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { ExpoConfig, getPackageJson } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import chalk from 'chalk';
import {
addAndroidDevelopmentBuildProfileToEasJsonAsync,
addIosDevelopmentBuildProfileToEasJsonAsync,
DEVELOPMENT_BUILD_PROFILE_NAME,
DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME,
addProductionBuildProfileToEasJsonIfNeededAsync,
buildProfilesFromProjectAsync,
hasBuildConfigureBeenRunAsync,
hasUpdateConfigureBeenRunAsync,
isBuildProfileForDevelopment,
isIosBuildProfileForSimulator,
ensureDevelopmentBuildProfilesExistAsync,
} from './buildProfileUtils';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
import Log, { link } from '../../log';
import { confirmAsync } from '../../prompts';
import { ensureApplicationIdIsDefinedForManagedProjectAsync } from '../../project/android/applicationId';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { resolveWorkflowAsync } from '../../project/workflow';
import { easCliVersion } from '../../utils/easCli';
import { expoCommandAsync } from '../../utils/expoCli';
import { Client } from '../../vcs/vcs';
export enum WorkflowStarterName {
BUILD = 'build',
@@ -33,6 +35,13 @@ export type WorkflowStarter = {
const createdByEASCLI = `# Created by EAS CLI v${easCliVersion}`;
export const PLACEHOLDER_WORKFLOW_CONTENTS = `name: # Workflow name
on: # Add triggers https://docs.expo.dev/eas/workflows/syntax/#on
jobs: # Add pre-packaged jobs https://docs.expo.dev/eas/workflows/pre-packaged-jobs/. See all syntax https://docs.expo.dev/eas/workflows/syntax/#jobs.
`;
const CUSTOM_TEMPLATE = {
name: 'Custom workflow',
on: {
@@ -58,95 +67,77 @@ const CUSTOM_TEMPLATE = {
};
const CUSTOM_TEMPLATE_HEADER = `
# Custom job
# Custom workflow
#
# This workflow shows how to write custom jobs.
# It contains a predefined workflow step and a shell command to print "Hello, World!".
#
# Key features:
# - Triggers on pushes to the main branch.
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
# - Can be triggered manually with eas workflow:run custom.yml
# - Runs eas/checkout then a custom "echo" command
#
# For detailed documentation on workflow syntax and available step types, visit:
# https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idstepsstepuses
# Runs eas/checkout, then a custom shell command. Triggered on pushes to "main".
# Learn more: https://docs.expo.dev/eas/workflows/syntax/
#
${createdByEASCLI}
#
`;
const BUILD_TEMPLATE = {
name: 'Create development builds',
on: {
push: {
branches: ['main'],
jobs: {
android_development_build: {
name: 'Build Android',
type: 'build',
params: {
platform: 'android',
profile: DEVELOPMENT_BUILD_PROFILE_NAME,
},
},
ios_device_development_build: {
name: 'Build iOS device',
type: 'build',
params: {
platform: 'ios',
profile: DEVELOPMENT_BUILD_PROFILE_NAME,
},
},
ios_simulator_development_build: {
name: 'Build iOS simulator',
type: 'build',
params: {
platform: 'ios',
profile: DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME,
},
},
},
jobs: {},
};
const BUILD_TEMPLATE_HEADER = `
# Create development builds
#
# This workflow shows how to create development builds.
#
# Key features:
# - Can be triggered manually with eas workflow:run create-development-builds.yml
# - Runs the pre-packaged build job to create Android and iOS development builds
# for Android emulators, Android and iOS devices, and iOS simulators
#
# For a detailed guide on using this workflow, visit:
# https://docs.expo.dev/develop/development-builds/introduction/
# Builds Android and iOS development builds for devices and simulators.
# Learn more: https://docs.expo.dev/develop/development-builds/introduction/
#
${createdByEASCLI}
#
`;
const PUBLISH_UPDATE_TEMPLATE = {
name: 'Publish preview update',
on: {
push: {
branches: ['*'],
},
},
name: 'Publish update',
jobs: {
publish_preview_update: {
name: 'Publish preview update',
publish_update: {
name: 'Publish update',
type: 'update',
params: {
branch: '${{ github.ref_name || "test" }}',
branch: '${{ github.ref_name || "main" }}',
},
},
},
};
const PUBLISH_UPDATE_TEMPLATE_HEADER = `
# Publish preview update
# Publish update
#
# This workflow shows how to publish preview updates.
# Learn more: https://docs.expo.dev/review/share-previews-with-your-team/
#
# Key features:
# - Triggers on pushes to all branches
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
# - Can be triggered manually with eas workflow:run publish-preview-update.yml
# - Runs the pre-packaged update job
#
# For a detailed guide on using this workflow, visit:
# https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
# Publishes an EAS Update to the current branch.
# Learn more: https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
#
${createdByEASCLI}
#
`;
const DEPLOY_TEMPLATE = {
name: 'Deploy to production',
on: {
push: {
branches: ['main'],
},
},
jobs: {
fingerprint: {
name: 'Fingerprint',
@@ -232,304 +223,226 @@ const DEPLOY_TEMPLATE = {
const DEPLOY_TEMPLATE_HEADER = `
# Deploy to production
#
# This workflow shows how to build and submit to app stores.
# Builds and submits to the app stores, or sends an over-the-air update when there are no native changes.
# Learn more: https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
#
# Key features:
# - Triggers on pushes to "main" (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
# - Can be triggered manually with eas workflow:run deploy-to-production.yml
# - Creates builds and submits them to app stores when native changes are detected, otherwise sends an over-the-air update.
#
# For a detailed guide on using this workflow, visit:
# https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
${createdByEASCLI}
`;
function nextStepForDeviceBuildProfile(buildProfileName: string): string {
return `A build job in this workflow uses the build profile "${buildProfileName}"
to build your app for real devices, and may require credentials.
You can configure your credentials with the command "eas credentials".
For more information, please see ${link('https://docs.expo.dev/app-signing/app-credentials/')}.
`
.trim()
.trimStart();
function nextStepsForProductionCredentials(): string[] {
return [
`Set up iOS build credentials for the "production" profile, required by the build jobs. Run ${chalk.bold(
'eas credentials:configure-build -p ios -e production'
)}`,
`Set up Android build credentials for the "production" profile, required by the build jobs. Run ${chalk.bold(
'eas credentials:configure-build -p android -e production'
)}`,
`Set up an App Store Connect API Key, required by the iOS submit job. In the menu, choose "App Store Connect: Manage your API Key" then "Set up your project to use an API Key for EAS Submit". Run ${chalk.bold(
'eas credentials -p ios'
)}`,
`Set up a Google Service Account Key, required by the Android submit job. In the menu, choose "Google Service Account" then "Set up a Google Service Account Key for Play Store Submissions". Run ${chalk.bold(
'eas credentials -p android'
)}`,
];
}
function nextStepForAppSubmission(): string {
return `This workflow includes a job to submit your app to app stores.
You will need to configure your App Store/Play Store credentials
before you can run this workflow.
Please see ${link('https://docs.expo.dev/deploy/submit-to-app-stores/')}.
`
.trim()
.trimStart();
function nextStepForDeviceDevelopmentBuild(buildProfileName: string): string {
return `Set up iOS credentials so the development build can run on a physical device (you'll register your device when prompted). Learn more: ${link(
'https://docs.expo.dev/app-signing/app-credentials/'
)} Run ${chalk.bold(`eas credentials:configure-build -p ios -e ${buildProfileName}`)}`;
}
export function howToRunWorkflow(
workflowFileName: string,
workflowStarter: WorkflowStarter
): string {
const lines = [
`To run this workflow manually, use the command "eas workflow:run .eas/workflows/${workflowFileName}".`,
];
if (workflowStarter.template?.on?.push?.branches?.length > 0) {
const branches = workflowStarter.template.on.push.branches;
let autoRunNote = '';
const branches = workflowStarter.template?.on?.push?.branches;
if (Array.isArray(branches) && branches.length > 0) {
if (branches.length === 1 && branches[0] === '*') {
lines.push(
'This workflow is also configured to run automatically when code is pushed to any branch.'
);
autoRunNote = 'This workflow also runs automatically when code is pushed to any branch. ';
} else if (branches.length === 1) {
lines.push(
`This workflow is also configured to run automatically when code is pushed to the "${branches[0]}" branch.`
);
autoRunNote = `This workflow also runs automatically when code is pushed to the "${branches[0]}" branch. `;
} else {
lines.push(
`This workflow is also configured to run automatically when code is pushed to the following branches: ${branches.join(
', '
)}.`
);
autoRunNote = `This workflow also runs automatically when code is pushed to: ${branches.join(
', '
)}. `;
}
}
return lines.join('\n');
return `${autoRunNote}Run this workflow with ${chalk.bold(`eas workflow:run ${workflowFileName}`)}`;
}
export const workflowStarters: WorkflowStarter[] = [
{
displayName: 'Custom',
displayName: `${chalk.bold('Build')} development builds`,
name: WorkflowStarterName.BUILD,
defaultFileName: 'build.yml',
template: BUILD_TEMPLATE,
header: BUILD_TEMPLATE_HEADER,
},
{
displayName: `${chalk.bold('Publish')} updates`,
name: WorkflowStarterName.UPDATE,
defaultFileName: 'update.yml',
template: PUBLISH_UPDATE_TEMPLATE,
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
},
{
displayName: `${chalk.bold('Deploy')} to production`,
name: WorkflowStarterName.DEPLOY,
defaultFileName: 'deploy.yml',
template: DEPLOY_TEMPLATE,
header: DEPLOY_TEMPLATE_HEADER,
},
{
displayName: chalk.bold('Custom'),
name: WorkflowStarterName.CUSTOM,
defaultFileName: 'custom.yml',
template: CUSTOM_TEMPLATE,
header: CUSTOM_TEMPLATE_HEADER,
},
{
displayName: 'Create development builds',
name: WorkflowStarterName.BUILD,
defaultFileName: 'create-development-builds.yml',
template: BUILD_TEMPLATE,
header: BUILD_TEMPLATE_HEADER,
},
{
displayName: 'Publish updates',
name: WorkflowStarterName.UPDATE,
defaultFileName: 'publish-updates.yml',
template: PUBLISH_UPDATE_TEMPLATE,
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
},
{
displayName: 'Deploy to production',
name: WorkflowStarterName.DEPLOY,
defaultFileName: 'deploy-to-production.yml',
template: DEPLOY_TEMPLATE,
header: DEPLOY_TEMPLATE_HEADER,
},
];
export async function addBuildJobsToDevelopmentBuildTemplateAsync(
projectDir: string,
workflowStarter: WorkflowStarter
): Promise<WorkflowStarter> {
const buildProfiles = await buildProfilesFromProjectAsync(projectDir);
// android_development_build
const nextSteps: Set<string> = new Set();
let androidDevelopmentBuildProfileName: string | null = null;
for (const profileName of buildProfiles.keys()) {
const profile = buildProfiles.get(profileName)?.android;
if (!profile) {
continue;
}
if (isBuildProfileForDevelopment(profile, Platform.ANDROID)) {
androidDevelopmentBuildProfileName = profileName;
break;
}
}
if (!androidDevelopmentBuildProfileName) {
Log.warn(
'This workflow requires an Android development build profile in your eas.json, but none were found.'
);
const add = await confirmAsync({
message: 'Do you want to add an Android development build profile?',
initial: false,
});
if (add) {
let androidDevelopmentBuildProfileName = 'android_development';
while (buildProfiles.has(androidDevelopmentBuildProfileName)) {
androidDevelopmentBuildProfileName = `${androidDevelopmentBuildProfileName}_1`;
}
await addAndroidDevelopmentBuildProfileToEasJsonAsync(
projectDir,
androidDevelopmentBuildProfileName
);
} else {
Log.log('Skipping Android development build job...');
}
}
if (androidDevelopmentBuildProfileName) {
Log.log(`Using Android development build profile: ${androidDevelopmentBuildProfileName}`);
workflowStarter.template.jobs.android_development_build = {
name: `Build ${androidDevelopmentBuildProfileName} for android`,
type: 'build',
params: {
profile: androidDevelopmentBuildProfileName,
platform: 'android',
},
};
nextSteps.add(nextStepForDeviceBuildProfile(androidDevelopmentBuildProfileName));
}
// ios_simulator_development_build
let iosSimulatorDevelopmentBuildProfileName: string | null = null;
for (const profileName of buildProfiles.keys()) {
const profile = buildProfiles.get(profileName)?.ios;
if (!profile) {
continue;
}
if (
isBuildProfileForDevelopment(profile, Platform.IOS) &&
isIosBuildProfileForSimulator(profile)
) {
iosSimulatorDevelopmentBuildProfileName = profileName;
break;
}
}
if (!iosSimulatorDevelopmentBuildProfileName) {
Log.warn(
'This workflow requires an iOS simulator development build profile in your eas.json, but none were found.'
);
const add = await confirmAsync({
message: 'Do you want to add an iOS simulator development build profile?',
initial: false,
});
if (add) {
let iosSimulatorDevelopmentBuildProfileName = 'ios_simulator_development';
while (buildProfiles.has(iosSimulatorDevelopmentBuildProfileName)) {
iosSimulatorDevelopmentBuildProfileName = `${iosSimulatorDevelopmentBuildProfileName}_1`;
}
await addIosDevelopmentBuildProfileToEasJsonAsync(
projectDir,
iosSimulatorDevelopmentBuildProfileName,
true
);
} else {
Log.log('Skipping iOS simulator development build job...');
}
}
if (iosSimulatorDevelopmentBuildProfileName) {
Log.log(
`Using iOS simulator development build profile: ${iosSimulatorDevelopmentBuildProfileName}`
);
workflowStarter.template.jobs.ios_simulator_development_build = {
name: `Build ${iosSimulatorDevelopmentBuildProfileName} for iOS simulator`,
type: 'build',
params: {
profile: iosSimulatorDevelopmentBuildProfileName,
platform: 'ios',
},
};
}
// ios_device_development_build
let iosDeviceDevelopmentBuildProfileName: string | null = null;
for (const profileName of buildProfiles.keys()) {
const profile = buildProfiles.get(profileName)?.ios;
if (!profile) {
continue;
}
if (
isBuildProfileForDevelopment(profile, Platform.IOS) &&
!isIosBuildProfileForSimulator(profile)
) {
iosDeviceDevelopmentBuildProfileName = profileName;
break;
}
}
if (!iosDeviceDevelopmentBuildProfileName) {
Log.warn(
'This workflow requires an iOS device development build profile in your eas.json, but none were found.'
);
const add = await confirmAsync({
message: 'Do you want to add an iOS device development build profile?',
initial: false,
});
if (add) {
let iosDeviceDevelopmentBuildProfileName = 'ios_device_development';
while (buildProfiles.has(iosDeviceDevelopmentBuildProfileName)) {
iosDeviceDevelopmentBuildProfileName = `${iosDeviceDevelopmentBuildProfileName}_1`;
}
await addIosDevelopmentBuildProfileToEasJsonAsync(
projectDir,
iosDeviceDevelopmentBuildProfileName,
false
);
} else {
Log.log('Skipping iOS device development build job...');
}
}
if (iosDeviceDevelopmentBuildProfileName) {
Log.log(`Using iOS device development build profile: ${iosDeviceDevelopmentBuildProfileName}`);
workflowStarter.template.jobs.ios_development_build = {
name: `Build ${iosDeviceDevelopmentBuildProfileName} for iOS simulator`,
type: 'build',
params: {
profile: iosDeviceDevelopmentBuildProfileName,
platform: 'ios',
},
};
nextSteps.add(nextStepForDeviceBuildProfile(iosDeviceDevelopmentBuildProfileName));
}
workflowStarter.nextSteps = [...nextSteps];
async function setUpDevelopmentBuildTemplateAsync({
workflowStarter,
projectDir,
expoConfig,
graphqlClient,
projectId,
vcsClient,
}: {
workflowStarter: WorkflowStarter;
projectDir: string;
expoConfig: ExpoConfig;
graphqlClient: ExpoGraphqlClient;
projectId: string;
vcsClient: Client;
}): Promise<WorkflowStarter> {
await ensureAppIdentifiersAreDefinedAsync({
graphqlClient,
projectDir,
projectId,
exp: expoConfig,
vcsClient,
});
await ensureDevelopmentBuildProfilesExistAsync(projectDir);
await ensureExpoDevClientInstalledAsync(projectDir);
workflowStarter.nextSteps = [nextStepForDeviceDevelopmentBuild(DEVELOPMENT_BUILD_PROFILE_NAME)];
return workflowStarter;
}
export async function ensureProductionBuildProfileExistsAsync(
projectDir: string,
workflowStarter: WorkflowStarter
): Promise<WorkflowStarter> {
async function ensureAppIdentifiersAreDefinedAsync({
graphqlClient,
projectDir,
projectId,
exp,
vcsClient,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
exp: ExpoConfig;
vcsClient: Client;
}): Promise<void> {
const androidWorkflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient);
if (androidWorkflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir,
projectId,
exp,
vcsClient,
nonInteractive: false,
autoSelectDefault: true,
});
}
const iosWorkflow = await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient);
if (iosWorkflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir,
projectId,
exp,
vcsClient,
nonInteractive: false,
autoSelectDefault: true,
});
}
}
async function ensureExpoDevClientInstalledAsync(projectDir: string): Promise<void> {
const packageJson = getPackageJson(projectDir);
const isInstalled = !!(packageJson.dependencies && 'expo-dev-client' in packageJson.dependencies);
if (isInstalled) {
return;
}
Log.log('Installing expo-dev-client...');
await expoCommandAsync(projectDir, ['install', 'expo-dev-client']);
}
async function setUpDeployTemplateAsync({
workflowStarter,
projectDir,
expoConfig,
graphqlClient,
projectId,
vcsClient,
}: {
workflowStarter: WorkflowStarter;
projectDir: string;
expoConfig: ExpoConfig;
graphqlClient: ExpoGraphqlClient;
projectId: string;
vcsClient: Client;
}): Promise<WorkflowStarter> {
await ensureAppIdentifiersAreDefinedAsync({
graphqlClient,
projectDir,
projectId,
exp: expoConfig,
vcsClient,
});
await addProductionBuildProfileToEasJsonIfNeededAsync(projectDir);
workflowStarter.nextSteps = [
nextStepForDeviceBuildProfile('production'),
nextStepForAppSubmission(),
];
workflowStarter.nextSteps = nextStepsForProductionCredentials();
return workflowStarter;
}
export async function customizeTemplateIfNeededAsync(
workflowStarter: WorkflowStarter,
projectDir: string,
expoConfig: ExpoConfig
): Promise<any> {
// Ensure EAS Build is configured
export async function customizeTemplateIfNeededAsync({
workflowStarter,
projectDir,
expoConfig,
graphqlClient,
projectId,
vcsClient,
}: {
workflowStarter: WorkflowStarter;
projectDir: string;
expoConfig: ExpoConfig;
graphqlClient: ExpoGraphqlClient;
projectId: string;
vcsClient: Client;
}): Promise<WorkflowStarter> {
switch (workflowStarter.name) {
case WorkflowStarterName.BUILD:
Log.debug('Setting up development builds workflow...');
return await setUpDevelopmentBuildTemplateAsync({
workflowStarter,
projectDir,
expoConfig,
graphqlClient,
projectId,
vcsClient,
});
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE:
if (!(await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig }))) {
throw new Error(
'EAS Build is not configured for this project. Please run "eas build:configure" to configure it.'
);
}
break;
default:
break;
}
// Ensure EAS Update is configured
switch (workflowStarter.name) {
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE:
if (!(await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig }))) {
throw new Error(
'EAS Update is not configured for this project. Please run "eas update:configure" to configure it.'
);
}
break;
default:
break;
}
// Customize template
switch (workflowStarter.name) {
case WorkflowStarterName.BUILD:
Log.debug('Adding build jobs to template...');
return await addBuildJobsToDevelopmentBuildTemplateAsync(projectDir, workflowStarter);
case WorkflowStarterName.DEPLOY:
return await ensureProductionBuildProfileExistsAsync(projectDir, workflowStarter);
Log.debug('Setting up deploy workflow...');
return await setUpDeployTemplateAsync({
workflowStarter,
projectDir,
expoConfig,
graphqlClient,
projectId,
vcsClient,
});
default:
return workflowStarter;
}
@@ -61,7 +61,6 @@ describe(IntegrationsConvexConnect, () => {
ownerUserActor: null,
users: [],
},
preferences: { onboarding: null },
accounts: [],
};
@@ -63,7 +63,6 @@ describe(IntegrationsPostHogConnect, () => {
featureGates: {},
isExpoAdmin: false,
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
preferences: { onboarding: null },
accounts: [],
};
@@ -3,7 +3,8 @@ import openBrowserAsync from 'better-opn';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { PostHogRegion } from '../../../../graphql/generated';
import { PostHogDeepLinkPurpose, PostHogRegion } from '../../../../graphql/generated';
import { PostHogMutation } from '../../../../graphql/mutations/PostHogMutation';
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
import {
PostHogOrganizationConnectionData,
@@ -16,6 +17,7 @@ import IntegrationsPostHogDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/PostHogQuery');
jest.mock('../../../../graphql/mutations/PostHogMutation');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
@@ -50,6 +52,8 @@ describe(IntegrationsPostHogDashboard, () => {
posthogOrganizationConnection: mockConnection,
};
const deepLinkUrl = 'https://us.posthog.com/agentic/login?token=deeplink&team_id=res-123';
function createCommand(argv: string[] = []): IntegrationsPostHogDashboard {
const command = new IntegrationsPostHogDashboard(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
@@ -67,6 +71,7 @@ describe(IntegrationsPostHogDashboard, () => {
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(PostHogMutation.createPostHogDeepLinkAsync).mockResolvedValue(deepLinkUrl);
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
@@ -75,13 +80,50 @@ describe(IntegrationsPostHogDashboard, () => {
} as any);
});
it('opens the linked PostHog project dashboard', async () => {
it('mints a signed-in deep link for the linked project and opens it', async () => {
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
expect(PostHogMutation.createPostHogDeepLinkAsync).toHaveBeenCalledWith(graphqlClient, {
posthogOrganizationConnectionId: 'connection-1',
appId: testProjectId,
purpose: PostHogDeepLinkPurpose.Dashboard,
});
expect(openBrowserAsync).toHaveBeenCalledWith(deepLinkUrl);
});
it('normalizes a trailing-slash host and percent-encodes the project identifier', async () => {
it('does not print the one-time deep-link token by default', async () => {
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(deepLinkUrl);
expect(spinner.succeed).toHaveBeenCalledWith('Opened your PostHog dashboard');
expect(spinner.succeed).not.toHaveBeenCalledWith(expect.stringContaining('token='));
});
it('prints the signed-in deep-link URL when --show-link is set', async () => {
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await createCommand(['--show-link']).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(deepLinkUrl);
expect(spinner.succeed).toHaveBeenCalledWith(`Opened ${deepLinkUrl}`);
});
it('falls back to the static (login-required) URL when minting a deep link fails', async () => {
jest
.mocked(PostHogMutation.createPostHogDeepLinkAsync)
.mockRejectedValue(new Error('deep links not enabled'));
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue({
...mockProject,
posthogHost: 'https://us.posthog.com/',
@@ -118,19 +160,25 @@ describe(IntegrationsPostHogDashboard, () => {
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('https://us.posthog.com/project/res-123')
);
expect(spinner.fail).not.toHaveBeenCalledWith(expect.stringContaining('token='));
});
it('prints the dashboard URL in non-interactive mode without opening a browser', async () => {
it('prints the stable static URL (not a one-time deep link) in non-interactive mode', async () => {
await createCommand(['--non-interactive']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(PostHogMutation.createPostHogDeepLinkAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
});
it('emits the dashboard URL as JSON with --json', async () => {
it('emits the stable static URL as JSON with --json (not a one-time deep link)', async () => {
await createCommand(['--json']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(PostHogMutation.createPostHogDeepLinkAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
dashboardUrl: 'https://us.posthog.com/project/res-123',
});
@@ -1,3 +1,4 @@
import { Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import EasCommand from '../../../commandUtils/EasCommand';
@@ -6,6 +7,8 @@ import {
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getPostHogProjectDashboardUrl, logNoPostHogProject } from '../../../commandUtils/posthog';
import { PostHogDeepLinkPurpose } from '../../../graphql/generated';
import { PostHogMutation } from '../../../graphql/mutations/PostHogMutation';
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
@@ -15,6 +18,11 @@ export default class IntegrationsPostHogDashboard extends EasCommand {
static override description = 'open the PostHog dashboard for the linked PostHog project';
static override flags = {
'show-link': Flags.boolean({
default: false,
description:
'Print the signed-in dashboard URL in addition to opening it. The URL contains a single-use login token.',
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -25,6 +33,7 @@ export default class IntegrationsPostHogDashboard extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsPostHogDashboard);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const showLink = flags['show-link'];
if (jsonFlag) {
enableJsonOutput();
}
@@ -51,24 +60,42 @@ export default class IntegrationsPostHogDashboard extends EasCommand {
return;
}
const dashboardUrl = getPostHogProjectDashboardUrl(posthogProject);
const staticDashboardUrl = getPostHogProjectDashboardUrl(posthogProject);
// Signed-in deep links are single-use and expire in 10 minutes, so only the interactive
// browser-open benefits from one. JSON/non-interactive consumers may store the URL, so give
// them the stable (login-required) dashboard URL instead.
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl });
printJsonOnlyOutput({ dashboardUrl: staticDashboardUrl });
return;
}
if (nonInteractive) {
Log.log(dashboardUrl);
Log.log(staticDashboardUrl);
return;
}
const failedMessage = `Unable to open a web browser. PostHog dashboard is available at: ${dashboardUrl}`;
const spinner = ora(`Opening ${dashboardUrl}`).start();
let dashboardUrl: string;
try {
dashboardUrl = await PostHogMutation.createPostHogDeepLinkAsync(graphqlClient, {
posthogOrganizationConnectionId: posthogProject.posthogOrganizationConnection.id,
appId: projectId,
purpose: PostHogDeepLinkPurpose.Dashboard,
});
} catch (error) {
Log.debug(`Failed to mint a PostHog deep link; opening the static dashboard URL: ${error}`);
dashboardUrl = staticDashboardUrl;
}
const openLabel = showLink ? dashboardUrl : 'your PostHog dashboard';
const failedMessage = `Unable to open a web browser. PostHog dashboard is available at: ${
showLink ? dashboardUrl : staticDashboardUrl
}`;
const spinner = ora(`Opening ${openLabel}`).start();
try {
const opened = await openBrowserAsync(dashboardUrl);
if (opened) {
spinner.succeed(`Opened ${dashboardUrl}`);
spinner.succeed(`Opened ${openLabel}`);
} else {
spinner.fail(failedMessage);
}
@@ -52,6 +52,23 @@ describe(ObserveMetrics, () => {
return command;
}
it('accepts a navigation metric alias as the positional arg', async () => {
const command = createCommand(['nav_tti']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
expect(mockFetchObserveEventsAsync.mock.calls[0][2].metricName).toBe('expo.navigation.tti');
});
it('accepts nav_cold_ttr as a positional arg and resolves it to the navigation full name', async () => {
const command = createCommand(['nav_cold_ttr']);
await command.runAsync();
expect(mockFetchObserveEventsAsync.mock.calls[0][2].metricName).toBe(
'expo.navigation.cold_ttr'
);
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
@@ -79,9 +79,9 @@ describe(ObserveRoutes, () => {
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
const command = createCommand([
'--metric',
'cold_ttr',
'nav_cold_ttr',
'--metric',
'cold_ttr',
'nav_cold_ttr',
'--metric',
'nav_tti',
]);
@@ -34,7 +34,7 @@ export default class ObserveMetrics extends EasCommand {
static override args = {
metric: Args.string({
description: 'Metric to query (e.g. tti, cold_launch)',
description: 'Metric to query (e.g. tti, cold_launch, nav_tti)',
required: false,
options: Object.keys(METRIC_ALIASES),
}),
+11 -402
View File
@@ -1,28 +1,12 @@
import { ConfigError, ExpoConfig, getProjectConfigDescription } from '@expo/config';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { validSlugName, validateFullNameAndSlug } from '../../commandUtils/projectNameValidation';
import { Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, confirmAsync, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
type InitializeMethodOptions = {
force: boolean;
nonInteractive: boolean;
};
import {
ensureOwnerSlugConsistencyAsync,
initializeWithExplicitIDAsync,
initializeWithoutExplicitIDAsync,
} from '../../project/projectInitialization';
export default class ProjectInit extends EasCommand {
static override description = 'create or link an EAS project';
@@ -44,376 +28,6 @@ export default class ProjectInit extends EasCommand {
...this.ContextOptions.ProjectDir,
};
private static async saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
): Promise<void> {
await saveProjectIdToAppConfigAsync(projectDir, projectId);
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
}
private static async modifyExpoConfigAsync(
projectDir: string,
modifications: Partial<ExpoConfig>
): Promise<void> {
let result;
try {
result = await createOrModifyExpoConfigAsync(projectDir, modifications);
} catch (error) {
if (error instanceof ConfigError && error.code === 'MODULE_NOT_FOUND') {
Log.warn(
'Cannot determine which native SDK version your project uses because the module `expo` is not installed.'
);
return;
} else {
throw error;
}
}
switch (result.type) {
case 'success':
break;
case 'warn': {
Log.warn();
Log.warn(
`Warning: Your project uses dynamic app configuration, and cannot be automatically modified.`
);
Log.warn(
chalk.dim(
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
)
);
Log.warn();
Log.warn(
`To complete the setup process, add the following in your ${chalk.bold(
getProjectConfigDescription(projectDir)
)}:`
);
Log.warn();
Log.warn(chalk.bold(JSON.stringify(modifications, null, 2)));
Log.warn();
throw new Error(result.message);
}
case 'fail':
throw new Error(result.message);
default:
throw new Error('Unexpected result type from modifyConfigAsync');
}
}
private static async ensureOwnerSlugConsistencyAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
const correctOwner = appForProjectId.ownerAccount.name;
const correctSlug = appForProjectId.slug;
if (exp.owner && exp.owner !== correctOwner) {
if (force) {
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
} else {
const message = `Project owner (${correctOwner}) does not match the value configured in the "owner" field (${exp.owner}).`;
if (nonInteractive) {
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
}
const confirm = await confirmAsync({
message: `${message}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
}
} else if (!exp.owner) {
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
}
if (exp.slug && exp.slug !== correctSlug) {
if (force) {
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
} else {
const message = `Project slug (${correctSlug}) does not match the value configured in the "slug" field (${exp.slug}).`;
if (nonInteractive) {
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
}
const confirm = await confirmAsync({
message: `${message}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
}
} else if (!exp.slug) {
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
}
}
private static async setExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (projectId === existingProjectId) {
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
return;
}
if (!existingProjectId) {
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (projectId !== existingProjectId) {
if (force) {
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
}
}
private static async initializeWithExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
await this.setExplicitIDAsync(projectId, projectDir, {
force,
nonInteractive,
});
}
private static async initializeWithoutExplicitIDAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<string> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (existingProjectId) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return existingProjectId;
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
// if no owner field, ask the user which account they want to use to create/link the project
let accountName = exp.owner;
if (!accountName) {
if (allAccounts.length === 1) {
accountName = allAccounts[0].name;
} else if (nonInteractive) {
if (!force) {
throw new Error(
`There are multiple accounts that you have access to: ${allAccounts
.map(a => a.name)
.join(
', '
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
allAccounts[0].name
}.`
);
}
accountName = allAccounts[0].name;
Log.log(`Using default account ${accountName} for non-interactive and force mode`);
} else {
const choices = ProjectInit.getAccountChoices(
actor,
accountNamesWhereUserHasSufficientPermissionsToCreateApp
);
accountName = (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
}
if (!accountName) {
throw new Error('No account selected for project. Canceling.');
}
const projectName = validSlugName(exp.slug); // This filters out invalid characters
const projectFullName = `@${accountName}/${projectName}`;
validateFullNameAndSlug(projectFullName, projectName);
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
if (existingProjectIdOnServer) {
if (!force) {
if (nonInteractive) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
);
}
const affirmedLink = await confirmAsync({
message: `Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Link this project?`,
});
if (!affirmedLink) {
throw new Error(
`Project ID configuration canceled. Re-run the command to select a different account/project.`
);
}
}
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
return existingProjectIdOnServer;
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
throw new Error(
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
);
}
if (!force) {
if (nonInteractive) {
throw new Error(
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
);
}
const affirmedCreate = await confirmAsync({
message: `Would you like to create a project for ${projectFullName}?`,
});
if (!affirmedCreate) {
throw new Error(`Project ID configuration canceled for ${projectFullName}.`);
}
}
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectLink = link(projectDashboardUrl, { text: projectFullName });
const account = nullthrows(allAccounts.find(a => a.name === accountName));
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
let createdProjectId: string;
try {
createdProjectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName,
});
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
} catch (err) {
spinner.fail();
throw err;
}
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
return createdProjectId;
}
private static getAccountChoices(
actor: Actor,
namesWithSufficientPermissions: Set<string>
): Choice[] {
const allAccounts = actor.accounts;
const sortedAccounts =
actor.__typename === 'Robot'
? allAccounts
: [...allAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
if (actor.__typename !== 'Robot') {
const personalAccount = allAccounts?.find(
account => account?.ownerUserActor?.id === actor.id
);
const personalAccountChoice = personalAccount
? {
title: personalAccount.name,
value: personalAccount,
description: !namesWithSufficientPermissions.has(personalAccount.name)
? '(Personal) (Viewer Role)'
: '(Personal)',
}
: undefined;
const userAccounts = allAccounts
?.filter(account => account.ownerUserActor && account.name !== actor.username)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Team) (Viewer Role)'
: '(Team)',
}));
const organizationAccounts = allAccounts
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Organization) (Viewer Role)'
: '(Organization)',
}));
let choices: Choice[] = [];
if (personalAccountChoice) {
choices = [personalAccountChoice];
}
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
);
}
return sortedAccounts.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
}));
}
async runAsync(): Promise<void> {
const {
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
@@ -425,24 +39,19 @@ export default class ProjectInit extends EasCommand {
let idForConsistency: string;
if (idArgument) {
await ProjectInit.initializeWithExplicitIDAsync(idArgument, projectDir, {
await initializeWithExplicitIDAsync(idArgument, projectDir, {
force,
nonInteractive,
});
idForConsistency = idArgument;
} else {
idForConsistency = await ProjectInit.initializeWithoutExplicitIDAsync(
graphqlClient,
actor,
projectDir,
{
force,
nonInteractive,
}
);
idForConsistency = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
force,
nonInteractive,
});
}
await ProjectInit.ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
await ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
force,
nonInteractive,
});
@@ -1,498 +0,0 @@
import { Args } from '@oclif/core';
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getEASUpdateURL, getExpoWebsiteBaseUrl } from '../../api';
import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { reviewAndCommitChangesAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { validateOrSetProjectIdAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
import { CredentialsContextProjectInfo } from '../../credentials/context';
import { SetUpBuildCredentialsCommandAction } from '../../credentials/manager/SetUpBuildCredentialsCommandAction';
import {
AppFragment,
AppPlatform,
OnboardingDeviceType,
OnboardingEnvironment,
} from '../../graphql/generated';
import { UserPreferencesMutation } from '../../graphql/mutations/UserPreferencesMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import {
canAccessRepositoryUsingSshAsync,
runGitCloneAsync,
runGitPushAsync,
} from '../../onboarding/git';
import { installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { RequestedPlatform } from '../../platform';
import { ExpoConfigOptions, getPrivateExpoConfigAsync } from '../../project/expoConfig';
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 {
static override aliases = ['init:onboarding', 'onboarding'];
static override description =
'continue onboarding process started on the https://expo.new website.';
static override flags = {};
static override args = {
TARGET_PROJECT_DIRECTORY: Args.string({}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
const {
args: { TARGET_PROJECT_DIRECTORY: targetProjectDirInput },
} = await this.parse(Onboarding);
const {
loggedIn: { actor, graphqlClient },
analytics,
} = await this.getContextAsync(Onboarding, {
nonInteractive: false,
});
if (actor.__typename === 'Robot') {
throw new Error(
'This command is not available for robot users. Make sure you are not using a robot token and try again.'
);
}
if (actor.__typename === 'PartnerActor') {
throw new Error(
'This command is not available for partner actors. Make sure you are using a user token and try again.'
);
}
if (!actor.preferences.onboarding) {
throw new Error(
'This command can only be run as part of the onboarding process started on the Expo website. Visit https://expo.new to start a new project.'
);
}
if (!actor.preferences.onboarding.platform) {
throw new Error(
'This command can only be run as part of the onboarding process started on the Expo website. It seems like you started an onboarding process, but we are missing some information needed to be filled in before running the eas init:onboarding command (selected platform). Continue the onboarding process on the Expo website.'
);
}
if (!actor.preferences.onboarding.environment) {
throw new Error(
'This command can only be run as part of the onboarding process started on the Expo website. It seems like you started an onboarding process, but we are missing some information needed to be filled in before running the eas init:onboarding command (selected environment). Continue the onboarding process on the Expo website.'
);
}
if (!actor.preferences.onboarding.deviceType) {
throw new Error(
'This command can only be run as part of the onboarding process started on the Expo website. It seems like you started an onboarding process, but we are missing some information needed to be filled in before running the eas init:onboarding command (selected device type). Continue the onboarding process on the Expo website.'
);
}
if (
new Date(actor.preferences.onboarding.lastUsed) < new Date(Date.now() - 1000 * 60 * 60 * 24)
) {
Log.warn(
'It seems like you started an onboarding process, but it has been a while since you last used it. If you want to start a new onboarding process, visit https://expo.new.'
);
Log.log();
}
const platform =
actor.preferences.onboarding.platform === AppPlatform.Android
? Platform.ANDROID
: Platform.IOS;
const app = await AppQuery.byIdAsync(graphqlClient, actor.preferences.onboarding.appId);
const githubUsername = app.githubRepository
? app.githubRepository.metadata.githubRepoOwnerName
: 'expo';
const githubRepositoryName = app.githubRepository
? app.githubRepository.metadata.githubRepoName
: 'expo-template-default';
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
Log.log();
Log.log('✨ We will continue your onboarding process in EAS CLI');
Log.log();
Log.log(
`🚚 Let's start by cloning ${
app.githubRepository
? `your project (${githubUsername}/${githubRepositoryName})`
: `default Expo template project (${githubUsername}/${githubRepositoryName})`
} from GitHub and installing dependencies.`
);
Log.log();
let initialTargetProjectDirectory: string;
if (targetProjectDirInput) {
initialTargetProjectDirectory = targetProjectDirInput;
Log.log(`📂 Cloning the project to ${initialTargetProjectDirectory}`);
} else {
const { selectedTargetProjectDirectory } = await promptAsync({
type: 'text',
name: 'selectedTargetProjectDirectory',
message: app.githubRepository
? '📂 Where would you like to clone the project to?'
: '📂 Where would you like to create your new project directory?',
initial: app.githubRepository
? path.join(process.cwd(), githubRepositoryName)
: path.join(process.cwd(), `${actor.username}-first-project`),
});
initialTargetProjectDirectory = selectedTargetProjectDirectory;
}
Log.log();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}))
? 'ssh'
: 'https';
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
Log.log();
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
githubUsername,
githubRepositoryName,
targetProjectDir: initialTargetProjectDirectory,
cloneMethod,
});
const vcsClient = new GitClient({
maybeCwdOverride: finalTargetProjectDirectory,
requireCommit: false,
});
if (!app.githubRepository) {
await fs.remove(path.join(finalTargetProjectDirectory, '.git'));
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'git',
args: ['init'],
});
Log.log();
await configureProjectFromBareDefaultExpoTemplateAsync({
app,
vcsClient,
targetDir: finalTargetProjectDirectory,
});
}
await installDependenciesAsync({
projectDir: finalTargetProjectDirectory,
});
const exp = await getPrivateExpoConfigWithProjectIdAsync({
projectDir: finalTargetProjectDirectory,
graphqlClient,
actor,
});
const getDynamicProjectConfigFn = getDynamicPrivateProjectConfigGetter({
projectDir: finalTargetProjectDirectory,
graphqlClient,
actor,
});
if (!app.githubRepository) {
await expoCommandAsync(finalTargetProjectDirectory, [
'install',
'expo-updates',
'expo-insights',
'expo-dev-client',
]);
Log.log();
}
await vcsClient.trackFileAsync('package-lock.json');
const shouldSetupCredentials =
((platform === Platform.IOS &&
actor.preferences.onboarding.deviceType === OnboardingDeviceType.Device) ||
platform === Platform.ANDROID) &&
actor.preferences.onboarding.environment === OnboardingEnvironment.DevBuild;
if (shouldSetupCredentials) {
Log.log('🔑 Now we need to set up build credentials for your project:');
await new SetUpBuildCredentialsCommandAction(
actor,
graphqlClient,
vcsClient,
analytics,
exp,
getDynamicProjectConfigFn,
platform,
actor.preferences.onboarding.deviceType === OnboardingDeviceType.Simulator
? 'development-simulator'
: 'development',
finalTargetProjectDirectory
).runAsync();
}
if (app.githubRepository && (await vcsClient.hasUncommittedChangesAsync())) {
Log.log(
'📦 We will now commit the changes made by the configuration process and push them to GitHub:'
);
Log.log();
Log.log('🔍 Checking for changes in the repository...');
await vcsClient.showChangedFilesAsync();
await reviewAndCommitChangesAsync(
vcsClient,
`[eas-onboarding] Install dependencies${
shouldSetupCredentials ? ' and set up build credentials' : ''
}`,
{ nonInteractive: false }
);
Log.log('📤 Pushing changes to GitHub...');
await runGitPushAsync({
targetProjectDir: finalTargetProjectDirectory,
});
} else if (!app.githubRepository) {
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'git',
args: ['add', '.'],
});
Log.log();
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'git',
args: ['commit', '-m', 'Initial commit'],
});
Log.log();
}
Log.log();
Log.log('🎉 We finished configuring your project.');
Log.log();
if (
!!app.githubRepository ||
actor.preferences.onboarding.environment === OnboardingEnvironment.ExpoGo
) {
Log.log('🚀 You can now go back to the website to continue:');
const url = new URL(
`/onboarding/develop/set-up-project-on-your-machine?project=${app.slug}&accountId=${app.ownerAccount.id}`,
getExpoWebsiteBaseUrl()
).toString();
Log.log(`👉 ${link(url)}`);
} else {
Log.log('🚀 Now we are going to trigger your first build');
Log.log();
const { buildIds } = await runBuildAndSubmitAsync({
graphqlClient,
analytics,
vcsClient,
projectDir: finalTargetProjectDirectory,
flags: {
nonInteractive: true,
requestedPlatform:
platform === Platform.ANDROID ? RequestedPlatform.Android : RequestedPlatform.Ios,
profile:
actor.preferences.onboarding.deviceType === OnboardingDeviceType.Simulator
? 'development-simulator'
: 'development',
wait: false,
clearCache: false,
json: false,
autoSubmit: false,
localBuildOptions: {},
freezeCredentials: false,
},
actor,
// eslint-disable-next-line async-protect/async-suffix
getDynamicPrivateProjectConfigAsync: getDynamicProjectConfigFn,
});
const buildId = buildIds[0];
Log.log();
Log.log('🚀 You can now go back to the website to continue:');
const url = new URL(
`/onboarding/develop/set-up-project-on-your-machine?project=${app.slug}&accountId=${app.ownerAccount.id}&buildId=${buildId}`,
getExpoWebsiteBaseUrl()
).toString();
Log.log(`👉 ${link(url)}`);
}
const { __typename, ...previousPreferences } = actor.preferences.onboarding;
await UserPreferencesMutation.markCliDoneInOnboardingUserPreferencesAsync(graphqlClient, {
...previousPreferences,
appId: app.id,
});
}
}
// we can't get this automated by using command context because when we run a command the project directory doesn't exist yet
async function getPrivateExpoConfigWithProjectIdAsync({
projectDir,
graphqlClient,
actor,
options,
}: {
projectDir: string;
graphqlClient: ExpoGraphqlClient;
actor: Actor;
options?: ExpoConfigOptions;
}): Promise<CredentialsContextProjectInfo> {
const expBefore = await getPrivateExpoConfigAsync(projectDir, options);
const projectId = await validateOrSetProjectIdAsync({
exp: expBefore,
graphqlClient,
actor,
options: {
nonInteractive: false,
},
cwd: projectDir,
});
const exp = await getPrivateExpoConfigAsync(projectDir, options);
return {
exp,
projectId,
};
}
// we can't get this automated by using command context because when we run a command the project directory doesn't exist yet
function getDynamicPrivateProjectConfigGetter({
projectDir,
graphqlClient,
actor,
}: {
projectDir: string;
graphqlClient: ExpoGraphqlClient;
actor: Actor;
}): DynamicConfigContextFn {
return async (options?: ExpoConfigOptions) => {
return {
...(await getPrivateExpoConfigWithProjectIdAsync({
projectDir,
graphqlClient,
actor,
options,
})),
projectDir,
};
};
}
async function configureProjectFromBareDefaultExpoTemplateAsync({
app,
vcsClient,
targetDir,
}: {
app: AppFragment;
vcsClient: GitClient;
targetDir: string;
}): Promise<void> {
// Android package name requires each component to start with a lowercase letter.
const isUsernameValidSegment = /^[^a-z]/.test(app.ownerAccount.name);
const userPrefix = isUsernameValidSegment ? 'user' : '';
const isSlugValidSegment = /^[^a-z]/.test(app.slug);
const slugPrefix = isSlugValidSegment ? 'app' : '';
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
app.ownerAccount.name
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.slug)}`;
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
const easBuildGitHubConfig = {
android: {
image: 'latest',
},
ios: {
image: 'latest',
},
};
const easJson: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
appVersionSource: AppVersionSource.REMOTE,
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
...easBuildGitHubConfig,
},
'development-simulator': {
extends: 'development',
ios: {
simulator: true,
},
},
preview: {
distribution: 'internal',
channel: 'main',
...easBuildGitHubConfig,
},
production: {
channel: 'production',
autoIncrement: true,
...easBuildGitHubConfig,
},
},
submit: {
production: {},
},
};
const easJsonPath = path.join(targetDir, 'eas.json');
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await vcsClient.trackFileAsync(easJsonPath);
Log.log(
`✅ Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
Log.log();
const baseExpoConfig = JSON.parse(await fs.readFile(path.join(targetDir, 'app.json'), 'utf8'))
.expo as ExpoConfig;
const expoConfig: ExpoConfig = {
...baseExpoConfig,
name: app.name ?? app.slug,
slug: app.slug,
extra: {
eas: {
projectId: app.id,
},
},
owner: app.ownerAccount.name,
updates: {
url: updateUrl,
},
runtimeVersion: {
policy: 'appVersion',
},
ios: {
...baseExpoConfig.ios,
bundleIdentifier,
},
android: {
...baseExpoConfig.android,
package: bundleIdentifier,
},
};
const appJsonPath = path.join(targetDir, 'app.json');
await fs.writeFile(appJsonPath, `${JSON.stringify({ expo: expoConfig }, null, 2)}\n`);
await vcsClient.trackFileAsync(appJsonPath);
Log.log(
`✅ Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.log();
}
function stripInvalidCharactersForBundleIdentifier(string: string): string {
return string.replaceAll(/[^A-Za-z0-9]/g, '');
}
@@ -2,6 +2,7 @@ import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
@@ -47,6 +48,11 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: null,
updatedAt: '2025-01-01T00:01:00.000Z',
app: {
id: 'app-123',
slug: 'testapp',
@@ -55,6 +61,18 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
name: 'testuser',
},
},
artifacts: [
{
id: 'artifact-123',
name: 'session-log',
filename: 'session.log',
downloadUrl: 'https://artifacts.example.com/session.log',
fileSizeBytes: 1234,
metadata: { kind: 'log' },
createdAt: '2025-01-01T00:00:10.000Z',
updatedAt: '2025-01-01T00:00:20.000Z',
},
],
remoteConfig: {
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
@@ -118,9 +136,15 @@ describe(SimulatorGet, () => {
id: 'session-123',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: undefined,
updatedAt: '2025-01-01T00:01:00.000Z',
deviceRunSessionUrl:
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123',
remoteConfig: session.remoteConfig,
artifacts: session.artifacts,
});
});
@@ -98,6 +98,11 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: null,
updatedAt: '2025-01-01T00:01:00.000Z',
app: {
id: 'app-123',
slug: 'testapp',
@@ -106,6 +111,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
name: 'testuser',
},
},
artifacts: [],
remoteConfig: {
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
+76 -6
View File
@@ -6,7 +6,7 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
@@ -19,8 +19,13 @@ import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
} from '../../simulator/utils';
import { formatBytes } from '../../utils/files';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
export default class SimulatorGet extends EasCommand {
static override hidden = true;
static override description =
@@ -62,7 +67,7 @@ export default class SimulatorGet extends EasCommand {
}
const fetchSpinner = ora(`Fetching simulator session ${flagId}`).start();
let session;
let session: DeviceRunSessionById;
try {
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flagId);
fetchSpinner.succeed(`Fetched simulator session ${session.id}`);
@@ -82,17 +87,20 @@ export default class SimulatorGet extends EasCommand {
id: session.id,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
createdAt: session.createdAt,
startedAt: session.startedAt ?? undefined,
finishedAt: session.finishedAt ?? undefined,
updatedAt: session.updatedAt,
deviceRunSessionUrl,
remoteConfig: session.remoteConfig,
artifacts: session.artifacts,
});
return;
}
Log.newLine();
Log.log(`ID: ${session.id}`);
Log.log(`Type: ${session.type}`);
Log.log(`Status: ${session.status}`);
Log.log(`URL: ${link(deviceRunSessionUrl)}`);
Log.log(formatSessionFields(session, deviceRunSessionUrl));
if (session.status === DeviceRunSessionStatus.InProgress) {
Log.newLine();
@@ -104,5 +112,67 @@ export default class SimulatorGet extends EasCommand {
);
}
}
printArtifacts('Session artifacts', session.artifacts, formatDeviceRunSessionArtifactFields);
}
}
function formatSessionFields(session: DeviceRunSessionById, deviceRunSessionUrl: string): string {
return formatFields([
{ label: 'ID', value: session.id },
{ label: 'Type', value: session.type },
{ label: 'Status', value: session.status },
{ label: 'Platform', value: session.platform },
{ label: 'Created at', value: String(session.createdAt) },
{ label: 'Started at', value: formatNullable(session.startedAt) },
{ label: 'Finished at', value: formatNullable(session.finishedAt) },
{ label: 'Updated at', value: String(session.updatedAt) },
{ label: 'URL', value: link(deviceRunSessionUrl) },
]);
}
function printArtifacts<TArtifact>(
title: string,
artifacts: TArtifact[],
formatArtifactFields: (artifact: TArtifact) => FormatFieldsItem[]
): void {
if (artifacts.length === 0) {
return;
}
Log.addNewLineIfNone();
Log.gray(`${title}:`);
for (const artifact of artifacts) {
Log.log(formatFields(formatArtifactFields(artifact)));
Log.addNewLineIfNone();
}
}
function formatDeviceRunSessionArtifactFields(
artifact: DeviceRunSessionArtifact
): FormatFieldsItem[] {
return [
{ label: ' ID', value: artifact.id },
{ label: ' Name', value: artifact.name },
{ label: ' Filename', value: artifact.filename },
{ label: ' File size', value: formatFileSize(artifact.fileSizeBytes) },
{ label: ' Created at', value: String(artifact.createdAt) },
{ label: ' Updated at', value: String(artifact.updatedAt) },
{ label: ' Metadata', value: formatMetadata(artifact.metadata) },
{ label: ' Download URL', value: link(artifact.downloadUrl) },
];
}
function formatFileSize(fileSizeBytes: number | null | undefined): string {
return typeof fileSizeBytes === 'number'
? `${formatBytes(fileSizeBytes)} (${fileSizeBytes} B)`
: 'null';
}
function formatMetadata(metadata: unknown): string {
return metadata ? JSON.stringify(metadata) : 'null';
}
function formatNullable(value: unknown): string {
return value ? String(value) : 'null';
}
+174 -105
View File
@@ -1,15 +1,18 @@
import { ExpoConfig } from '@expo/config';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs/promises';
import fsExtra from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import {
runBuildConfigureIfNeededAsync,
runUpdateConfigureIfNeededAsync,
configureEasBuildIfNeededAsync,
configureEasUpdateIfNeededAsync,
} from '../../commandUtils/workflow/buildProfileUtils';
import {
PLACEHOLDER_WORKFLOW_CONTENTS,
WorkflowStarter,
WorkflowStarterName,
customizeTemplateIfNeededAsync,
@@ -21,22 +24,34 @@ import {
validateWorkflowFileAsync,
workflowContentsFromParsedYaml,
} from '../../commandUtils/workflow/validation';
import Log from '../../log';
import Log, { link } from '../../log';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { initializeWithoutExplicitIDAsync } from '../../project/projectInitialization';
import { promptAsync } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { WorkflowFile } from '../../utils/workflowFile';
import { Client } from '../../vcs/vcs';
export class WorkflowCreate extends EasCommand {
static override description = 'create a new workflow configuration YAML file';
static override args = {
name: Args.string({
description: 'Name of the workflow file (must end with .yml or .yaml)',
description:
'Name of the workflow file. When provided without --template, a placeholder workflow is created.',
required: false,
}),
};
static override flags = {
template: Flags.option({
description: 'Template to use for the workflow file',
options: [
WorkflowStarterName.BUILD,
WorkflowStarterName.UPDATE,
WorkflowStarterName.DEPLOY,
WorkflowStarterName.CUSTOM,
] as const,
})(),
'skip-validation': Flags.boolean({
description: 'If set, the workflow file will not be validated before being created',
default: false,
@@ -47,6 +62,7 @@ export class WorkflowCreate extends EasCommand {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
@@ -58,68 +74,67 @@ export class WorkflowCreate extends EasCommand {
try {
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
loggedIn: { actor, graphqlClient },
projectDir,
vcsClient,
} = await this.getContextAsync(WorkflowCreate, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const { exp: originalExpoConfig, projectId } = await getDynamicPrivateProjectConfigAsync();
let expoConfig = originalExpoConfig;
let workflowStarter;
while (!workflowStarter) {
workflowStarter = await chooseTemplateAsync();
switch (workflowStarter.name) {
case WorkflowStarterName.BUILD:
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE: {
const shouldProceed = await runBuildConfigureIfNeededAsync({
projectDir,
expoConfig,
});
if (!shouldProceed) {
workflowStarter = undefined;
continue;
}
break;
}
default:
break;
}
switch (workflowStarter.name) {
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE: {
const shouldProceed = await runUpdateConfigureIfNeededAsync({
projectDir,
expoConfig,
});
if (!shouldProceed) {
workflowStarter = undefined;
continue;
}
// Need to refetch the Expo config because it may have changed
expoConfig = (await getDynamicPrivateProjectConfigAsync()).exp;
break;
}
default:
break;
}
if (argFileName && !flags.template) {
await createPlaceholderWorkflowFileAsync({ argFileName, projectDir });
return;
}
const { fileName, filePath } = await chooseFileNameAsync(
const privateExpoConfig = await getPrivateExpoConfigAsync(projectDir);
if (!privateExpoConfig.extra?.eas?.projectId) {
// Unlike `eas init`, we intentionally do not call `ensureOwnerSlugConsistencyAsync` here.
// The workflow scaffolder should only link/create a project ID, not rewrite the app
// config's `owner`/`slug` fields. If those are inconsistent, we leave them for `eas init`
// to reconcile.
await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
force: false,
nonInteractive: false,
});
}
const { exp: originalExpoConfig, projectId } = await getDynamicPrivateProjectConfigAsync();
let workflowStarter = flags.template
? nullthrows(workflowStarters.find(s => s.name === flags.template))
: await chooseTemplateAsync();
let expoConfig = originalExpoConfig;
if (
workflowStarter.name === WorkflowStarterName.BUILD ||
workflowStarter.name === WorkflowStarterName.UPDATE ||
workflowStarter.name === WorkflowStarterName.DEPLOY
) {
expoConfig = await configureProjectForStarterAsync({
workflowStarter,
projectDir,
expoConfig: originalExpoConfig,
projectId,
vcsClient,
getDynamicPrivateProjectConfigAsync,
});
}
const { fileName, filePath } = await resolveTemplateFileNameAsync({
argFileName,
projectDir,
workflowStarter
);
workflowStarter,
});
// Customize the template if needed
workflowStarter = await customizeTemplateIfNeededAsync(
workflowStarter = await customizeTemplateIfNeededAsync({
workflowStarter,
projectDir,
expoConfig
);
expoConfig,
graphqlClient,
projectId,
vcsClient,
});
Log.debug(`Creating workflow file ${fileName} from template ${workflowStarter.name}`);
const yamlString = [
@@ -136,32 +151,88 @@ export class WorkflowCreate extends EasCommand {
);
}
await ensureWorkflowsDirectoryExistsAsync({ projectDir });
filePath && (await fs.writeFile(filePath, yamlString));
await fs.writeFile(filePath, yamlString);
Log.withTick(`Created ${chalk.bold(filePath)}`);
Log.addNewLineIfNone();
Log.log(howToRunWorkflow(fileName, workflowStarter));
// Next steps
if (workflowStarter.nextSteps && workflowStarter.nextSteps.length > 0) {
Log.addNewLineIfNone();
Log.log('Next steps:');
Log.addNewLineIfNone();
Log.log(
formatFields(
workflowStarter.nextSteps.map((step: string, index: number) => ({
label: `${index + 1}.`,
value: step,
}))
)
);
}
logNextSteps([
...(workflowStarter.nextSteps ?? []).map(
step => `${chalk.yellow('[Action required]')} ${step}`
),
howToRunWorkflow(fileName, workflowStarter),
]);
} catch (error) {
logWorkflowValidationErrors(error);
Log.error('Failed to create workflow file.');
process.exitCode = 1;
}
}
}
async function configureProjectForStarterAsync({
workflowStarter,
projectDir,
expoConfig,
projectId,
vcsClient,
getDynamicPrivateProjectConfigAsync,
}: {
workflowStarter: WorkflowStarter;
projectDir: string;
expoConfig: ExpoConfig;
projectId: string;
vcsClient: Client;
getDynamicPrivateProjectConfigAsync: () => Promise<{ exp: ExpoConfig }>;
}): Promise<ExpoConfig> {
await configureEasBuildIfNeededAsync({ projectDir, expoConfig, vcsClient });
if (
workflowStarter.name === WorkflowStarterName.UPDATE ||
workflowStarter.name === WorkflowStarterName.DEPLOY
) {
await configureEasUpdateIfNeededAsync({ projectDir, expoConfig, projectId, vcsClient });
}
return (await getDynamicPrivateProjectConfigAsync()).exp;
}
function logNextSteps(steps: string[]): void {
Log.addNewLineIfNone();
Log.log('➡️ Next steps:');
Log.addNewLineIfNone();
Log.log(
formatFields(
steps.map((step, index) => ({
label: `${index + 1}.`,
value: step,
}))
)
);
}
async function createPlaceholderWorkflowFileAsync({
argFileName,
projectDir,
}: {
argFileName: string;
projectDir: string;
}): Promise<void> {
const fileName = ensureYamlExtension(argFileName);
const filePath = path.join(projectDir, '.eas', 'workflows', fileName);
if (await fsExtra.pathExists(filePath)) {
Log.error(`Workflow file already exists: ${chalk.bold(filePath)}`);
return;
}
await ensureWorkflowsDirectoryExistsAsync({ projectDir });
await fs.writeFile(filePath, PLACEHOLDER_WORKFLOW_CONTENTS);
Log.withTick(`Created ${chalk.bold(filePath)}`);
logNextSteps([
`Fill in the "name", "on", and "jobs" fields. Learn more: ${link(
'https://docs.expo.dev/eas/workflows/syntax/'
)}`,
`Run this workflow with ${chalk.bold(`eas workflow:run ${fileName}`)}`,
]);
}
async function ensureWorkflowsDirectoryExistsAsync({
projectDir,
}: {
@@ -190,40 +261,38 @@ async function chooseTemplateAsync(): Promise<WorkflowStarter> {
return workflowStarter;
}
async function chooseFileNameAsync(
initialValue: string | undefined,
projectDir: string,
workflowStarter: WorkflowStarter
): Promise<{ fileName: string; filePath: string }> {
let fileName = initialValue;
let filePath = '';
while ((fileName?.length ?? 0) === 0) {
fileName = (
await promptAsync({
type: 'text',
name: 'fileName',
message: 'What would you like to name your workflow file?',
initial: workflowStarter.defaultFileName,
})
).fileName;
if (!fileName) {
fileName = undefined;
continue;
}
try {
WorkflowFile.validateYamlExtension(fileName);
} catch (error) {
Log.error(error instanceof Error ? error.message : 'Invalid YAML file name extension');
fileName = undefined;
continue;
}
async function resolveTemplateFileNameAsync({
argFileName,
projectDir,
workflowStarter,
}: {
argFileName: string | undefined;
projectDir: string;
workflowStarter: WorkflowStarter;
}): Promise<{ fileName: string; filePath: string }> {
const baseName = argFileName ? ensureYamlExtension(argFileName) : workflowStarter.defaultFileName;
const ext = path.extname(baseName);
const stem = baseName.slice(0, baseName.length - ext.length);
let fileName = baseName;
let filePath = path.join(projectDir, '.eas', 'workflows', fileName);
let counter = 1;
while (await fsExtra.pathExists(filePath)) {
fileName = `${stem}-${counter}${ext}`;
filePath = path.join(projectDir, '.eas', 'workflows', fileName);
if (await fsExtra.pathExists(filePath)) {
Log.error(`Workflow file already exists: ${filePath}`);
Log.error('Please choose a different file name.');
Log.newLine();
fileName = undefined;
}
counter++;
}
return { fileName: fileName ?? '', filePath };
return { fileName, filePath };
}
function ensureYamlExtension(name: string): string {
const ext = path.extname(name);
if (!ext) {
return `${name}.yml`;
}
const lowerExt = ext.toLowerCase();
if (lowerExt === '.yml' || lowerExt === '.yaml') {
return name;
}
throw new Error(`Workflow file name must end with .yml or .yaml, but got "${name}".`);
}
@@ -27,7 +27,6 @@ export const jester: Actor = {
],
isExpoAdmin: false,
featureGates: {},
preferences: {},
};
export const jester2 = {
@@ -654,10 +654,81 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
noBroadcastNotificationOption
)
).rejects.toThrowError(
`https://developer-mdn.apple.com/account/resources/identifiers/bundleId/edit/XXX333`
`https://developer.apple.com/account/resources/identifiers/bundleId/edit/XXX333`
);
});
it('re-throws generic Apple errors with actionable context and preserves the original message', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => []),
updateBundleIdCapabilityAsync: jest
.fn()
.mockRejectedValue(
new Error(
"HTTP 400 — The request entity is not a valid request document object - Unexpected or invalid value at 'data.relationships.bundleIdCapabilities.data.[0].attributes'."
)
),
attributes: { identifier: 'dev.expo.app' },
id: 'XXX333',
} as any;
await expect(
syncCapabilitiesForEntitlementsAsync(
bundleId,
{ 'com.apple.developer.associated-domains': ['applinks:example.com'] },
noBroadcastNotificationOption
)
).rejects.toThrow(/Failed to update Apple capabilities for app "dev\.expo\.app"/);
await expect(
syncCapabilitiesForEntitlementsAsync(
bundleId,
{ 'com.apple.developer.associated-domains': ['applinks:example.com'] },
noBroadcastNotificationOption
)
).rejects.toThrow(/data\.relationships\.bundleIdCapabilities\.data\.\[0\]\.attributes/);
});
it('mentions EXPO_NO_CAPABILITY_SYNC and the Apple developer console URL in rethrown errors', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => []),
updateBundleIdCapabilityAsync: jest.fn().mockRejectedValue(new Error('Some Apple error')),
attributes: { identifier: 'dev.expo.app' },
id: 'XXX333',
} as any;
await expect(
syncCapabilitiesForEntitlementsAsync(
bundleId,
{ 'com.apple.developer.associated-domains': ['applinks:example.com'] },
noBroadcastNotificationOption
)
).rejects.toThrow(/EXPO_NO_CAPABILITY_SYNC=1/);
});
it('preserves the original error as .cause when rethrowing generic Apple errors', async () => {
const originalError = new Error('Some Apple error');
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => []),
updateBundleIdCapabilityAsync: jest.fn().mockRejectedValue(originalError),
attributes: { identifier: 'dev.expo.app' },
id: 'XXX333',
} as any;
let caught: any;
try {
await syncCapabilitiesForEntitlementsAsync(
bundleId,
{ 'com.apple.developer.associated-domains': ['applinks:example.com'] },
noBroadcastNotificationOption
);
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
expect(caught.cause).toBe(originalError);
});
it('cannot skip complex duplicates', async () => {
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: [] }),
@@ -67,15 +67,21 @@ export async function syncCapabilitiesForEntitlementsAsync(
try {
await bundleId.updateBundleIdCapabilityAsync(modifiedRequest);
} catch (error: any) {
if (error.message.match(/bundle '[\w\d]+' cannot be deleted. Delete all the Apps/)) {
Log.error(
'Failed to patch capabilities:',
inspect(modifiedRequest, { depth: null, colors: true })
);
throw new Error(
`Unexpected error occurred while attempting to update capabilities for app "${bundleId.attributes.identifier}".\nCapabilities can be modified manually in the Apple developer console at https://developer-mdn.apple.com/account/resources/identifiers/bundleId/edit/${bundleId.id}.\nAuto capability syncing can be disabled with the environment variable \`EXPO_NO_CAPABILITY_SYNC=1\`.\n${error.message}`
);
Log.error(
'Failed to patch capabilities:',
inspect(modifiedRequest, { depth: null, colors: true })
);
const context = `Failed to update Apple capabilities for app "${bundleId.attributes.identifier}".\nYou can enable or disable capabilities manually in the Apple developer console at https://developer.apple.com/account/resources/identifiers/bundleId/edit/${bundleId.id}, then re-run this command.\nAuto capability syncing can be disabled with the environment variable \`EXPO_NO_CAPABILITY_SYNC=1\`.`;
if (error.message?.match?.(/bundle '[\w\d]+' cannot be deleted. Delete all the Apps/)) {
throw new Error(`${context}\n${error.message}`);
}
// Preserve the underlying Apple error and stack, but attach the same
// actionable context so the user knows how to unblock their build.
const wrapped: Error & { cause?: unknown } = new Error(
`${context}\n\nApple API error: ${error.message}`
);
wrapped.cause = error;
throw wrapped;
}
}
@@ -45,7 +45,6 @@ describe(AccountResolver, () => {
],
isExpoAdmin: false,
featureGates: {},
preferences: {},
};
describe('when inside project dir', () => {
+156 -224
View File
@@ -79,8 +79,6 @@ export type AccessTokenMutationSetAccessTokenRevokedArgs = {
*/
export type Account = {
__typename?: 'Account';
/** @deprecated Legacy access tokens are deprecated */
accessTokens: Array<Maybe<AccessToken>>;
/** Server account feature gate values for this account, optionally filtering by desired gates. */
accountFeatureGates: Scalars['JSONObject']['output'];
/** Coalesced project activity for all apps belonging to this account. */
@@ -116,8 +114,6 @@ export type Account = {
appsPaginated: AccountAppsConnection;
/** Audit logs for account */
auditLogsPaginated: AuditLogConnection;
/** @deprecated Build packs are no longer supported */
availableBuilds?: Maybe<Scalars['Int']['output']>;
/** Billing information. Only visible to members with the ADMIN or OWNER role. */
billing?: Maybe<Billing>;
billingPeriod: BillingPeriod;
@@ -141,8 +137,6 @@ export type Account = {
googleServiceAccountKeys: Array<GoogleServiceAccountKey>;
/** Android credentials for account */
googleServiceAccountKeysPaginated: AccountGoogleServiceAccountKeysConnection;
/** @deprecated No longer needed */
hasBuilds: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
isCurrent: Scalars['Boolean']['output'];
isDisabled: Scalars['Boolean']['output'];
@@ -174,8 +168,6 @@ export type Account = {
profileImageUrl: Scalars['String']['output'];
pushSecurityEnabled: Scalars['Boolean']['output'];
requireTwoFactor: Scalars['Boolean']['output'];
/** @deprecated Legacy access tokens are deprecated */
requiresAccessTokenForPushSecurity: Scalars['Boolean']['output'];
sentryInstallation?: Maybe<SentryInstallation>;
/**
* Snacks associated with this account
@@ -190,20 +182,11 @@ export type Account = {
ssoConfiguration?: Maybe<AccountSsoConfiguration>;
/** Subscription info visible to members that have VIEWER role */
subscription?: Maybe<SubscriptionDetails>;
/** @deprecated No longer needed */
subscriptionChangesPending?: Maybe<Scalars['Boolean']['output']>;
/** Coalesced project activity for an app using pagination */
timelineActivity: TimelineActivityConnection;
/** @deprecated See isCurrent */
unlimitedBuilds: Scalars['Boolean']['output'];
updatedAt: Scalars['DateTime']['output'];
/** Account query object for querying EAS usage metrics */
usageMetrics: AccountUsageMetrics;
/**
* Owning UserActor of this account if personal account
* @deprecated Deprecated in favor of ownerUserActor
*/
userActorOwner?: Maybe<UserActor>;
/** Pending user invitations for this account */
userInvitations: Array<UserInvitation>;
userSpecifiedAccountUsage?: Maybe<UserSpecifiedAccountUsage>;
@@ -218,8 +201,6 @@ export type Account = {
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
/** Permission info for the viewer on this account */
viewerUserPermission: UserPermission;
/** @deprecated Build packs are no longer supported */
willAutoRenewBuilds?: Maybe<Scalars['Boolean']['output']>;
};
@@ -470,6 +451,7 @@ export type AccountGoogleServiceAccountKeysPaginatedArgs = {
export type AccountMembersPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AccountMembersFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
memberType?: InputMaybe<AccountMemberType>;
@@ -670,6 +652,18 @@ export type AccountMembersEdge = {
node: UserPermission;
};
export type AccountMembersFilterInput = {
/**
* Filter members by 2FA status. Only regular users are returned when set, since SSO users
* and robots don't have 2FA. Requires admin permission on the account.
*/
isSecondFactorAuthenticationEnabled?: InputMaybe<Scalars['Boolean']['input']>;
/** Filter members by role. Only OWNER, ADMIN, DEVELOPER, and VIEW_ONLY are supported. */
role?: InputMaybe<Role>;
/** Case-insensitive substring match on username, email, and name */
searchTerm?: InputMaybe<Scalars['String']['input']>;
};
export type AccountMutation = {
__typename?: 'AccountMutation';
/** Cancels all subscriptions immediately */
@@ -1047,20 +1041,6 @@ export type ActorExperimentMutationCreateOrUpdateActorExperimentArgs = {
experiment: Experiment;
};
export type ActorQuery = {
__typename?: 'ActorQuery';
/**
* Query an Actor by ID
* @deprecated Public actor queries are no longer supported
*/
byId: Actor;
};
export type ActorQueryByIdArgs = {
id: Scalars['ID']['input'];
};
export type AddonDetails = {
__typename?: 'AddonDetails';
id: Scalars['ID']['output'];
@@ -1071,15 +1051,6 @@ export type AddonDetails = {
willCancel?: Maybe<Scalars['Boolean']['output']>;
};
export type Address = {
__typename?: 'Address';
city?: Maybe<Scalars['String']['output']>;
country?: Maybe<Scalars['String']['output']>;
line1?: Maybe<Scalars['String']['output']>;
state?: Maybe<Scalars['String']['output']>;
zip?: Maybe<Scalars['String']['output']>;
};
export type AgentDeviceRunSessionRemoteConfig = {
__typename?: 'AgentDeviceRunSessionRemoteConfig';
agentDeviceRemoteSessionToken: Scalars['String']['output'];
@@ -1438,8 +1409,6 @@ export type AndroidSubmissionConfigInput = {
/** Represents an Exponent App (or Experience in legacy terms) */
export type App = Project & {
__typename?: 'App';
/** @deprecated Legacy access tokens are deprecated */
accessTokens: Array<Maybe<AccessToken>>;
/** Coalesced project activity for an app */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
/** Android app credentials for the project */
@@ -1458,11 +1427,6 @@ export type App = Project & {
/** (EAS Build) Builds associated with this app */
builds: Array<Build>;
buildsPaginated: AppBuildsConnection;
/**
* Classic update release channel names that have at least one build
* @deprecated Classic updates have been deprecated.
*/
buildsReleaseChannels: Array<Scalars['String']['output']>;
channelsPaginated: AppChannelsConnection;
convexProject?: Maybe<ConvexProject>;
deployment?: Maybe<Deployment>;
@@ -1504,15 +1468,11 @@ export type App = Project & {
internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy;
/** iOS app credentials for the project */
iosAppCredentials: Array<IosAppCredentials>;
/** @deprecated Use lastDeletionAttemptTime !== null instead */
isDeleting: Scalars['Boolean']['output'];
/**
* Whether the latest classic update publish is using a deprecated SDK version
* @deprecated Classic updates have been deprecated.
*/
isDeprecated: Scalars['Boolean']['output'];
/** @deprecated 'likes' have been deprecated. */
isLikedByMe: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
/** @deprecated No longer supported */
lastPublishedTime: Scalars['DateTime']['output'];
@@ -1521,23 +1481,12 @@ export type App = Project & {
latestAppVersionByPlatformAndApplicationIdentifier?: Maybe<AppVersion>;
/** @deprecated Classic updates have been deprecated. */
latestReleaseForReleaseChannel?: Maybe<AppRelease>;
/**
* ID of latest classic update release
* @deprecated Classic updates have been deprecated.
*/
latestReleaseId: Scalars['ID']['output'];
/** @deprecated 'likes' have been deprecated. */
likeCount: Scalars['Int']['output'];
/** @deprecated 'likes' have been deprecated. */
likedBy: Array<Maybe<User>>;
logRocketProject?: Maybe<LogRocketProject>;
name: Scalars['String']['output'];
observe: AppObserve;
ownerAccount: Account;
/** @deprecated No longer supported */
packageName: Scalars['String']['output'];
/** @deprecated No longer supported */
packageUsername: Scalars['String']['output'];
/**
* android.playStoreUrl field from most recent classic update manifest
* @deprecated Classic updates have been deprecated.
@@ -1557,11 +1506,6 @@ export type App = Project & {
/** App query field for querying details about an app's push notifications */
pushNotifications: AppPushNotifications;
pushSecurityEnabled: Scalars['Boolean']['output'];
/**
* Classic update release channel names (to be removed)
* @deprecated Classic updates have been deprecated.
*/
releaseChannels: Array<Scalars['String']['output']>;
/** @deprecated Legacy access tokens are deprecated */
requiresAccessTokenForPushSecurity: Scalars['Boolean']['output'];
resourceClassExperiment?: Maybe<ResourceClassExperiment>;
@@ -1581,8 +1525,6 @@ export type App = Project & {
suggestedDevDomainName: Scalars['String']['output'];
/** Coalesced project activity for an app using pagination */
timelineActivity: TimelineActivityConnection;
/** @deprecated 'likes' have been deprecated. */
trendScore: Scalars['Float']['output'];
turtleBrownfieldArtifactsPaginated: BrownfieldArtifactsConnection;
/** get an EAS branch owned by the app by name */
updateBranchByName?: Maybe<UpdateBranch>;
@@ -1607,8 +1549,6 @@ export type App = Project & {
usageMetrics: AppUsageMetrics;
/** @deprecated Use ownerAccount.name instead */
username: Scalars['String']['output'];
/** @deprecated No longer supported */
users?: Maybe<Array<Maybe<User>>>;
vexoApp?: Maybe<VexoApp>;
/** Notification preferences of the viewer for this app */
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
@@ -1775,13 +1715,6 @@ export type AppLatestReleaseForReleaseChannelArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppLikedByArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppProfileImageUrlArgs = {
width?: InputMaybe<AppProfileImageWidth>;
@@ -2106,8 +2039,6 @@ export type AppFingerprintsConnection = {
export type AppIcon = {
__typename?: 'AppIcon';
/** @deprecated No longer supported */
colorPalette?: Maybe<Scalars['JSON']['output']>;
originalUrl: Scalars['String']['output'];
primaryColor?: Maybe<Scalars['String']['output']>;
url: Scalars['String']['output'];
@@ -2155,8 +2086,6 @@ export type AppMutation = {
__typename?: 'AppMutation';
/** Create an app */
createApp: App;
/** @deprecated No longer supported */
grantAccess?: Maybe<App>;
/** Remove profile image (icon) for the app. Do nothing if there's no profile image associated. */
removeProfileImage: App;
/** Delete an App. Returns the ID of the background job receipt. Use BackgroundJobReceiptQuery to get the status of the job. */
@@ -2175,12 +2104,6 @@ export type AppMutationCreateAppArgs = {
};
export type AppMutationGrantAccessArgs = {
accessLevel?: InputMaybe<Scalars['String']['input']>;
toUser: Scalars['ID']['input'];
};
export type AppMutationRemoveProfileImageArgs = {
appId: Scalars['ID']['input'];
};
@@ -2272,6 +2195,7 @@ export type AppObserveCustomEventListArgs = {
filter?: InputMaybe<AppObserveCustomEventListFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveCustomEventListOrderBy>;
};
@@ -2532,6 +2456,15 @@ export type AppObserveCustomEventListFilter = {
startTime?: InputMaybe<Scalars['DateTime']['input']>;
};
export type AppObserveCustomEventListOrderBy = {
direction: AppObserveEventsOrderByDirection;
field: AppObserveCustomEventListOrderByField;
};
export enum AppObserveCustomEventListOrderByField {
Timestamp = 'TIMESTAMP'
}
export type AppObserveCustomEventName = {
__typename?: 'AppObserveCustomEventName';
count: Scalars['Int']['output'];
@@ -2594,10 +2527,14 @@ export type AppObserveErrorGroup = {
firstSeenAt: Scalars['DateTime']['output'];
isFatal: Scalars['Boolean']['output'];
lastSeenAt: Scalars['DateTime']['output'];
/** Per-platform occurrence and unique-user counts, sorted by eventCount descending. Covers every device OS seen (iOS, Android, iPadOS, ...), so the counts sum to the group totals. */
platformCounts: Array<AppObserveErrorGroupPlatformCount>;
/** Distinct device OS values seen for this group (e.g. Android, iOS). */
platforms: Array<Scalars['String']['output']>;
/** FATAL if any occurrence in the group was fatal, otherwise ERROR. */
severity: AppObserveErrorSeverity;
/** Occurrences over the query window, bucketed ascending. Sparse (empty buckets omitted); the client fills gaps. Renders as the list sparkline. Bucket width is set via AppObserveErrorGroupsInput.bucketIntervalMinutes. */
timeSeries: Array<AppObserveErrorTimeSeriesBucket>;
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2624,6 +2561,14 @@ export type AppObserveErrorGroupBreakdownInput = {
startTime: Scalars['DateTime']['input'];
};
export type AppObserveErrorGroupPlatformCount = {
__typename?: 'AppObserveErrorGroupPlatformCount';
eventCount: Scalars['Int']['output'];
/** Device OS value, e.g. iOS, Android, iPadOS. */
platform: Scalars['String']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
export type AppObserveErrorGroups = {
__typename?: 'AppObserveErrorGroups';
groups: Array<AppObserveErrorGroup>;
@@ -2636,6 +2581,8 @@ export type AppObserveErrorGroupsInput = {
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
/** Bucket width in minutes for the per-group timeSeries field. Only used when timeSeries is selected. Defaults to 1440 (daily). */
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
/** Restrict to a single group, e.g. to fetch one group's header on the detail page. */
@@ -2702,6 +2649,8 @@ export type AppObserveErrorTimeSeries = {
export type AppObserveErrorTimeSeriesBucket = {
__typename?: 'AppObserveErrorTimeSeriesBucket';
/** Approximate count of unique affected users in this bucket. Not summable across buckets. */
affectedUsers: Scalars['Int']['output'];
/** Start of the bucket interval. */
bucket: Scalars['DateTime']['output'];
fatalCount: Scalars['Int']['output'];
@@ -4127,8 +4076,6 @@ export type Billing = {
/** History of invoices */
charges?: Maybe<Array<Maybe<Charge>>>;
id: Scalars['ID']['output'];
/** @deprecated No longer used */
payment?: Maybe<PaymentDetails>;
subscription?: Maybe<SubscriptionDetails>;
};
@@ -4513,11 +4460,6 @@ export type BuildMutation = {
deleteBuild: Build;
/** Retry an Android EAS Build */
retryAndroidBuild: Build;
/**
* Retry an EAS Build build
* @deprecated Use retryAndroidBuild and retryIosBuild instead
*/
retryBuild: Build;
/** Retry an iOS EAS Build */
retryIosBuild: Build;
/** Update metadata for EAS Build build */
@@ -4565,11 +4507,6 @@ export type BuildMutationRetryAndroidBuildArgs = {
};
export type BuildMutationRetryBuildArgs = {
buildId: Scalars['ID']['input'];
};
export type BuildMutationRetryIosBuildArgs = {
buildId: Scalars['ID']['input'];
jobOverrides?: InputMaybe<IosJobOverridesInput>;
@@ -4766,13 +4703,18 @@ export enum BuildWorkflow {
Unknown = 'UNKNOWN'
}
export type Card = {
__typename?: 'Card';
brand?: Maybe<Scalars['String']['output']>;
cardHolder?: Maybe<Scalars['String']['output']>;
expMonth?: Maybe<Scalars['Int']['output']>;
expYear?: Maybe<Scalars['Int']['output']>;
last4?: Maybe<Scalars['String']['output']>;
export type ChannelBuildOrEmbeddedUpdate = Build | EmbeddedUpdate;
export type ChannelBuildOrEmbeddedUpdateEdge = {
__typename?: 'ChannelBuildOrEmbeddedUpdateEdge';
cursor: Scalars['String']['output'];
node: ChannelBuildOrEmbeddedUpdate;
};
export type ChannelBuildsAndEmbeddedUpdatesConnection = {
__typename?: 'ChannelBuildsAndEmbeddedUpdatesConnection';
edges: Array<ChannelBuildOrEmbeddedUpdateEdge>;
pageInfo: PageInfo;
};
export type ChannelFilterInput = {
@@ -4820,6 +4762,15 @@ export type Charge = {
wasRefunded: Scalars['Boolean']['output'];
};
/** A human user's chat token usage over the trailing 30 days, with their fair-usage limit. */
export type ChatTokenUsage = {
__typename?: 'ChatTokenUsage';
isLimitExceeded: Scalars['Boolean']['output'];
/** Null when the user's plan has no chat token limit. */
limitTokens?: Maybe<Scalars['Int']['output']>;
usedTokens: Scalars['Int']['output'];
};
export type CodeSigningInfo = {
__typename?: 'CodeSigningInfo';
alg: Scalars['String']['output'];
@@ -5000,6 +4951,7 @@ export type CreateConvexTeamConnectionInput = {
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
filename: Scalars['String']['input'];
kind?: InputMaybe<Scalars['String']['input']>;
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
name: Scalars['String']['input'];
size: Scalars['Int']['input'];
@@ -5189,6 +5141,14 @@ export type CreatePostHogAccountRequestInput = {
region: PostHogRegion;
};
export type CreatePostHogDeepLinkInput = {
/** When set, deep-links to this app's linked PostHog project; otherwise lands on the organization home. */
appId?: InputMaybe<Scalars['ID']['input']>;
posthogOrganizationConnectionId: Scalars['ID']['input'];
/** Which EAS surface opened the link; omitted lets PostHog apply its own default. */
purpose?: InputMaybe<PostHogDeepLinkPurpose>;
};
export type CreateSentryProjectInput = {
appId: Scalars['ID']['input'];
sentryProjectId: Scalars['String']['input'];
@@ -5664,6 +5624,7 @@ export type DeviceRunSessionArtifact = {
fileSizeBytes?: Maybe<Scalars['Float']['output']>;
filename: Scalars['String']['output'];
id: Scalars['ID']['output'];
kind?: Maybe<Scalars['String']['output']>;
metadata?: Maybe<Scalars['JSONObject']['output']>;
name: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
@@ -5693,7 +5654,7 @@ export type DeviceRunSessionFilterInput = {
export type DeviceRunSessionMutation = {
__typename?: 'DeviceRunSessionMutation';
/** Create an upload session for an artifact produced by an active device run session */
/** Create an upload session for an artifact while the backing job run is not in a final state */
createArtifactUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
/** Create a device run session */
createDeviceRunSession: DeviceRunSession;
@@ -5812,6 +5773,7 @@ export enum EasService {
Builds = 'BUILDS',
Jobs = 'JOBS',
Mcp = 'MCP',
Observe = 'OBSERVE',
Updates = 'UPDATES'
}
@@ -5823,6 +5785,7 @@ export enum EasServiceMetric {
LocalBuilds = 'LOCAL_BUILDS',
ManifestRequests = 'MANIFEST_REQUESTS',
McpRequests = 'MCP_REQUESTS',
ObserveEvents = 'OBSERVE_EVENTS',
RunTime = 'RUN_TIME',
UniqueUpdaters = 'UNIQUE_UPDATERS',
UniqueUsers = 'UNIQUE_USERS'
@@ -5838,6 +5801,7 @@ export enum EasTotalPlanEnablementUnit {
Build = 'BUILD',
Byte = 'BYTE',
Concurrency = 'CONCURRENCY',
Event = 'EVENT',
Request = 'REQUEST',
Updater = 'UPDATER',
User = 'USER'
@@ -6693,8 +6657,6 @@ export enum EnvironmentSecretType {
export type EnvironmentVariable = {
__typename?: 'EnvironmentVariable';
/** @deprecated Environment variables are automatically linked to all apps */
apps: Array<App>;
createdAt: Scalars['DateTime']['output'];
environments?: Maybe<Array<Scalars['EnvironmentVariableEnvironment']['output']>>;
fileName?: Maybe<Scalars['String']['output']>;
@@ -7727,8 +7689,6 @@ export type InvoiceQueryPreviewInvoiceForSubscriptionUpdateArgs = {
export type IosAppBuildCredentials = {
__typename?: 'IosAppBuildCredentials';
/** @deprecated Get Apple Devices from AppleProvisioningProfile instead */
appleDevices?: Maybe<Array<Maybe<AppleDevice>>>;
distributionCertificate?: Maybe<AppleDistributionCertificate>;
id: Scalars['ID']['output'];
iosAppCredentials: IosAppCredentials;
@@ -8001,8 +7961,6 @@ export type JobRun = {
__typename?: 'JobRun';
app: App;
artifacts: Array<WorkflowArtifact>;
/** @deprecated No longer supported */
childJobRun?: Maybe<JobRun>;
createdAt: Scalars['DateTime']['output'];
displayName?: Maybe<Scalars['String']['output']>;
endedAt?: Maybe<Scalars['DateTime']['output']>;
@@ -8248,6 +8206,8 @@ export type MeMutation = {
leaveAccount: LeaveAccountResult;
/** Purge unfinished two-factor authentication setup for the current user if not fully-set-up */
purgeUnfinishedSecondFactorAuthentication: SecondFactorBooleanResult;
/** Record chat token usage for the current user. */
recordChatTokenUsage: ChatTokenUsage;
/** Regenerate backup codes for the current user */
regenerateSecondFactorBackupCodes: SecondFactorRegenerateBackupCodesResult;
/** Schedule deletion for Account created via createAccount */
@@ -8256,11 +8216,6 @@ export type MeMutation = {
scheduleCurrentUserDeletion: BackgroundJobReceipt;
/** Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. */
scheduleSSOUserDeletionAsSSOAccountOwner: BackgroundJobReceipt;
/**
* Send SMS OTP to a second factor device for use during device setup or during change confirmation
* @deprecated This method is no longer supported.
*/
sendSMSOTPToSecondFactorDevice: SecondFactorBooleanResult;
/**
* Sets user preferences. This is a key-value store for user-specific settings. Provided values are
* key-level merged with existing values.
@@ -8322,6 +8277,12 @@ export type MeMutationLeaveAccountArgs = {
};
export type MeMutationRecordChatTokenUsageArgs = {
inputTokens: Scalars['Int']['input'];
outputTokens: Scalars['Int']['input'];
};
export type MeMutationRegenerateSecondFactorBackupCodesArgs = {
otp?: InputMaybe<Scalars['String']['input']>;
};
@@ -8337,11 +8298,6 @@ export type MeMutationScheduleSsoUserDeletionAsSsoAccountOwnerArgs = {
};
export type MeMutationSendSmsotpToSecondFactorDeviceArgs = {
userSecondFactorDeviceId: Scalars['ID']['input'];
};
export type MeMutationSetPreferencesArgs = {
preferences: UserPreferencesInput;
};
@@ -8557,13 +8513,6 @@ export type PartnerActorFeatureGatesArgs = {
filter?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type PaymentDetails = {
__typename?: 'PaymentDetails';
address?: Maybe<Address>;
card?: Maybe<Card>;
id: Scalars['ID']['output'];
};
export type PendingSentryInstallation = {
__typename?: 'PendingSentryInstallation';
account: Account;
@@ -8588,6 +8537,20 @@ export type PinnedDashboardView = {
export type PlanEnablement = Concurrencies | EasTotalPlanEnablement;
/** A one-time, signed-in PostHog link. Expires in 10 minutes and opens once. */
export type PostHogDeepLink = {
__typename?: 'PostHogDeepLink';
expiresAt: Scalars['DateTime']['output'];
url: Scalars['String']['output'];
};
/** Which EAS surface a deep link was opened from; PostHog records it as an analytics label. */
export enum PostHogDeepLinkPurpose {
Dashboard = 'DASHBOARD',
Observability = 'OBSERVABILITY',
Project = 'PROJECT'
}
export type PostHogIntegrationQuery = {
__typename?: 'PostHogIntegrationQuery';
clientIdentifier: Scalars['String']['output'];
@@ -8613,6 +8576,11 @@ export type PostHogOrganizationConnectionMutation = {
*/
completePostHogConnection: PostHogOrganizationConnection;
createPostHogAccountRequest: PostHogOrganizationConnection;
/**
* Mints a one-time, signed-in PostHog link. The URL expires in 10 minutes and can be
* opened once, so request it at click time rather than pre-rendering it.
*/
createPostHogDeepLink: PostHogDeepLink;
/** Removes the Expo-side connection only; the PostHog organization is preserved. */
deletePostHogOrganizationConnection: Scalars['ID']['output'];
/**
@@ -8633,6 +8601,11 @@ export type PostHogOrganizationConnectionMutationCreatePostHogAccountRequestArgs
};
export type PostHogOrganizationConnectionMutationCreatePostHogDeepLinkArgs = {
input: CreatePostHogDeepLinkInput;
};
export type PostHogOrganizationConnectionMutationDeletePostHogOrganizationConnectionArgs = {
id: Scalars['ID']['input'];
};
@@ -9111,11 +9084,6 @@ export type RootQuery = {
account: AccountQuery;
/** Top-level query object for querying AccountSSOConfigurationPublicData */
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
/**
* Top-level query object for querying Actors.
* @deprecated Public actor queries are no longer supported
*/
actor: ActorQuery;
/**
* Public apps in the app directory
* @deprecated Use 'all' field under 'app'.
@@ -9214,11 +9182,6 @@ export type RootQuery = {
* @deprecated Public user queries are no longer supported
*/
user: UserQuery;
/**
* Top-level query object for querying UserActors.
* @deprecated Public user queries are no longer supported
*/
userActor: UserActorQuery;
/** Top-level query object for querying UserActorPublicData publicly. */
userActorPublicData: UserActorPublicDataQuery;
/** Top-level query object for querying User Audit Logs. */
@@ -9238,6 +9201,8 @@ export type RootQuery = {
* query object
*/
viewer?: Maybe<User>;
/** The current user's chat token usage and limit. */
viewerChatTokenUsage: ChatTokenUsage;
/** Top-level query object for querying Webhooks. */
webhook: WebhookQuery;
workerDeployment: WorkerDeploymentQuery;
@@ -9283,6 +9248,7 @@ export type Runtime = {
builds: AppBuildsConnection;
createdAt: Scalars['DateTime']['output'];
deployments: DeploymentsConnection;
embeddedUpdateCount: Scalars['Int']['output'];
fingerprint?: Maybe<Fingerprint>;
firstBuildCreatedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
@@ -9317,6 +9283,11 @@ export type RuntimeDeploymentsArgs = {
};
export type RuntimeEmbeddedUpdateCountArgs = {
channel?: InputMaybe<Scalars['String']['input']>;
};
export type RuntimeUpdatesArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
@@ -9388,8 +9359,6 @@ export type SsoUser = Actor & UserActor & {
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/**
* Apps this user has published. If this user is the viewer, this field returns the apps the user has access to.
* @deprecated Use Account.appsPaginated instead
@@ -9411,17 +9380,11 @@ export type SsoUser = Actor & UserActor & {
fullName?: Maybe<Scalars['String']['output']>;
/** GitHub account linked to a user */
githubUser?: Maybe<GitHubUser>;
/** @deprecated No longer supported */
githubUsername?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
pinnedApps: Array<App>;
pinnedDashboardViews: Array<PinnedDashboardView>;
preferences: UserPreferences;
@@ -9432,8 +9395,6 @@ export type SsoUser = Actor & UserActor & {
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this account */
snacks: Array<Snack>;
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']['output']>;
username: Scalars['String']['output'];
websiteNotificationsPaginated: WebsiteNotificationsConnection;
};
@@ -9945,6 +9906,7 @@ export type SubscriptionDetails = {
name?: Maybe<Scalars['String']['output']>;
nextInvoice?: Maybe<Scalars['DateTime']['output']>;
nextInvoiceAmountDueCents?: Maybe<Scalars['Int']['output']>;
paymentFailedAt?: Maybe<Scalars['DateTime']['output']>;
planEnablement?: Maybe<PlanEnablement>;
planId?: Maybe<Scalars['String']['output']>;
price: Scalars['Int']['output'];
@@ -10194,7 +10156,10 @@ export type UpdateChannel = {
app: App;
appId: Scalars['ID']['output'];
branchMapping: Scalars['String']['output'];
buildCount: Scalars['Int']['output'];
buildsAndEmbeddedUpdatesPaginated: ChannelBuildsAndEmbeddedUpdatesConnection;
createdAt: Scalars['DateTime']['output'];
embeddedUpdateCount: Scalars['Int']['output'];
id: Scalars['ID']['output'];
isPaused: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
@@ -10206,6 +10171,14 @@ export type UpdateChannel = {
};
export type UpdateChannelBuildsAndEmbeddedUpdatesPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
export type UpdateChannelLatestRuntimesArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
@@ -10696,6 +10669,7 @@ export enum UsageMetricType {
Bandwidth = 'BANDWIDTH',
Build = 'BUILD',
Credit = 'CREDIT',
Event = 'EVENT',
Minute = 'MINUTE',
Request = 'REQUEST',
Update = 'UPDATE',
@@ -10723,8 +10697,6 @@ export type User = Actor & UserActor & {
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/**
* Apps this user has published
* @deprecated Use Account.appsPaginated instead
@@ -10748,14 +10720,10 @@ export type User = Actor & UserActor & {
fullName?: Maybe<Scalars['String']['output']>;
/** GitHub account linked to a user */
githubUser?: Maybe<GitHubUser>;
/** @deprecated No longer supported */
githubUsername?: Maybe<Scalars['String']['output']>;
hasPassword: Scalars['Boolean']['output'];
/** Whether this user has any pending user invitations. Only resolves for the viewer. */
hasPendingUserInvitations: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
/** @deprecated No longer supported */
isLegacy: Scalars['Boolean']['output'];
@@ -10763,8 +10731,6 @@ export type User = Actor & UserActor & {
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
newEmailPendingVerification?: Maybe<Scalars['String']['output']>;
oAuthIdentities: Array<OAuthIdentity>;
/** Registered passkey credentials */
@@ -10783,8 +10749,6 @@ export type User = Actor & UserActor & {
secondFactorDevices: Array<UserSecondFactorDevice>;
/** Snacks associated with this account */
snacks: Array<Snack>;
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']['output']>;
username: Scalars['String']['output'];
websiteNotificationsPaginated: WebsiteNotificationsConnection;
};
@@ -10838,8 +10802,6 @@ export type UserActor = {
*/
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/**
* Apps this user has published
* @deprecated Use Account.appsPaginated instead
@@ -10865,17 +10827,11 @@ export type UserActor = {
fullName?: Maybe<Scalars['String']['output']>;
/** GitHub account linked to a user */
githubUser?: Maybe<GitHubUser>;
/** @deprecated No longer supported */
githubUsername?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
pinnedApps: Array<App>;
preferences: UserPreferences;
/** Associated accounts */
@@ -10885,8 +10841,6 @@ export type UserActor = {
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']['output']>;
username: Scalars['String']['output'];
websiteNotificationsPaginated: WebsiteNotificationsConnection;
};
@@ -10961,30 +10915,6 @@ export type UserActorPublicDataQueryByUsernameArgs = {
username: Scalars['String']['input'];
};
export type UserActorQuery = {
__typename?: 'UserActorQuery';
/**
* Query a UserActor by ID
* @deprecated Public user actor queries are no longer supported
*/
byId: UserActor;
/**
* Query a UserActor by username
* @deprecated Public user actor queries are no longer supported
*/
byUsername: UserActor;
};
export type UserActorQueryByIdArgs = {
id: Scalars['ID']['input'];
};
export type UserActorQueryByUsernameArgs = {
username: Scalars['String']['input'];
};
export enum UserAgentBrowser {
AndroidMobile = 'ANDROID_MOBILE',
Chrome = 'CHROME',
@@ -11047,8 +10977,6 @@ export type UserAuditLog = {
targetEntityMutationType: TargetEntityMutationType;
targetEntityTypeName: UserEntityTypeName;
targetEntityTypePublicName: Scalars['String']['output'];
/** @deprecated Use userActor instead */
user: User;
userActor: UserActor;
websiteMessage: Scalars['String']['output'];
};
@@ -11161,11 +11089,6 @@ export type UserInvitation = {
accountName: Scalars['String']['output'];
/** The profile image URL of the account owner */
accountProfileImageUrl: Scalars['String']['output'];
/**
* If the invite is for a personal team, the profile photo of account owner
* @deprecated Use accountProfileImageUrl
*/
accountProfilePhoto?: Maybe<Scalars['String']['output']>;
/** Whether the account requires two-factor authentication */
accountRequiresTwoFactor: Scalars['Boolean']['output'];
created: Scalars['DateTime']['output'];
@@ -11354,12 +11277,12 @@ export type UserPreferenceQueryGetArgs = {
export type UserPreferences = {
__typename?: 'UserPreferences';
onboarding?: Maybe<UserPreferencesOnboarding>;
/** @deprecated Use userPreference.get(key: "selected-account-id") instead. */
selectedAccountName?: Maybe<Scalars['String']['output']>;
};
export type UserPreferencesInput = {
onboarding?: InputMaybe<UserPreferencesOnboardingInput>;
selectedAccountName?: InputMaybe<Scalars['String']['input']>;
};
/**
@@ -11395,11 +11318,6 @@ export type UserPreferencesOnboardingInput = {
export type UserQuery = {
__typename?: 'UserQuery';
/**
* Query a User by ID
* @deprecated Public user queries are no longer supported
*/
byId: User;
/**
* Query a User by username
* @deprecated Public user queries are no longer supported
@@ -11408,11 +11326,6 @@ export type UserQuery = {
};
export type UserQueryByIdArgs = {
userId: Scalars['ID']['input'];
};
export type UserQueryByUsernameArgs = {
username: Scalars['String']['input'];
};
@@ -12405,6 +12318,24 @@ export type WorkflowDeviceTestCaseWorkflowFacet = {
name: Scalars['String']['output'];
};
export type WorkflowDispatchInput = {
__typename?: 'WorkflowDispatchInput';
default?: Maybe<Scalars['JSON']['output']>;
description?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
options?: Maybe<Array<Scalars['String']['output']>>;
required: Scalars['Boolean']['output'];
type: WorkflowDispatchInputType;
};
export enum WorkflowDispatchInputType {
Boolean = 'BOOLEAN',
Choice = 'CHOICE',
Environment = 'ENVIRONMENT',
Number = 'NUMBER',
String = 'STRING'
}
export type WorkflowJob = {
__typename?: 'WorkflowJob';
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
@@ -12566,6 +12497,7 @@ export type WorkflowRevision = {
commitSha?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
inputs: Array<WorkflowDispatchInput>;
workflow: Workflow;
yamlConfig: Scalars['String']['output'];
};
@@ -13632,6 +13564,13 @@ export type DeletePostHogProjectMutationVariables = Exact<{
export type DeletePostHogProjectMutation = { __typename?: 'RootMutation', posthogProject: { __typename?: 'PostHogProjectMutation', deletePostHogProject: string } };
export type CreatePostHogDeepLinkMutationVariables = Exact<{
input: CreatePostHogDeepLinkInput;
}>;
export type CreatePostHogDeepLinkMutation = { __typename?: 'RootMutation', posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnectionMutation', createPostHogDeepLink: { __typename?: 'PostHogDeepLink', url: string } } };
export type GetSignedUploadMutationVariables = Exact<{
contentTypes: Array<Scalars['String']['input']> | Scalars['String']['input'];
}>;
@@ -13698,13 +13637,6 @@ export type CreateAccountScopedUploadSessionMutationVariables = Exact<{
export type CreateAccountScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAccountScopedUploadSession: any } };
export type MarkCliDoneInOnboardingUserPreferencesMutationVariables = Exact<{
preferences: UserPreferencesInput;
}>;
export type MarkCliDoneInOnboardingUserPreferencesMutation = { __typename?: 'RootMutation', me: { __typename?: 'MeMutation', setPreferences: { __typename?: 'UserPreferences', onboarding?: { __typename?: 'UserPreferencesOnboarding', appId: string, isCLIDone?: boolean | null } | null } } };
export type CreateWebhookMutationVariables = Exact<{
appId: Scalars['String']['input'];
webhookInput: WebhookInput;
@@ -14046,7 +13978,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: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, 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, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -14333,7 +14265,7 @@ export type UpdateByIdQuery = { __typename?: 'RootQuery', updates: { __typename?
export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type CurrentUserQuery = { __typename?: 'RootQuery', meActor?: { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, preferences: { __typename?: 'UserPreferences', onboarding?: { __typename?: 'UserPreferencesOnboarding', appId: string, platform?: AppPlatform | null, deviceType?: OnboardingDeviceType | null, environment?: OnboardingEnvironment | null, isCLIDone?: boolean | null, lastUsed: string } | null }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, preferences: { __typename?: 'UserPreferences', onboarding?: { __typename?: 'UserPreferencesOnboarding', appId: string, platform?: AppPlatform | null, deviceType?: OnboardingDeviceType | null, environment?: OnboardingEnvironment | null, isCLIDone?: boolean | null, lastUsed: string } | null }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | null };
export type CurrentUserQuery = { __typename?: 'RootQuery', meActor?: { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }> } | null };
export type WebhooksByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -3,7 +3,11 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { CreatePostHogAccountRequestInput, SetupPostHogProjectInput } from '../generated';
import {
CreatePostHogAccountRequestInput,
CreatePostHogDeepLinkInput,
SetupPostHogProjectInput,
} from '../generated';
import {
PostHogOrganizationConnectionFragmentNode,
PostHogProjectData,
@@ -41,6 +45,16 @@ type DeletePostHogProjectMutationVariables = {
id: string;
};
type CreatePostHogDeepLinkMutation = {
posthogOrganizationConnection: {
createPostHogDeepLink: { url: string };
};
};
type CreatePostHogDeepLinkMutationVariables = {
input: CreatePostHogDeepLinkInput;
};
export const PostHogMutation = {
async startPostHogConnectionAsync(
graphqlClient: ExpoGraphqlClient,
@@ -118,4 +132,27 @@ export const PostHogMutation = {
);
return data.posthogProject.deletePostHogProject;
},
async createPostHogDeepLinkAsync(
graphqlClient: ExpoGraphqlClient,
input: CreatePostHogDeepLinkInput
): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreatePostHogDeepLinkMutation, CreatePostHogDeepLinkMutationVariables>(
gql`
mutation CreatePostHogDeepLink($input: CreatePostHogDeepLinkInput!) {
posthogOrganizationConnection {
createPostHogDeepLink(input: $input) {
url
}
}
}
`,
{ input }
)
.toPromise()
);
return data.posthogOrganizationConnection.createPostHogDeepLink.url;
},
};
@@ -1,59 +0,0 @@
import assert from 'assert';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
MarkCliDoneInOnboardingUserPreferencesMutation,
MarkCliDoneInOnboardingUserPreferencesMutationVariables,
UserPreferencesOnboardingInput,
} from '../generated';
export const UserPreferencesMutation = {
async markCliDoneInOnboardingUserPreferencesAsync(
graphqlClient: ExpoGraphqlClient,
userPreferencesData: Partial<UserPreferencesOnboardingInput> & { appId: string }
): Promise<{
isCLIDone: boolean;
appId: string;
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
MarkCliDoneInOnboardingUserPreferencesMutation,
MarkCliDoneInOnboardingUserPreferencesMutationVariables
>(
gql`
mutation MarkCliDoneInOnboardingUserPreferencesMutation($preferences: UserPreferencesInput!) {
me {
setPreferences(preferences: $preferences) {
onboarding {
appId
isCLIDone
}
}
}
}
`,
{
preferences: {
onboarding: {
...userPreferencesData,
isCLIDone: true,
lastUsed: new Date().toISOString(),
},
},
}
)
.toPromise()
);
const appId = data.me.setPreferences.onboarding?.appId;
assert(appId, 'App ID must be defined');
const isCLIDone = data.me.setPreferences.onboarding?.isCLIDone;
assert(isCLIDone, 'isCLIDone must be defined and true');
return {
appId,
isCLIDone,
};
},
};
@@ -25,6 +25,11 @@ export const DeviceRunSessionQuery = {
id
status
type
platform
createdAt
startedAt
finishedAt
updatedAt
app {
id
slug
@@ -33,6 +38,16 @@ export const DeviceRunSessionQuery = {
name
}
}
artifacts {
id
name
filename
downloadUrl
fileSizeBytes
metadata
createdAt
updatedAt
}
remoteConfig {
__typename
... on AgentDeviceRunSessionRemoteConfig {
@@ -25,16 +25,6 @@ export const UserQuery = {
id
...AccountFragment
}
preferences {
onboarding {
appId
platform
deviceType
environment
isCLIDone
lastUsed
}
}
}
... on Robot {
firstName
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`resolveMetricName lists both app-startup and navigation aliases in the error message on unknown alias 1`] = `"Unknown metric: "bogus". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: nav_cold_ttr, nav_warm_ttr, nav_tti, tti, ttr, cold_launch, warm_launch, bundle_load, update_download"`;
@@ -43,12 +43,32 @@ describe(resolveMetricName, () => {
it('passes through dot-containing custom metric names', () => {
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
});
it('resolves navigation short aliases so they can be passed on the observe:metrics command line', () => {
expect(resolveMetricName('nav_cold_ttr')).toBe('expo.navigation.cold_ttr');
expect(resolveMetricName('nav_warm_ttr')).toBe('expo.navigation.warm_ttr');
expect(resolveMetricName('nav_tti')).toBe('expo.navigation.tti');
});
it('passes through navigation full metric names unchanged', () => {
expect(resolveMetricName('expo.navigation.cold_ttr')).toBe('expo.navigation.cold_ttr');
expect(resolveMetricName('expo.navigation.warm_ttr')).toBe('expo.navigation.warm_ttr');
expect(resolveMetricName('expo.navigation.tti')).toBe('expo.navigation.tti');
});
it('lists both app-startup and navigation aliases in the error message on unknown alias', () => {
try {
resolveMetricName('bogus');
} catch (e: any) {
expect(e.message).toMatchSnapshot();
}
});
});
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_cold_ttr')).toBe('expo.navigation.cold_ttr');
expect(resolveNavigationMetricName('nav_warm_ttr')).toBe('expo.navigation.warm_ttr');
expect(resolveNavigationMetricName('nav_tti')).toBe('expo.navigation.tti');
});
+7 -6
View File
@@ -1,6 +1,13 @@
import { EasCommandError } from '../commandUtils/errors';
export const NAVIGATION_METRIC_ALIASES: Record<string, string> = {
nav_cold_ttr: 'expo.navigation.cold_ttr',
nav_warm_ttr: 'expo.navigation.warm_ttr',
nav_tti: 'expo.navigation.tti',
};
export const METRIC_ALIASES: Record<string, string> = {
...NAVIGATION_METRIC_ALIASES,
tti: 'expo.app_startup.tti',
ttr: 'expo.app_startup.ttr',
cold_launch: 'expo.app_startup.cold_launch_time',
@@ -9,12 +16,6 @@ export const METRIC_ALIASES: Record<string, string> = {
update_download: 'expo.updates.download_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));
@@ -29,6 +29,7 @@ export async function ensureApplicationIdIsDefinedForManagedProjectAsync({
exp,
vcsClient,
nonInteractive,
autoSelectDefault = false,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
@@ -36,6 +37,7 @@ export async function ensureApplicationIdIsDefinedForManagedProjectAsync({
exp: ExpoConfig;
vcsClient: Client;
nonInteractive: boolean;
autoSelectDefault?: boolean;
}): Promise<string> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient);
assert(workflow === Workflow.MANAGED, 'This function should be called only for managed projects');
@@ -51,6 +53,7 @@ export async function ensureApplicationIdIsDefinedForManagedProjectAsync({
projectId,
exp,
nonInteractive,
autoSelectDefault,
});
}
}
@@ -141,12 +144,14 @@ async function configureApplicationIdAsync({
projectId,
exp,
nonInteractive,
autoSelectDefault = false,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
exp: ExpoConfig;
nonInteractive: boolean;
autoSelectDefault?: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
@@ -166,25 +171,31 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
let packageName: string;
if (autoSelectDefault && suggestedAndroidApplicationId) {
packageName = suggestedAndroidApplicationId;
Log.withTick(`Set "android.package" to ${chalk.bold(packageName)} (modified app.json)`);
} else {
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
({ packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
}));
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -23,6 +23,7 @@ export async function ensureBundleIdentifierIsDefinedForManagedProjectAsync({
exp,
vcsClient,
nonInteractive,
autoSelectDefault = false,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
@@ -30,6 +31,7 @@ export async function ensureBundleIdentifierIsDefinedForManagedProjectAsync({
exp: ExpoConfig;
vcsClient: Client;
nonInteractive: boolean;
autoSelectDefault?: boolean;
}): Promise<string> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient);
assert(workflow === Workflow.MANAGED, 'This function should be called only for managed projects');
@@ -43,6 +45,7 @@ export async function ensureBundleIdentifierIsDefinedForManagedProjectAsync({
exp,
projectId,
nonInteractive,
autoSelectDefault,
});
}
}
@@ -130,12 +133,14 @@ async function configureBundleIdentifierAsync({
projectId,
exp,
nonInteractive,
autoSelectDefault = false,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
exp: ExpoConfig;
nonInteractive: boolean;
autoSelectDefault?: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
@@ -155,26 +160,34 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
let bundleIdentifier: string;
if (autoSelectDefault && suggestedBundleIdentifier) {
bundleIdentifier = suggestedBundleIdentifier;
Log.withTick(
`Set "ios.bundleIdentifier" to ${chalk.bold(bundleIdentifier)} (modified app.json)`
);
} else {
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
({ bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
}));
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -0,0 +1,387 @@
import { ConfigError, ExpoConfig, getProjectConfigDescription } from '@expo/config';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from './expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from './fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { getProjectDashboardUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { validSlugName, validateFullNameAndSlug } from '../commandUtils/projectNameValidation';
import { Role } from '../graphql/generated';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { link } from '../log';
import { ora } from '../ora';
import { Choice, confirmAsync, promptAsync } from '../prompts';
import { Actor } from '../user/User';
export type InitializeMethodOptions = {
force: boolean;
nonInteractive: boolean;
};
async function saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
): Promise<void> {
await saveProjectIdToAppConfigAsync(projectDir, projectId);
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
}
async function modifyExpoConfigAsync(
projectDir: string,
modifications: Partial<ExpoConfig>
): Promise<void> {
let result;
try {
result = await createOrModifyExpoConfigAsync(projectDir, modifications);
} catch (error) {
if (error instanceof ConfigError && error.code === 'MODULE_NOT_FOUND') {
Log.warn(
'Cannot determine which native SDK version your project uses because the module `expo` is not installed.'
);
return;
} else {
throw error;
}
}
switch (result.type) {
case 'success':
break;
case 'warn': {
Log.warn();
Log.warn(
`Warning: Your project uses dynamic app configuration, and cannot be automatically modified.`
);
Log.warn(
chalk.dim(
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
)
);
Log.warn();
Log.warn(
`To complete the setup process, add the following in your ${chalk.bold(
getProjectConfigDescription(projectDir)
)}:`
);
Log.warn();
Log.warn(chalk.bold(JSON.stringify(modifications, null, 2)));
Log.warn();
throw new Error(result.message);
}
case 'fail':
throw new Error(result.message);
default:
throw new Error('Unexpected result type from modifyConfigAsync');
}
}
export async function ensureOwnerSlugConsistencyAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
const correctOwner = appForProjectId.ownerAccount.name;
const correctSlug = appForProjectId.slug;
if (exp.owner && exp.owner !== correctOwner) {
if (force) {
await modifyExpoConfigAsync(projectDir, { owner: correctOwner });
} else {
const message = `Project owner (${correctOwner}) does not match the value configured in the "owner" field (${exp.owner}).`;
if (nonInteractive) {
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
}
const confirm = await confirmAsync({
message: `${message}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await modifyExpoConfigAsync(projectDir, { owner: correctOwner });
}
} else if (!exp.owner) {
await modifyExpoConfigAsync(projectDir, { owner: correctOwner });
}
if (exp.slug && exp.slug !== correctSlug) {
if (force) {
await modifyExpoConfigAsync(projectDir, { slug: correctSlug });
} else {
const message = `Project slug (${correctSlug}) does not match the value configured in the "slug" field (${exp.slug}).`;
if (nonInteractive) {
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
}
const confirm = await confirmAsync({
message: `${message}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await modifyExpoConfigAsync(projectDir, { slug: correctSlug });
}
} else if (!exp.slug) {
await modifyExpoConfigAsync(projectDir, { slug: correctSlug });
}
}
async function setExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (projectId === existingProjectId) {
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
return;
}
if (!existingProjectId) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (projectId !== existingProjectId) {
if (force) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
}
}
export async function initializeWithExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
await setExplicitIDAsync(projectId, projectDir, {
force,
nonInteractive,
});
}
export async function initializeWithoutExplicitIDAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<string> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (existingProjectId) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return existingProjectId;
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
// if no owner field, ask the user which account they want to use to create/link the project
let accountName = exp.owner;
if (!accountName) {
if (allAccounts.length === 1) {
accountName = allAccounts[0].name;
} else if (nonInteractive) {
if (!force) {
throw new Error(
`There are multiple accounts that you have access to: ${allAccounts
.map(a => a.name)
.join(
', '
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
allAccounts[0].name
}.`
);
}
accountName = allAccounts[0].name;
Log.log(`Using default account ${accountName} for non-interactive and force mode`);
} else {
const choices = getAccountChoices(
actor,
accountNamesWhereUserHasSufficientPermissionsToCreateApp
);
accountName = (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
}
if (!accountName) {
throw new Error('No account selected for project. Canceling.');
}
const projectName = validSlugName(exp.slug); // This filters out invalid characters
const projectFullName = `@${accountName}/${projectName}`;
validateFullNameAndSlug(projectFullName, projectName);
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
if (existingProjectIdOnServer) {
if (!force) {
if (nonInteractive) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
);
}
const affirmedLink = await confirmAsync({
message: `Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Link this project?`,
});
if (!affirmedLink) {
throw new Error(
`Project ID configuration canceled. Re-run the command to select a different account/project.`
);
}
}
await saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
return existingProjectIdOnServer;
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
throw new Error(
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
);
}
if (!force) {
if (nonInteractive) {
throw new Error(
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
);
}
const affirmedCreate = await confirmAsync({
message: `Would you like to create a project for ${projectFullName}?`,
});
if (!affirmedCreate) {
throw new Error(`Project ID configuration canceled for ${projectFullName}.`);
}
}
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectLink = link(projectDashboardUrl, { text: projectFullName });
const account = nullthrows(allAccounts.find(a => a.name === accountName));
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
let createdProjectId: string;
try {
createdProjectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName,
});
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
} catch (err) {
spinner.fail();
throw err;
}
await saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
return createdProjectId;
}
function getAccountChoices(actor: Actor, namesWithSufficientPermissions: Set<string>): Choice[] {
const allAccounts = actor.accounts;
const sortedAccounts =
actor.__typename === 'Robot'
? allAccounts
: [...allAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
if (actor.__typename !== 'Robot') {
const personalAccount = allAccounts?.find(account => account?.ownerUserActor?.id === actor.id);
const personalAccountChoice = personalAccount
? {
title: personalAccount.name,
value: personalAccount,
description: !namesWithSufficientPermissions.has(personalAccount.name)
? '(Personal) (Viewer Role)'
: '(Personal)',
}
: undefined;
const userAccounts = allAccounts
?.filter(account => account.ownerUserActor && account.name !== actor.username)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Team) (Viewer Role)'
: '(Team)',
}));
const organizationAccounts = allAccounts
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Organization) (Viewer Role)'
: '(Organization)',
}));
let choices: Choice[] = [];
if (personalAccountChoice) {
choices = [personalAccountChoice];
}
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
);
}
return sortedAccounts.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
}));
}
@@ -155,6 +155,12 @@ function mockUpdateChannel({
return {
id: 'a2a1fa12-9d6a-433a-a432-49c64ef8439f',
app: {} as App,
buildCount: 0,
buildsAndEmbeddedUpdatesPaginated: {
edges: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
},
embeddedUpdateCount: 0,
latestRuntimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
runtimeInsights: {} as UpdateChannelRuntimeInsights,
name: channelName ?? 'default-channel-name',
@@ -20,7 +20,6 @@ const userStub: Actor = {
],
isExpoAdmin: false,
featureGates: {},
preferences: {},
};
const ssoUserStub: Actor = {
@@ -41,7 +40,6 @@ const ssoUserStub: Actor = {
],
isExpoAdmin: false,
featureGates: {},
preferences: {},
};
const robotStub: Actor = {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "20.5.1",
"version": "21.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": "20.5.1",
"@expo/eas-build-job": "21.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": "20.5.1",
"version": "21.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": "20.5.1",
"@expo/eas-build-job": "20.5.1",
"@expo/logger": "20.0.0",
"@expo/build-tools": "21.0.0",
"@expo/eas-build-job": "21.0.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "20.0.0",
"@expo/turtle-spawn": "21.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": "20.0.0",
"version": "21.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": "20.5.1",
"version": "21.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": "20.5.1",
"@expo/logger": "20.0.0",
"@expo/eas-build-job": "21.0.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fs-extra": "^11.2.0",
@@ -67,11 +67,18 @@
dependencies:
"@types/node" "*"
"@types/node@*", "@types/node@^20.4.1":
"@types/node@*":
version "20.4.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.1.tgz#a6033a8718653c50ac4962977e14d0f984d9527d"
integrity sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==
"@types/[email protected]":
version "20.14.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==
dependencies:
undici-types "~5.26.4"
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
@@ -106,9 +113,9 @@ [email protected]:
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
@@ -260,6 +267,11 @@ typescript@^5.1.6:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
uuid@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "20.0.0",
"version": "21.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": "20.0.0",
"version": "21.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": "20.0.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "20.5.1",
"version": "21.0.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,24 +19,24 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "20.5.1",
"@expo/build-tools": "21.0.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.5.1",
"@expo/logger": "20.0.0",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.0",
"@expo/logger": "21.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.5.1",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/steps": "21.0.0",
"@expo/template-file": "21.0.0",
"@expo/turtle-spawn": "21.0.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
"filesize": "7.0.0",
"fs-extra": "11.2.0",
"koa": "3.1.1",
"koa": "3.1.2",
"koa-body": "7.0.1",
"koa-router": "14.0.0",
"lodash": "4.17.21",
Generated
+131 -177
View File
@@ -2856,24 +2856,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:20.5.1, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.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:20.0.0"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:20.0.0"
"@expo/logger": "npm:21.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.2.0"
"@expo/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.5.1"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/steps": "npm:21.0.0"
"@expo/template-file": "npm:21.0.0"
"@expo/turtle-spawn": "npm:21.0.0"
"@expo/xcpretty": "npm:^4.3.1"
"@google-cloud/storage": "npm:^7.11.2"
"@ngrok/ngrok": "npm:1.7.0"
@@ -3097,7 +3097,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:20.0.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:21.0.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -3113,13 +3113,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:20.5.1, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:21.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:20.0.0"
"@expo/logger": "npm:21.0.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/turtle-spawn": "npm:21.0.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
jest: "npm:^29.7.0"
@@ -3132,12 +3132,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:20.5.1, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:21.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:20.5.1"
"@expo/eas-build-job": "npm:21.0.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3274,7 +3274,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:20.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:21.0.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3485,12 +3485,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:20.5.1, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:21.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:20.5.1"
"@expo/logger": "npm:20.0.0"
"@expo/eas-build-job": "npm:21.0.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3515,7 +3515,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:20.0.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:21.0.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3538,11 +3538,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:20.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:21.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:20.0.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -3558,18 +3558,18 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:20.5.1"
"@expo/build-tools": "npm:21.0.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/logger": "npm:20.0.0"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.0"
"@expo/logger": "npm:21.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:20.5.1"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/steps": "npm:21.0.0"
"@expo/template-file": "npm:21.0.0"
"@expo/turtle-spawn": "npm:21.0.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -3590,7 +3590,7 @@ __metadata:
filesize: "npm:7.0.0"
fs-extra: "npm:11.2.0"
jest: "npm:29.7.0"
koa: "npm:3.1.1"
koa: "npm:3.1.2"
koa-body: "npm:7.0.1"
koa-router: "npm:14.0.0"
lodash: "npm:4.17.21"
@@ -8645,6 +8645,15 @@ __metadata:
languageName: node
linkType: hard
"agent-cli-detector@npm:0.1.2":
version: 0.1.2
resolution: "agent-cli-detector@npm:0.1.2"
bin:
agent-cli-detector: dist/cli.js
checksum: 10c0/d7751eab293c4543f079988ffe519951859c254edb204fd3105ff49c114ac466d8ef05ba00b3b574fdd5038f9c4e24414d977918f5bccfe9a147628f83d013c3
languageName: node
linkType: hard
"aggregate-error@npm:^3.0.0":
version: 3.1.0
resolution: "aggregate-error@npm:3.1.0"
@@ -10256,7 +10265,7 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:0.5.4, content-disposition@npm:~0.5.4":
"content-disposition@npm:0.5.4":
version: 0.5.4
resolution: "content-disposition@npm:0.5.4"
dependencies:
@@ -10265,6 +10274,13 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:~1.0.1":
version: 1.0.1
resolution: "content-disposition@npm:1.0.1"
checksum: 10c0/bd7ff1fe8d2542d3a2b9a29428cc3591f6ac27bb5595bba2c69664408a68f9538b14cbd92479796ea835b317a09a527c8c7209c4200381dedb0c34d3b658849e
languageName: node
linkType: hard
"content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5":
version: 1.0.5
resolution: "content-type@npm:1.0.5"
@@ -10487,7 +10503,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:20.5.1"
"@expo/steps": "npm:21.0.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -11100,11 +11116,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:20.5.1"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/logger": "npm:20.0.0"
"@expo/build-tools": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/turtle-spawn": "npm:21.0.0"
"@types/bunyan": "npm:^1.8.11"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
@@ -11158,11 +11174,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:20.5.1"
"@expo/eas-json": "npm:20.5.1"
"@expo/eas-build-job": "npm:21.0.0"
"@expo/eas-json": "npm:21.0.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:20.0.0"
"@expo/logger": "npm:21.0.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -11174,7 +11190,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:20.5.1"
"@expo/steps": "npm:21.0.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"
@@ -11208,6 +11224,7 @@ __metadata:
"@types/wrap-ansi": "npm:3.0.0"
"@urql/core": "npm:4.0.11"
"@urql/exchange-retry": "npm:1.2.0"
agent-cli-detector: "npm:0.1.2"
ajv: "npm:8.11.0"
ajv-formats: "npm:2.1.1"
axios: "npm:1.11.0"
@@ -11249,7 +11266,7 @@ __metadata:
nanoid: "npm:3.3.8"
nock: "npm:13.4.0"
node-fetch: "npm:2.6.7"
node-forge: "npm:1.3.1"
node-forge: "npm:1.4.0"
node-stream-zip: "npm:1.15.0"
nullthrows: "npm:1.1.1"
ora: "npm:5.1.0"
@@ -11943,9 +11960,12 @@ __metadata:
linkType: hard
"fast-xml-builder@npm:^1.0.0":
version: 1.0.0
resolution: "fast-xml-builder@npm:1.0.0"
checksum: 10c0/2631fda265c81e8008884d08944eeed4e284430116faa5b8b7a43a3602af367223b7bf01c933215c9ad2358b8666e45041bc038d64877156a2f88821841b3014
version: 1.2.1
resolution: "fast-xml-builder@npm:1.2.1"
dependencies:
path-expression-matcher: "npm:^1.5.0"
xml-naming: "npm:^0.1.0"
checksum: 10c0/4870607b362d875daf667d6151a2b2fe38a01924a7d7fc87dd7493f5d12050e69015fb9867c551d0053141973ebfd9bd50d834d3cb36f75b936f8338fa6a1297
languageName: node
linkType: hard
@@ -11962,13 +11982,13 @@ __metadata:
linkType: hard
"fast-xml-parser@npm:^4.4.1":
version: 4.5.3
resolution: "fast-xml-parser@npm:4.5.3"
version: 4.5.7
resolution: "fast-xml-parser@npm:4.5.7"
dependencies:
strnum: "npm:^1.1.1"
strnum: "npm:^1.0.5"
bin:
fxparser: src/cli/cli.js
checksum: 10c0/bf9ccadacfadc95f6e3f0e7882a380a7f219cf0a6f96575149f02cb62bf44c3b7f0daee75b8ff3847bcfd7fbcb201e402c71045936c265cf6d94b141ec4e9327
checksum: 10c0/5fccf3f53d6b2b83143d73089f04ef5db5343422891193cf10d92d3eb856007b7337818494109e46f6fa47b31b9716be15c4b275ff87a0aeb6f7315aa2edc181
languageName: node
linkType: hard
@@ -12174,42 +12194,29 @@ __metadata:
linkType: hard
"form-data@npm:^2.5.5":
version: 2.5.5
resolution: "form-data@npm:2.5.5"
version: 2.5.6
resolution: "form-data@npm:2.5.6"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
hasown: "npm:^2.0.4"
mime-types: "npm:^2.1.35"
safe-buffer: "npm:^5.2.1"
checksum: 10c0/7fb70447849fc9bce4d01fe9a626f6587441f85779a2803b67f803e1ab52b0bd78db0a7acd80d944c665f68ca90936c327f1244b730719b638a0219e98b20488
checksum: 10c0/de6b085a2b0d013299ccc888b677dbdb3d2a54ef8d74982bda9ad7d928f57440abae1bc736a5b85ea01077cfb6c72e9a7752dc786304271c03bd0013ef8f08cb
languageName: node
linkType: hard
"form-data@npm:^4.0.0, form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
"form-data@npm:^4.0.0, form-data@npm:^4.0.4, form-data@npm:^4.0.5":
version: 4.0.6
resolution: "form-data@npm:4.0.6"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"form-data@npm:^4.0.5":
version: 4.0.5
resolution: "form-data@npm:4.0.5"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b
hasown: "npm:^2.0.4"
mime-types: "npm:^2.1.35"
checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff
languageName: node
linkType: hard
@@ -12684,24 +12691,9 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^10.2.2":
version: 10.3.10
resolution: "glob@npm:10.3.10"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^2.3.5"
minimatch: "npm:^9.0.1"
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
path-scurry: "npm:^1.10.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10c0/13d8a1feb7eac7945f8c8480e11cd4a44b24d26503d99a8d8ac8d5aefbf3e9802a2b6087318a829fad04cb4e829f25c5f4f1110c68966c498720dd261c7e344d
languageName: node
linkType: hard
"glob@npm:^10.3.10, glob@npm:^10.4.2":
version: 10.4.5
resolution: "glob@npm:10.4.5"
"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2":
version: 10.5.0
resolution: "glob@npm:10.5.0"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^3.1.2"
@@ -12711,23 +12703,7 @@ __metadata:
path-scurry: "npm:^1.11.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e
languageName: node
linkType: hard
"glob@npm:^10.3.7":
version: 10.4.2
resolution: "glob@npm:10.4.2"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^3.1.2"
minimatch: "npm:^9.0.4"
minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^1.11.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10c0/2c7296695fa75a935f3ad17dc62e4e170a8bb8752cf64d328be8992dd6ad40777939003754e10e9741ff8fbe43aa52fba32d6930d0ffa0e3b74bc3fb5eebaa2f
checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828
languageName: node
linkType: hard
@@ -13208,6 +13184,15 @@ __metadata:
languageName: node
linkType: hard
"hasown@npm:^2.0.4":
version: 2.0.4
resolution: "hasown@npm:2.0.4"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506
languageName: node
linkType: hard
"header-case@npm:^2.0.4":
version: 2.0.4
resolution: "header-case@npm:2.0.4"
@@ -14107,19 +14092,6 @@ __metadata:
languageName: node
linkType: hard
"jackspeak@npm:^2.3.5":
version: 2.3.6
resolution: "jackspeak@npm:2.3.6"
dependencies:
"@isaacs/cliui": "npm:^8.0.2"
"@pkgjs/parseargs": "npm:^0.11.0"
dependenciesMeta:
"@pkgjs/parseargs":
optional: true
checksum: 10c0/f01d8f972d894cd7638bc338e9ef5ddb86f7b208ce177a36d718eac96ec86638a6efa17d0221b10073e64b45edc2ce15340db9380b1f5d5c5d000cbc517dc111
languageName: node
linkType: hard
"jackspeak@npm:^3.1.2":
version: 3.4.0
resolution: "jackspeak@npm:3.4.0"
@@ -15030,7 +15002,7 @@ __metadata:
languageName: node
linkType: hard
"jwa@npm:^2.0.0":
"jwa@npm:^2.0.1":
version: 2.0.1
resolution: "jwa@npm:2.0.1"
dependencies:
@@ -15042,12 +15014,12 @@ __metadata:
linkType: hard
"jws@npm:^4.0.0":
version: 4.0.0
resolution: "jws@npm:4.0.0"
version: 4.0.1
resolution: "jws@npm:4.0.1"
dependencies:
jwa: "npm:^2.0.0"
jwa: "npm:^2.0.1"
safe-buffer: "npm:^5.0.1"
checksum: 10c0/f1ca77ea5451e8dc5ee219cb7053b8a4f1254a79cb22417a2e1043c1eb8a569ae118c68f24d72a589e8a3dd1824697f47d6bd4fb4bebb93a3bdf53545e721661
checksum: 10c0/6be1ed93023aef570ccc5ea8d162b065840f3ef12f0d1bb3114cade844de7a357d5dc558201d9a65101e70885a6fa56b17462f520e6b0d426195510618a154d0
languageName: node
linkType: hard
@@ -15133,12 +15105,12 @@ __metadata:
languageName: node
linkType: hard
"koa@npm:3.1.1":
version: 3.1.1
resolution: "koa@npm:3.1.1"
"koa@npm:3.1.2":
version: 3.1.2
resolution: "koa@npm:3.1.2"
dependencies:
accepts: "npm:^1.3.8"
content-disposition: "npm:~0.5.4"
content-disposition: "npm:~1.0.1"
content-type: "npm:^1.0.5"
cookies: "npm:~0.9.1"
delegates: "npm:^1.0.0"
@@ -15155,7 +15127,7 @@ __metadata:
statuses: "npm:^2.0.1"
type-is: "npm:^2.0.1"
vary: "npm:^1.1.2"
checksum: 10c0/2329fa9b2352dbbed8b5073b3b0d5b71678357222c7b8bc4d29e3383fa31c3dc23c6648a3445efa0e6b01e44de0349f8ec2f43d70cd0d37ce7ba1ce50337be15
checksum: 10c0/d686eec15cd185106a67ff19c8e2cf6ccef8a8001340bb81fb5d6dd8c0f6418dbbbf2decbfa7bb3a5d00b7d59ac95bb4053ec90e5041c98ea6f972416d9de5be
languageName: node
linkType: hard
@@ -15830,13 +15802,6 @@ __metadata:
languageName: node
linkType: hard
"mime-db@npm:1.44.0":
version: 1.44.0
resolution: "mime-db@npm:1.44.0"
checksum: 10c0/40479d676ff00e5acbd00abeaf3f0d948ef83b88d1d118f4fccce80a15f452f252a92ea4aff26e9841a6a8710f56d9123c6bb1a6e6630aba04346116bda8a085
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
@@ -15851,15 +15816,6 @@ __metadata:
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.27
resolution: "mime-types@npm:2.1.27"
dependencies:
mime-db: "npm:1.44.0"
checksum: 10c0/6ead19fd7cd6594e6b2d070fe14381c92a45c489dc5c5b342be41d1d573ac6870c71f7c4f1153a3a08f29ece2dea95c895ad914855b4ecd20575f4393360d802
languageName: node
linkType: hard
"mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
@@ -16028,15 +15984,6 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:^9.0.1":
version: 9.0.3
resolution: "minimatch@npm:9.0.3"
dependencies:
brace-expansion: "npm:^2.0.1"
checksum: 10c0/85f407dcd38ac3e180f425e86553911d101455ca3ad5544d6a7cec16286657e4f8a9aa6695803025c55e31e35a91a2252b5dc8e7d527211278b8b65b4dbd5eac
languageName: node
linkType: hard
"minimatch@npm:^9.0.4":
version: 9.0.5
resolution: "minimatch@npm:9.0.5"
@@ -16463,17 +16410,10 @@ __metadata:
languageName: node
linkType: hard
"node-forge@npm:1.3.1, node-forge@npm:^1.3.1":
version: 1.3.1
resolution: "node-forge@npm:1.3.1"
checksum: 10c0/e882819b251a4321f9fc1d67c85d1501d3004b4ee889af822fd07f64de3d1a8e272ff00b689570af0465d65d6bf5074df9c76e900e0aff23e60b847f2a46fbe8
languageName: node
linkType: hard
"node-forge@npm:^1.2.1":
version: 1.2.1
resolution: "node-forge@npm:1.2.1"
checksum: 10c0/0563bef5c6abfd031018ebd9cae41432811faef0b25ca7651489fb5f946250c3f8c433f42e76e9fd98f0d7617c12b700f8cbea134a1d284df84f8104c79127c0
"node-forge@npm:1.4.0, node-forge@npm:^1.2.1, node-forge@npm:^1.3.1":
version: 1.4.0
resolution: "node-forge@npm:1.4.0"
checksum: 10c0/67330a5f1f95257a4c8a93b7d555abe87b5f15e350123aa396c97a21a8ca94f9c6549008eb2c73668a91e0d7e3a905785acbd8f8bd0751c29401292011f8f8e1
languageName: node
linkType: hard
@@ -17677,6 +17617,13 @@ __metadata:
languageName: node
linkType: hard
"path-expression-matcher@npm:^1.5.0":
version: 1.6.1
resolution: "path-expression-matcher@npm:1.6.1"
checksum: 10c0/6d3bee01db16d8d3ad799aa6179785357a1277e227a44a4bff3ed5c7fcfb37325db596492c06356d8c82c097381c868ebc61b2e46a9363b35a1e007b7b902ee8
languageName: node
linkType: hard
"path-is-absolute@npm:^1.0.0":
version: 1.0.1
resolution: "path-is-absolute@npm:1.0.1"
@@ -17721,16 +17668,6 @@ __metadata:
languageName: node
linkType: hard
"path-scurry@npm:^1.10.1, path-scurry@npm:^1.6.1":
version: 1.10.1
resolution: "path-scurry@npm:1.10.1"
dependencies:
lru-cache: "npm:^9.1.1 || ^10.0.0"
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
checksum: 10c0/e5dc78a7348d25eec61ab166317e9e9c7b46818aa2c2b9006c507a6ff48c672d011292d9662527213e558f5652ce0afcc788663a061d8b59ab495681840c0c1e
languageName: node
linkType: hard
"path-scurry@npm:^1.11.1":
version: 1.11.1
resolution: "path-scurry@npm:1.11.1"
@@ -17741,6 +17678,16 @@ __metadata:
languageName: node
linkType: hard
"path-scurry@npm:^1.6.1":
version: 1.10.1
resolution: "path-scurry@npm:1.10.1"
dependencies:
lru-cache: "npm:^9.1.1 || ^10.0.0"
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
checksum: 10c0/e5dc78a7348d25eec61ab166317e9e9c7b46818aa2c2b9006c507a6ff48c672d011292d9662527213e558f5652ce0afcc788663a061d8b59ab495681840c0c1e
languageName: node
linkType: hard
"path-scurry@npm:^2.0.0":
version: 2.0.1
resolution: "path-scurry@npm:2.0.1"
@@ -19010,9 +18957,9 @@ __metadata:
linkType: hard
"shell-quote@npm:^1.7.3":
version: 1.7.4
resolution: "shell-quote@npm:1.7.4"
checksum: 10c0/54a9f16eee9449879290b9ab082d380ff229b9176608879087d1c21c423ad0bf954fe02941963ee80cafce6e09d629ae5b209ac7061de22cf8e1b9b3edf3e694
version: 1.9.0
resolution: "shell-quote@npm:1.9.0"
checksum: 10c0/a39960107eb086b02394cfceb3c732bd3bf567b568719ff47101448fc3f1bf4a59ecfdd8960ff7ea564d749ef8bda70f2c82a768f22d8c7aeeabf2b64f53b7e8
languageName: node
linkType: hard
@@ -19638,7 +19585,7 @@ __metadata:
languageName: node
linkType: hard
"strnum@npm:^1.1.1":
"strnum@npm:^1.0.5":
version: 1.1.2
resolution: "strnum@npm:1.1.2"
checksum: 10c0/a0fce2498fa3c64ce64a40dada41beb91cabe3caefa910e467dc0518ef2ebd7e4d10f8c2202a6104f1410254cae245066c0e94e2521fb4061a5cb41831952392
@@ -21353,6 +21300,13 @@ __metadata:
languageName: node
linkType: hard
"xml-naming@npm:^0.1.0":
version: 0.1.0
resolution: "xml-naming@npm:0.1.0"
checksum: 10c0/8c7614865361bcb7e53e3e091dac21c567e2b92d447919b2f072775aa9dcfc94a5255bd52fbaa0fd53c93513e53a23a6a835218ad2af512451dbc678392f85fe
languageName: node
linkType: hard
"xml2js@npm:0.6.0":
version: 0.6.0
resolution: "xml2js@npm:0.6.0"