Compare commits

...
24 Commits
Author SHA1 Message Date
Expo CI b92a9cc5a7 v22.0.0 2026-08-14 14:24:53 +00:00
763cbadaaa [eas-cli] remove deprecated GraphQL field usage (#4021)
* Remove deprecated GraphQL field usage

* Keep flat runtimeVersion in update:republish --json output

* Revert "Keep flat runtimeVersion in update:republish --json output"

This reverts commit 32264aea2d5c200bba7ee6be46d80598433fee08.

* Clarify CHANGELOG entry for update:republish --json shape

* Limit breaking-changes note to user-facing output changes

* Migrate account fixtures in accountSelection tests

* Migrate remaining account fixtures in getProjectIdAsync tests

Co-authored-by: tchayen <[email protected]>

* [eas-cli] Restore submission Application Identifier from the submitted build

Read it from Build.appIdentifier instead of the deprecated
androidConfig.applicationIdentifier.

Co-authored-by: tchayen <[email protected]>

* Regenerate GraphQL code and update channel fixture after rebase

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-14 17:21:27 +03:00
Tomek Czajecki 3cc58e500f [eas-cli] set up TestFlight group for existing ASC apps (#4136)
Set up the internal TestFlight group during interactive iOS submissions when ascAppId is configured, with an explicit opt-out flag. Skip setup in non-interactive mode, reuse the shared setup implementation, and fix the TestFlight group URL shown on partial tester failures.
2026-08-14 17:04:46 +03:00
expo-tuft[bot]andtchayen 2a7cb5e1fe [eas-cli] Set the project icon from the app config on init (#4177)
* [eas-cli] Set the project icon from the app config on init

Reuses the project:icon:set upload path, and only fills an icon when the
project does not have one yet.

Co-authored-by: tchayen <[email protected]>

* [eas-cli] Update CHANGELOG.md

Co-authored-by: tchayen <[email protected]>

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-14 14:18:52 +03:00
Stanisław Chmiela 289d579869 [build-tools] Add maestro-runner installation backend (#4186)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to try `maestro-runner` as Maestro test runner.

# How

Added support for installation of `maestro-runner` through `eas/install_maestro`.

I can see how icky this is, but I want to allow easy opt-in with `EAS_MAESTRO_BACKEND=maestro-runner` and we won't have easy access to all environment variables in `WorkflowMaestroTestJob.ts`.

# Test Plan

CI should pass. Verified `maestro-runner --version` output.
2026-08-14 09:28:05 +02:00
Mustafa Abban 067c0e578f [build-tools] upload source maps from builds for Observe (#4170)
* [build-tools] Upload Observe source maps from EAS builds

* [build-tools] Fix source map upload after storage refactor

* [build] configure source map uploads in build profiles

* fix CHANGELOG formatting

* use SystemError in source map upload, rename prepare to resolve
2026-08-12 14:36:55 -04:00
William Grosset 520c63fd3e [eas-cli] Improve non-interactive error for unconfigured projects (#4153)
* [eas-cli] Improve non-interactive error for unconfigured projects

* [eas-cli] Format non-interactive project error with indented commands

* [eas-cli] Skip redundant "not configured" warning in non-interactive mode

* [eas-cli] Show the non-interactive project error on all unconfigured paths
2026-08-11 10:55:31 -07:00
Expo CI 221e01568c update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-11 16:01:58 +00:00
Expo CI b8cff20089 v21.8.0 2026-08-11 15:59:00 +00:00
Gabe Debes c99ec44b92 [eas-cli] Add integrations:supabase:disconnect command (#4108) 2026-08-11 08:49:29 -07:00
Gabe Debes 90501af961 [eas-cli] Add integrations:supabase:dashboard command (#4107) 2026-08-11 08:22:55 -07:00
Gabe Debes 4c317292d2 [eas-cli] Add integrations:supabase:connect (#4106)
* [eas-cli] Add integrations:supabase:connect command

* [eas-cli] Update connect test for provision failure hint

* [eas-cli] Avoid re-run advice after additional Supabase provision

Env-write and missing-credential recovery now print URL/key for manual setup
instead of suggesting another --environment run that would bill a second project.

* [eas-cli] Cover primary provision receipt parse failure in connect

* [eas-cli] Prompt link vs provision after supabase --reauth

Cascade delete clears the EAS project link on reauth; default to
linking an existing project instead of provisioning a new billed one.

* [eas-cli] Cover post-reauth link prompt validation paths

* [eas-cli] Format supabase connect reauth changes

* [eas-cli] Drop redundant URL polyfill and match docs client path

* [eas-cli] Print the Supabase authorize URL before opening the browser

* [eas-cli] Keep the browser open call from rejecting unhandled
2026-08-11 07:49:23 -07:00
Stanisław Chmiela 16cf32ae73 [build-tools] Stream logcat to a file (#3627)
# Why

Workflow Maestro tests currently collect Android logs with `adb logcat` after the tests. This depends on the ADB server and can lose logs when ADB becomes unavailable or the device shuts down.

# How

- Start each Android Emulator with native `-logcat '*:v'` and `-logcat-output` arguments.
- Write logs to a unique temporary directory that lasts for the job.
- Expose that directory as the `logcat_directory` output of `eas/start_android_emulator`.

In Maestro test job we're going to copy files from that `logcat_directory` to Maestro bundle and thus get logs for all devices.

# Test Plan

- `yarn workspace @expo/build-tools jest-unit src/steps/functions/__tests__/startAndroidEmulator.test.ts src/utils/__tests__/AndroidEmulatorUtils.test.ts --runInBand`
- `yarn workspace @expo/build-tools typecheck`
- `yarn fmt:check`
- Real-emulator integration test confirms native logcat capture continues across an ADB server restart.
2026-08-11 15:40:59 +02:00
Gabe Debes 1ec9e83c3b [eas-cli] Supabase integration base (#4130)
* [eas-cli] Add Supabase integration foundation (GraphQL, shared helpers, provisioning)

* [eas-cli] Fix additional Supabase provision failure hint

Stop suggesting --environment with --link; that combo is rejected by connect.

* [eas-cli] Reach 100% coverage on Supabase foundation helpers

* [eas-cli] Regenerate GraphQL after rebase onto main

* [eas-cli] Drop thin Supabase env wrappers and re-exports

Call shared writeEnvLocal / env upsert / resolveTargetEnvironments
with the Supabase label at the callsite instead of one-arg wrappers.

* [eas-cli] Slim supabase/sdk.ts to package constants only

Call shared setupSdkAndConfigAsync with packages/plugin/label at
the connect callsite instead of thin binder wrappers.

* [eas-cli] Consolidate supabase helpers; move writeEnvVarsAsync

Drop constant-only sdk.ts/environments.ts into env.ts. Move the
generic writeEnvVarsAsync loop into environments/variables.

* [eas-cli] Address Supabase foundation review feedback

* [eas-cli] Stop polling when a Supabase request fails for good

* [eas-cli] Move Supabase changelog entry under unreleased main
2026-08-10 16:51:06 -07:00
Stanisław Chmiela 3b1b96e71f [build-tools] Add direct DADB mode for Maestro tests (#4163)
## Why

ADB server is sometimes flaky.

## How

Allow opting in to [DADB](https://github.com/mobile-dev-inc/dadb) for Maestro-Android emulator connections.

DADB mode can be enabled with either:

- the `android_connection_mode: dadb` step input
- the `EAS_MAESTRO_ANDROID_CONNECTION_MODE=dadb` environment variable

The explicit step input has priority over the environment variable. The default remains `adb`. On iOS, `dadb` is accepted and ignored, so the environment variable can be set for the whole project.

## Test plan

I tested this before with a client. I want to allow another client to easily opt in. This is a no-op unless it is enabled on Android.

Unit tests cover input opt-in, environment opt-in, input precedence, invalid values, iOS behavior, retries, and failure behavior.
2026-08-10 21:59:53 +02:00
Douglas Lowder 2f081de936 [ci] Fix actionlint findings across workflows and internal actions (#4167)
# Why

Working on the repo locally, I found warnings and errors when linting workflows with actionlint.

# How

- Fix the warnings and errors, including updating a workflow using an outdated Node 20 runner.
- Add actionlint to the mise configuration, an actionlint script to the top level package.json, and an actionlint step in test.yml to ensure no regressions occur on future PRs.

# Test Plan

CI should pass, including the new actionlint check in test.yml.
2026-08-10 11:08:58 -07:00
Expo CI 954534df42 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-10 15:47:56 +00:00
Expo CI 60032049ec v21.7.1 2026-08-10 15:44:17 +00:00
Gabriel Donadel Dall'AgnolandBrent Vatne 7a63315be5 [build-tools] Make configure-version gradle script compatible with AGP 9 (#4155)
* [build-tools] Make configure-version gradle script compatible with AGP 9

* [build-tools] Update gradleConfig snapshots for AGP 9 template change

* update CHANGELOG.md

---------

Co-authored-by: Brent Vatne <[email protected]>
2026-08-10 14:42:56 +02:00
David Mokos 295d49681b [cli] add sandbox detection to telemetry (#4038) 2026-08-10 05:33:33 -07:00
Pieter Willekens c93614ec55 [steps][create-eas-build-function] Declare missing dependencies (#3798)
`fast-glob` and `arg` were imported but not declared in their packages,
resolving only via workspace hoisting. Declare them explicitly so the
packages keep working when installed standalone.
2026-08-10 13:15:28 +02:00
Stanisław Chmiela e8e135431c [build-tools][worker] Enable production EAS Logs (#4168)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to see how the architecture works under full logs pressure.

# How

Enabled pushing logs to logs.expo.dev in production too.

# Test Plan

Staging works, let's see it in production.
2026-08-10 12:59:25 +02:00
Stanisław Chmiela 42a487e695 [build-tools] Enable real time sessions in production (#4160)
# Why

We want to stream session events in real-time.

# How

Added necessary config.

# Test Plan

Land after https://github.com/expo/universe/pull/29610 and verify in production. Staging works good.
2026-08-07 19:19:46 +02:00
Expo CI 2a062dba96 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-07 03:13:21 +00:00
182 changed files with 10786 additions and 1211 deletions

No files matched your search

@@ -1,12 +1,13 @@
name: notify-slack-on-fail-or-recover
description: Notify a Slack channel when a workflow fails, or when it recovers after a previous failure.
inputs:
channel:
description: Slack channel to send the notification to.
required: true
type: string
slack_webhook:
description: Slack incoming webhook URL used to post the notification.
required: true
type: string
runs:
using: 'composite'
@@ -1,4 +1,5 @@
name: setup-gcloud
description: Authenticate to Google Cloud via workload identity federation and set up the gcloud CLI.
runs:
using: "composite"
@@ -38,7 +38,7 @@ jobs:
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -191,8 +191,8 @@ jobs:
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
@@ -201,5 +201,5 @@ jobs:
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
done
@@ -60,9 +60,11 @@ jobs:
elif [ "$rest" != "all" ]; then
agents=$(printf '%s' "$rest" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-')
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "agents=$agents" >> "$GITHUB_OUTPUT"
echo "route=$route" >> "$GITHUB_OUTPUT"
{
echo "run=true"
echo "agents=$agents"
echo "route=$route"
} >> "$GITHUB_OUTPUT"
- name: Acknowledge
if: steps.cmd.outputs.run == 'true'
@@ -137,10 +139,10 @@ jobs:
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
run: |
ARGS=""
ARGS=()
if [ -n "$AGENTS" ]; then
ARGS="--agents $AGENTS"
ARGS=(--agents "$AGENTS")
elif [ "$ROUTE" = "true" ]; then
ARGS="--route"
ARGS=(--route)
fi
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci $ARGS
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
@@ -61,10 +61,12 @@ jobs:
if [ -z "$(printf '%s' "$ids" | tr -d ' ')" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"; exit 0
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "sub=$sub" >> "$GITHUB_OUTPUT"
echo "ids=$ids" >> "$GITHUB_OUTPUT"
echo "reason=$reason" >> "$GITHUB_OUTPUT"
{
echo "run=true"
echo "sub=$sub"
echo "ids=$ids"
echo "reason=$reason"
} >> "$GITHUB_OUTPUT"
- name: Acknowledge
if: steps.cmd.outputs.run == 'true'
+9 -7
View File
@@ -74,9 +74,11 @@ jobs:
else
route=true # bare `ai-review` => let the router pick
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "agents=$agents" >> "$GITHUB_OUTPUT"
echo "route=$route" >> "$GITHUB_OUTPUT"
{
echo "run=true"
echo "agents=$agents"
echo "route=$route"
} >> "$GITHUB_OUTPUT"
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
# merge ref — everything on this runner's disk comes from a commit that
@@ -172,13 +174,13 @@ jobs:
AGENTS: ${{ steps.resolve.outputs.agents }}
ROUTE: ${{ steps.resolve.outputs.route }}
run: |
ARGS=""
ARGS=()
if [ -n "$AGENTS" ]; then
ARGS="--agents $AGENTS"
ARGS=(--agents "$AGENTS")
elif [ "$ROUTE" = "true" ]; then
ARGS="--route"
ARGS=(--route)
fi
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci $ARGS
npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
# Persist the run log (decision, per-agent cost, duration, token/cache
# metrics) so cost/latency/cache reuse are observable over time — CI is
+5 -5
View File
@@ -5,7 +5,7 @@ on:
jobs:
needs-repro:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -49,7 +49,7 @@ jobs:
})
needs-info:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -78,7 +78,7 @@ jobs:
})
issue-accepted:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: github.event.label.name == 'issue accepted'
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -95,7 +95,7 @@ jobs:
`})
question:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
@@ -123,7 +123,7 @@ jobs:
})
feature-request:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
+8 -6
View File
@@ -66,12 +66,12 @@ jobs:
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Update changelog
working-directory: ./scripts
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push
@@ -90,9 +90,11 @@ jobs:
run: |
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
npx slackmd /tmp/changelog.md
echo 'SLACK_CHANGELOG<<EOF' >> $GITHUB_ENV
cat /tmp/changelog.slack.md >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
{
echo 'SLACK_CHANGELOG<<EOF'
cat /tmp/changelog.slack.md
echo 'EOF'
} >> "$GITHUB_ENV"
- name: Slack Notification
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
+1
View File
@@ -60,6 +60,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint
- run: yarn fmt:check
- run: yarn actionlint
- name: Install scripts dependencies
if: ${{ matrix.coverage }}
run: yarn install --immutable
+1 -1
View File
@@ -42,5 +42,5 @@ jobs:
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release $INPUT_VERSION
yarn release "$INPUT_VERSION"
fi
+28 -2
View File
@@ -6,6 +6,33 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- [eas-cli] Build objects in `--json` output (`eas build`, `eas build:list`, `eas build:view`, `eas build:resign`) now have `app`, `updateChannel` (object with `id` and `name`), and `runtime` (object with `id` and `version`) instead of `project`, `channel`, and `runtimeVersion`. `eas update:republish --json` output likewise has `runtime` instead of `runtimeVersion`. Other update commands' `--json` output and `eas-update-metadata.json` are unchanged. ([#4021](https://github.com/expo/eas-cli/pull/4021) by [@tchayen](https://github.com/tchayen))
### 🎉 New features
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
### 🐛 Bug fixes
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
### 🧹 Chores
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
### 🎉 New features
- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:connect` command. ([#4106](https://github.com/expo/eas-cli/pull/4106) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:dashboard` command. ([#4107](https://github.com/expo/eas-cli/pull/4107) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:disconnect` command. ([#4108](https://github.com/expo/eas-cli/pull/4108) by [@gwdp](https://github.com/gwdp))
## [21.7.1](https://github.com/expo/eas-cli/releases/tag/v21.7.1) - 2026-08-10
## [21.7.0](https://github.com/expo/eas-cli/releases/tag/v21.7.0) - 2026-08-07
### 🎉 New features
- [build-tools] Label serve-sim metrics artifacts with the sim's device name (from the metrics meta) instead of the raw udid. ([#4127](https://github.com/expo/eas-cli/pull/4127) by [@gwdp](https://github.com/gwdp))
@@ -15,8 +42,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Fail when `--private-key-path` is passed without `updates.codeSigningCertificate` in the resolved app config, instead of ignoring the flag and publishing an unsigned update. ([#4161](https://github.com/expo/eas-cli/pull/4161) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [21.6.0](https://github.com/expo/eas-cli/releases/tag/v21.6.0) - 2026-08-05
### 🎉 New features
@@ -31,6 +56,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Point accounts without EAS Simulator access at the waitlist: `eas simulator:availability` includes the link (and a `waitlistUrl` field in `--json`), and `eas simulator` now fails early with the same message instead of a generic permission error. ([#4151](https://github.com/expo/eas-cli/pull/4151) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Revert "Pin the default `agent-device` version for remote sessions" now that `agent-device` 0.20.5 fixes the broken release. ([#4144](https://github.com/expo/eas-cli/pull/4144) by [@gwdp](https://github.com/gwdp))
- [build-tools] Make the configure-version gradle script compatible with AGP 9, which removes the `applicationVariants` API; per-output version overrides now fall back to the `androidComponents` variant API. ([#4155](https://github.com/expo/eas-cli/pull/4155) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### 🧹 Chores
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "21.6.0",
"STAGING": "21.8.0",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.7.0",
"version": "22.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1
View File
@@ -3,6 +3,7 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+1
View File
@@ -11,6 +11,7 @@
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"actionlint": "actionlint",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.7.0",
"version": "22.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": "21.0.0",
"@expo/eas-build-job": "21.6.0",
"@expo/downloader": "22.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/logger": "22.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.6.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/steps": "22.0.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -4,6 +4,7 @@ import { Artifacts, BuildContext } from '../context';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { maybeUploadSourceMapAsync } from '../utils/sourceMaps';
export async function runBuilderWithHooksAsync<T extends BuildJob>(
ctx: BuildContext<T>,
@@ -38,6 +39,9 @@ export async function runBuilderWithHooksAsync<T extends BuildJob>(
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
if (buildSuccess) {
await maybeUploadSourceMapAsync(ctx);
}
await maybeFindAndUploadBuildArtifacts(ctx, {
logger: ctx.logger,
});
+11 -6
View File
@@ -29,6 +29,7 @@ import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { isSourceMapUploadEnabled, resolveIosSourceMapPathAsync } from '../utils/sourceMaps';
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -168,20 +169,24 @@ async function buildInnerAsync(
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
const sourceMapPath = isSourceMapUploadEnabled(ctx)
? await resolveIosSourceMapPathAsync(ctx)
: null;
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
entitlements,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
extraEnv: {
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
}
: null),
...(sourceMapPath ? { SOURCEMAP_FILE: sourceMapPath } : null),
},
});
});
} finally {
@@ -6,6 +6,7 @@ import { Writable } from 'stream';
import { retryAsync } from '../utils/retry';
const MAX_BATCH_BYTES = 200_000;
const REQUEST_TIMEOUT_MS = 10_000;
interface BufferedLogEntry {
enqueuedAt: number;
@@ -161,6 +162,7 @@ export default class HttpLogStream extends Writable {
'Content-Type': 'application/x-ndjson',
},
body: logs.map(log => log.serialized).join('\n'),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
@@ -186,4 +186,21 @@ describe(HttpLogStream.name, () => {
const [firstAttemptLogs, secondAttemptLogs] = parseRequestLogs();
expect(secondAttemptLogs).toEqual(firstAttemptLogs);
});
it('sets a ten-second timeout on HTTP uploads', async () => {
fetchMock.mockResolvedValue(createResponse());
const abortSignalTimeoutSpy = jest.spyOn(AbortSignal, 'timeout');
const stream = new HttpLogStream({
url: 'https://logs.expo.test/build-id',
headers: { Authorization: 'Bearer token' },
logger: createLogger({ name: 'test' }),
});
stream.write({ logId: 'stable-id', msg: 'Do not hang' });
await stream.cleanUp();
expect(abortSignalTimeoutSpy).toHaveBeenCalledWith(10_000);
expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
abortSignalTimeoutSpy.mockRestore();
});
});
@@ -38,6 +38,7 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
maestro_backend: 'maestro',
});
});
@@ -70,6 +71,7 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
maestro_backend: 'maestro',
});
});
@@ -87,4 +89,141 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
it('checks maestro-runner when selected by input', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro-runner'
? 'maestro-runner 1.2.3\n Commit: abc123\n Built: 2026-08-13\n Go: go1.26.5\n OS/Arch: darwin/arm64\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(2);
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
'maestro-runner',
'maestro-runner',
]);
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.2.3',
maestro_backend: 'maestro-runner',
});
});
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_MAESTRO_BACKEND: 'maestro-runner',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
'maestro-runner',
'maestro-runner',
]);
});
it('installs the latest maestro-runner version when it is missing', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '' };
}) as any);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(fetchSpy).toHaveBeenCalledWith('https://open.devicelab.dev/install/maestro-runner');
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
[],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
expect(mockedSpawn).toHaveBeenLastCalledWith(
'maestro-runner',
['--version'],
expect.objectContaining({
env: expect.objectContaining({
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
}),
})
);
} finally {
fetchSpy.mockRestore();
process.env.PATH = originalPath;
}
});
it('installs the requested maestro-runner version', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.0.9\n' : '' };
}) as any);
jest.spyOn(global, 'fetch').mockResolvedValue({
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner', maestro_version: '1.0.9' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
['--version', '1.0.9'],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
} finally {
jest.restoreAllMocks();
process.env.PATH = originalPath;
}
});
});
@@ -63,11 +63,14 @@ const rejectExit1 = (): Error => {
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
options: {
env?: Record<string, string | undefined>;
logger?: ReturnType<typeof createMockLogger>;
} = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const logger = options.logger ?? createMockLogger();
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext;
@@ -134,6 +137,253 @@ describe('createMaestroTestsBuildFunction', () => {
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('uses direct DADB when android_connection_mode is dadb without cleanup', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const writeFileSpy = jest.spyOn(fs, 'writeFile');
const rmSpy = jest.spyOn(fs, 'rm');
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(2);
expect(mockedSpawn.mock.calls[0].slice(0, 2)).toEqual(['adb', ['kill-server']]);
expect(mockedSpawn.mock.calls[1][0]).toBe('maestro');
expect(mockedSpawn.mock.calls[0][2]?.signal).toBe(mockedSpawn.mock.calls[1][2]?.signal);
const maestroEnv = mockedSpawn.mock.calls[1][2]?.env;
expect(maestroEnv?.PATH).toMatch(
new RegExp(`^${os.tmpdir()}/maestro-tests-adb-override-.+${path.delimiter}/usr/bin$`)
);
expect(mockedSpawn.mock.calls[0][2]?.env?.PATH).not.toContain('maestro-tests-adb-override-');
expect(logger.info).toHaveBeenCalledWith(
'Using a direct DADB connection for Android Maestro tests after stopping the ADB server.'
);
expect(writeFileSpy).toHaveBeenCalledWith(
expect.stringMatching(/maestro-tests-adb-override-.+\/adb$/),
'#!/bin/sh\nexit 1\n',
{ mode: 0o755 }
);
expect(rmSpy).not.toHaveBeenCalledWith(
expect.stringMatching(/maestro-tests-adb-override-/),
expect.anything()
);
});
it('continues with direct DADB and warns when adb kill-server fails', async () => {
const killServerError = new Error('ADB server is unavailable');
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'adb') {
throw killServerError;
}
return SPAWN_SUCCESS;
}) as any);
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
);
await step.executeAsync();
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual(['adb', 'maestro']);
expect(logger.warn).toHaveBeenCalledWith(
{ err: killServerError },
'Using a direct DADB connection for Android Maestro tests, but failed to stop the ADB server.'
);
});
it.each([
{
source: 'omitted',
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android' },
env: { HOME: '/home/expo' },
},
{
source: 'empty input',
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android', android_connection_mode: '' },
env: { HOME: '/home/expo' },
},
{
source: 'empty environment variable',
callInputs: { flow_path: ['flows/a.yaml'], platform: 'android' },
env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: '' },
},
])('treats an $source connection mode value as adb', async ({ callInputs, env }) => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(callInputs, { env });
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('uses direct DADB when EAS_MAESTRO_ANDROID_CONNECTION_MODE is dadb', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
},
{
env: {
HOME: '/home/expo',
PATH: '/usr/bin',
EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb',
},
}
);
await step.executeAsync();
expect(mockedSpawn.mock.calls[0].slice(0, 2)).toEqual(['adb', ['kill-server']]);
expect(mockedSpawn.mock.calls[1][0]).toBe('maestro');
});
it('prefers android_connection_mode over EAS_MAESTRO_ANDROID_CONNECTION_MODE', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'adb',
},
{
env: {
HOME: '/home/expo',
PATH: '/usr/bin',
EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb',
},
}
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('uses the same direct DADB environment for all retries', async () => {
let maestroAttempt = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro' && maestroAttempt++ === 0) {
throw rejectExit1();
}
return SPAWN_SUCCESS;
}) as any);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
retries: 1,
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' } }
);
await step.executeAsync();
const maestroCalls = mockedSpawn.mock.calls.filter(([command]) => command === 'maestro');
expect(maestroCalls).toHaveLength(2);
expect(maestroCalls[0][2]?.env?.PATH).toBe(maestroCalls[1][2]?.env?.PATH);
expect(mockedSpawn.mock.calls.filter(([command]) => command === 'adb')).toHaveLength(1);
});
it('rejects invalid android_connection_mode values', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'automatic',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects invalid EAS_MAESTRO_ANDROID_CONNECTION_MODE values', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
},
{ env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'automatic' } }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('ignores direct DADB input mode on iOS', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'ios',
android_connection_mode: 'dadb',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('ignores direct DADB environment mode on iOS', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'ios',
},
{ env: { HOME: '/home/expo', EAS_MAESTRO_ANDROID_CONNECTION_MODE: 'dadb' } }
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('does not clean up direct DADB mode when Maestro fails', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro') {
throw new Error('maestro crashed');
}
return SPAWN_SUCCESS;
}) as any);
const rmSpy = jest.spyOn(fs, 'rm');
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' } }
);
await expect(step.executeAsync()).rejects.toThrow('Unexpected spawn failure invoking maestro');
expect(mockedSpawn.mock.calls.filter(([command]) => command === 'adb')).toHaveLength(1);
expect(rmSpy).not.toHaveBeenCalledWith(
expect.stringMatching(/maestro-tests-adb-override-/),
expect.anything()
);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
@@ -1,4 +1,5 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -34,6 +35,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
@@ -54,6 +56,7 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
@@ -76,6 +79,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
});
});
afterAll(() => {
mockedMkdtemp.mockRestore();
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
@@ -106,6 +113,20 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
@@ -174,6 +195,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-1',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-2',
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -200,6 +239,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
});
it('outputs and uses a temporary logcat directory', async () => {
const step = createStep();
await step.executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
logcatDirectory: '/tmp/logcat-directory',
})
);
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-default',
})
);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
@@ -15,6 +15,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
@@ -29,6 +30,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'backend',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -37,22 +43,28 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger, global }, { inputs, env, outputs }) => {
const requestedMaestroVersion = inputs.maestro_version.value as string | undefined;
const { value: currentMaestroVersion } = await asyncResult(getMaestroVersion({ env }));
const backend = resolveMaestroBackend({
input: inputs.backend.value,
env,
});
const requestedVersion = inputs.maestro_version.value as string | undefined;
const { value: currentMaestroVersion } = await asyncResult(
getMaestroVersion({ env, backend })
);
// When not running in EAS Build VM, do not modify local environment.
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
const currentIsJavaInstalled = await isJavaInstalled({ env });
const currentIsIdbInstalled = await isIdbInstalled({ env });
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
const needsToInstallIdb = backend === 'maestro' && !(await isIdbInstalled({ env }));
if (!currentIsJavaInstalled) {
if (needsToInstallJava) {
logger.warn(
'It seems Java is not installed. It is required to run Maestro. If the job fails, this may be the reason.'
);
logger.info('');
}
if (!currentIsIdbInstalled) {
if (needsToInstallIdb) {
logger.warn(
'It seems IDB is not installed. Maestro requires it to run flows on iOS Simulator. If the job fails, this may be the reason.'
);
@@ -61,27 +73,32 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
if (!currentMaestroVersion) {
logger.warn(
'It seems Maestro is not installed. Please install Maestro manually and rerun the job.'
`It seems ${backend} is not installed. Please install it manually and rerun the job.`
);
logger.info('');
}
// Guide is helpful in these two cases, it doesn't mention Java.
if (!currentIsIdbInstalled || !currentMaestroVersion) {
if (backend === 'maestro' && (needsToInstallIdb || !currentMaestroVersion)) {
logger.warn(
'For more info, check out Maestro installation guide: https://maestro.mobile.dev/getting-started/installing-maestro'
);
} else if (backend === 'maestro-runner' && !currentMaestroVersion) {
logger.warn(
'For more info, check out maestro-runner installation guide: https://github.com/devicelab-dev/maestro-runner#install'
);
}
if (currentMaestroVersion) {
outputs.maestro_version.set(currentMaestroVersion);
logger.info(`Maestro ${currentMaestroVersion} is ready.`);
logger.info(`${backend} ${currentMaestroVersion} is ready.`);
}
return;
}
if (!(await isJavaInstalled({ env }))) {
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
if (needsToInstallJava) {
if (global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info('Installing Java');
await installJavaFromGcs({ logger, env });
@@ -94,10 +111,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}
// IDB is only a requirement on macOS.
if (
const needsToInstallIdb =
backend === 'maestro' &&
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
!(await isIdbInstalled({ env }))
) {
!(await isIdbInstalled({ env }));
if (needsToInstallIdb) {
logger.info('Installing IDB');
await installIdbFromBrew({ logger, env });
}
@@ -106,43 +124,100 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
// and it is already installed which happens when developing on a local computer.
if (
!currentMaestroVersion ||
(requestedMaestroVersion && requestedMaestroVersion !== currentMaestroVersion)
(requestedVersion && requestedVersion !== currentMaestroVersion)
) {
await installMaestro({
version: requestedMaestroVersion,
global,
logger,
env,
});
switch (backend) {
case 'maestro':
await installMaestro({ version: requestedVersion, global, logger, env });
break;
case 'maestro-runner':
await installMaestroRunner({ version: requestedVersion, global, logger, env });
break;
}
}
const maestroVersionResult = await asyncResult(getMaestroVersion({ env }));
const maestroVersionResult = await asyncResult(getMaestroVersion({ env, backend }));
if (!maestroVersionResult.ok) {
logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.');
throw new Error('Failed to ensure Maestro is installed.');
throw new Error(`Failed to ensure ${backend} is installed.`);
}
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
maestro_backend: backend,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
async function getMaestroVersion({
env,
backend,
}: {
env: BuildStepEnv;
backend: MaestroBackend;
}): Promise<string> {
switch (backend) {
case 'maestro': {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
}
case 'maestro-runner': {
const { stdout } = await spawn('maestro-runner', ['--version'], { stdio: 'pipe', env });
// maestro-runner prints build information after its version. The Go runtime version in
// that output is also semver-shaped, so read only the prefixed runner version.
return /^maestro-runner\s+(\S+)/m.exec(stdout)?.[1] ?? stdout.trim();
}
}
}
async function installMaestroRunner({
global,
version,
logger,
env,
}: {
version?: string;
logger: bunyan;
global: BuildStepGlobalContext;
env: BuildStepEnv;
}): Promise<void> {
logger.info('Fetching maestro-runner install script');
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_maestro_runner'));
try {
const installMaestroRunnerScriptResponse = await fetch(
'https://open.devicelab.dev/install/maestro-runner'
);
const installMaestroRunnerScript = await installMaestroRunnerScriptResponse.text();
const scriptPath = path.join(tempDirectory, 'install_maestro_runner.sh');
await fs.promises.writeFile(scriptPath, installMaestroRunnerScript, { mode: 0o777 });
logger.info('Installing maestro-runner');
assert(
env.HOME,
'Failed to infer directory to install maestro-runner in: $HOME environment variable is empty.'
);
await spawn(scriptPath, version && version !== 'latest' ? ['--version', version] : [], {
logger,
env,
});
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
global.updateEnv({
...global.env,
PATH: `${global.env.PATH}:${binDir}`,
});
env.PATH = `${env.PATH}:${binDir}`;
process.env.PATH = `${process.env.PATH}:${binDir}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
}
async function installMaestro({
@@ -0,0 +1,25 @@
import { UserError } from '@expo/eas-build-job';
import { BuildStepEnv } from '@expo/steps';
import { z } from 'zod';
export const MaestroBackendSchema = z.enum(['maestro', 'maestro-runner']).default('maestro');
export type MaestroBackend = z.output<typeof MaestroBackendSchema>;
export function resolveMaestroBackend({
input,
env,
}: {
input: unknown;
env: BuildStepEnv;
}): MaestroBackend {
const result = MaestroBackendSchema.safeParse(input || env.EAS_MAESTRO_BACKEND || undefined);
if (!result.success) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'backend and EAS_MAESTRO_BACKEND must be either "maestro" or "maestro-runner".',
{ cause: result.error }
);
}
return result.data;
}
@@ -35,6 +35,7 @@ import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
const AndroidConnectionModeSchema = z.enum(['adb', 'dadb']).default('adb');
function parseInput<S extends z.ZodTypeAny>(
schema: S,
@@ -143,6 +144,11 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'android_connection_mode',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
@@ -180,7 +186,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
@@ -206,6 +212,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
inputs.shards.value,
'shards must be a positive integer.'
);
const androidConnectionMode = parseInput(
AndroidConnectionModeSchema,
inputs.android_connection_mode.value ||
env.EAS_MAESTRO_ANDROID_CONNECTION_MODE ||
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
try {
@@ -232,6 +245,40 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
const harvested: HarvestedScreenshot[] = [];
const totalAttempts = retries + 1;
if (platform === 'android' && androidConnectionMode === 'dadb') {
try {
const adbOverrideDirectoryPath = await fs.mkdtemp(
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
);
await fs.writeFile(path.join(adbOverrideDirectoryPath, 'adb'), '#!/bin/sh\nexit 1\n', {
mode: 0o755,
});
// DADB starts an ADB server when it can find an adb binary. Stop the existing
// server first, then make only the Maestro process find the failing shim.
try {
await spawn('adb', ['kill-server'], { env: { ...spawnEnv }, logger, signal });
logger.info(
'Using a direct DADB connection for Android Maestro tests after stopping the ADB server.'
);
} catch (err) {
logger.warn(
{ err },
'Using a direct DADB connection for Android Maestro tests, but failed to stop the ADB server.'
);
}
spawnEnv.PATH = spawnEnv.PATH
? `${adbOverrideDirectoryPath}${path.delimiter}${spawnEnv.PATH}`
: adbOverrideDirectoryPath;
} catch (err) {
// Intentionally skip cleanup because the worker is disposable.
throw new SystemError('Failed to enable direct DADB connection for Maestro', {
cause: err,
});
}
// Do not restart ADB or remove the override. This keeps Maestro in direct DADB mode.
}
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
@@ -5,8 +5,12 @@ import {
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
AndroidDeviceName,
@@ -49,7 +53,18 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger }, { inputs, env }) => {
outputProviders: [
BuildStepOutput.createProvider({
id: 'logcat_directory',
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
outputs.logcat_directory.set(logcatDirectory);
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
@@ -122,6 +137,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
@@ -211,6 +227,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
logcatDirectory,
});
cloneSerialId = startResult.serialId;
@@ -23,7 +23,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -33,6 +38,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -56,7 +73,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -66,6 +88,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -91,7 +125,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -101,6 +140,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -126,7 +177,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -136,6 +192,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -163,7 +231,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -173,6 +246,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -200,7 +285,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -210,6 +300,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -237,7 +339,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -247,6 +354,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -274,7 +393,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -284,6 +408,18 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
"
`;
@@ -330,7 +330,7 @@ function createRealtimeLogStream(
? 'http://localhost:4999/logs/'
: ctx.env.EXPO_STAGING
? 'https://staging-logs.expo.dev/logs/'
: undefined;
: 'https://logs.expo.dev/logs/';
const jobRunId = ctx.env.EAS_BUILD_ID;
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
if (!baseUrl || !jobRunId || !robotAccessToken) {
@@ -20,7 +20,12 @@ android {
versionName = versionNameVal
}
}
applicationVariants.all { variant ->
}
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
def easAndroidExtension = project.extensions.getByName('android')
if (easAndroidExtension.hasProperty('applicationVariants')) {
easAndroidExtension.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
@@ -30,5 +35,17 @@ android {
}
}
}
} else {
def easAndroidComponents = project.extensions.getByName('androidComponents')
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
variant.outputs.each { output ->
if (versionCodeVal) {
output.versionCode.set(Integer.parseInt(versionCodeVal))
}
if (versionNameVal) {
output.versionName.set(versionNameVal)
}
}
}
}
`;
@@ -1,8 +1,10 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -294,10 +296,34 @@ export namespace AndroidEmulatorUtils {
export async function startAsync({
deviceName,
env,
logcatDirectory,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<{ emulatorPromise: SpawnPromise<SpawnResult>; serialId: AndroidDeviceSerialId }> {
logcatDirectory: string;
}): Promise<{
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string;
}> {
let logcatOutputPath: string;
try {
await fs.promises.mkdir(logcatDirectory, { recursive: true });
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
const timestamp = Math.floor(Date.now() / 1000)
.toString(16)
.padStart(8, '0');
logcatOutputPath = path.join(
logcatDirectory,
`${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}.log`
);
await fs.promises.writeFile(logcatOutputPath, '');
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator logcat output for ${deviceName}.`, {
cause: err,
});
}
const emulatorPromise = spawn(
`${process.env.ANDROID_HOME}/emulator/emulator`,
[
@@ -306,6 +332,10 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
'-logcat',
'*:v',
'-logcat-output',
logcatOutputPath,
'-avd',
deviceName,
'-accel',
@@ -352,7 +382,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId };
return { emulatorPromise, serialId, logcatOutputPath };
}
export async function waitForReadyAsync({
@@ -1,6 +1,9 @@
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -16,6 +19,8 @@ jest.unmock('fs');
jest.unmock('node:fs');
describe('AndroidEmulatorUtils', () => {
const logcatDirectory = path.join(os.tmpdir(), 'android-emulator-utils-integration-logs');
beforeEach(async () => {
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
for (const { serialId } of devices) {
@@ -42,6 +47,7 @@ describe('AndroidEmulatorUtils', () => {
console.error('Failed to delete emulator', error);
}
}
await fs.promises.rm(logcatDirectory, { force: true, recursive: true });
});
describe('getAvailableDevicesAsync', () => {
@@ -71,6 +77,7 @@ describe('AndroidEmulatorUtils', () => {
({ serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
}));
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
@@ -94,6 +101,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
@@ -123,6 +131,7 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.startAsync({
deviceName: cloneDeviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: serialIdClone,
@@ -156,6 +165,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
@@ -206,6 +216,7 @@ describe('AndroidEmulatorUtils', () => {
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
logcatDirectory,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
@@ -262,6 +273,73 @@ describe('AndroidEmulatorUtils', () => {
expect(attemptCounter).toBe(2);
}, 360_000);
it('captures native logcat output across an ADB server restart', async () => {
const testLogcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
);
const deviceName =
`android-emulator-logcat-e2e-${randomUUID().slice(0, 8)}` as AndroidVirtualDeviceName;
let emulatorPromise: Promise<unknown> | null = null;
let serialId: AndroidDeviceSerialId | null = null;
try {
const logger = createMockLogger({ logToConsole: true });
const marker = `ENG-20762-${randomUUID()}`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger,
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory: testLogcatDirectory,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await spawn('adb', ['kill-server'], { env: process.env });
await spawn('adb', ['start-server'], { env: process.env });
await spawn('adb', ['-s', serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker], {
env: process.env,
});
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
},
{
logger,
retryOptions: {
retries: 10,
retryIntervalMs: 1_000,
},
}
);
} finally {
try {
await AndroidEmulatorUtils.deleteAsync({
...(serialId ? { serialId } : {}),
deviceName,
env: process.env,
});
} catch (error) {
console.warn('Failed to clean up emulator during logcat integration test', error);
}
if (emulatorPromise) {
await emulatorPromise;
}
await fs.promises.rm(testLogcatDirectory, { recursive: true, force: true });
}
}, 180_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -275,6 +353,7 @@ describe('AndroidEmulatorUtils', () => {
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
});
await AndroidEmulatorUtils.waitForReadyAsync({
@@ -1,7 +1,11 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
@@ -17,11 +21,96 @@ const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
let temporaryDirectories: string[] = [];
beforeEach(() => {
temporaryDirectories = [];
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
afterEach(async () => {
await Promise.all(
temporaryDirectories.map(async temporaryDirectory => {
await fs.promises.rm(temporaryDirectory, { force: true, recursive: true });
})
);
});
describe(AndroidEmulatorUtils.startAsync, () => {
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
const child = { pid: 1234, unref: jest.fn() };
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
spawnPromise.child = child;
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
if (command.endsWith('/emulator/emulator')) {
return spawnPromise;
}
if (command === 'adb' && args[0] === 'devices') {
return Promise.resolve({ stdout: 'emulator-5554\tdevice\n', stderr: '' });
}
if (command === 'adb' && args[0] === '-s' && args[2] === 'emu') {
return Promise.resolve({ stdout: `${deviceName}\nOK\n`, stderr: '' });
}
throw new Error(`Unexpected command: ${command} ${args.join(' ')}`);
}) as any);
return { child };
}
it('captures logcat through the emulator process', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: process.env,
logcatDirectory: outputDir,
});
expect(result.logcatOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({ detached: true, stdio: 'inherit' })
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining(['logcat']),
expect.anything()
);
expect(child.unref).toHaveBeenCalled();
});
it('throws a SystemError when the staging directory cannot be prepared', async () => {
const outputDir = '/unwritable/logcat-staging';
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const mkdirError = new Error('mkdir failed');
const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockRejectedValueOnce(mkdirError);
try {
await expect(
AndroidEmulatorUtils.startAsync({
deviceName,
env: process.env,
logcatDirectory: outputDir,
})
).rejects.toEqual(
new SystemError('Failed to prepare Android emulator logcat output for eas-simulator.', {
cause: mkdirError,
})
);
expect(mockedSpawn).not.toHaveBeenCalled();
} finally {
mkdirSpy.mockRestore();
}
});
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
@@ -0,0 +1,138 @@
import { BuildJob, ManagedArtifactType } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createTestAndroidJob, createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { maybeUploadSourceMapAsync, resolveIosSourceMapPathAsync } from '../sourceMaps';
function enableSourceMapUpload<TJob extends BuildJob>(job: TJob): TJob {
return {
...job,
experimental: {
...job.experimental,
uploadSourceMaps: true,
},
};
}
function createContext<TJob extends BuildJob>(
job: TJob,
env: Record<string, string> = {}
): { ctx: BuildContext<TJob>; uploadArtifact: jest.Mock } {
const uploadArtifact = jest.fn(async () => ({ filename: null }));
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_BUILD_RUNNER: 'eas-build',
...env,
},
uploadArtifact,
});
return { ctx, uploadArtifact };
}
describe(maybeUploadSourceMapAsync.name, () => {
it('uploads the final Android source map without sourcesContent', async () => {
const finalSourceMapPath =
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/index.android.bundle.map';
vol.fromJSON({
[finalSourceMapPath]: JSON.stringify({
version: 3,
sources: ['App.tsx'],
sourcesContent: ['customer source'],
names: [],
mappings: '',
sections: [
{
map: {
version: 3,
sources: ['nested.ts'],
sourcesContent: ['nested customer source'],
names: [],
mappings: '',
},
},
],
}),
[`${finalSourceMapPath.slice(0, -4)}.packager.map`]: JSON.stringify({ version: 3 }),
[`${finalSourceMapPath.slice(0, -4)}.compiler.map`]: JSON.stringify({ version: 3 }),
});
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).toHaveBeenCalledWith({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: ['/workingdir/observe-source-maps/android.map'],
},
logger: ctx.logger,
});
const uploadedSourceMap = await fs.readJson('/workingdir/observe-source-maps/android.map');
expect(uploadedSourceMap).not.toHaveProperty('sourcesContent');
expect(uploadedSourceMap.sections[0].map).not.toHaveProperty('sourcesContent');
});
it('does not guess when an Android build produces multiple final source maps', async () => {
vol.fromJSON({
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/one.map':
JSON.stringify({ version: 3 }),
'/workingdir/build/android/other/build/generated/sourcemaps/react/release/two.map':
JSON.stringify({ version: 3 }),
});
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
const markBuildPhaseHasWarnings = jest.spyOn(ctx, 'markBuildPhaseHasWarnings');
await expect(maybeUploadSourceMapAsync(ctx)).resolves.toBeUndefined();
expect(uploadArtifact).not.toHaveBeenCalled();
expect(markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload source map.'
);
});
it('does nothing when source-map upload is disabled', async () => {
const { ctx, uploadArtifact } = createContext(createTestAndroidJob());
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).not.toHaveBeenCalled();
});
it('does not upload source maps from local builds', async () => {
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()), {
EAS_BUILD_RUNNER: 'local-build-plugin',
});
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).not.toHaveBeenCalled();
});
});
describe(resolveIosSourceMapPathAsync.name, () => {
it('uses an absolute worker-owned path by default', async () => {
const { ctx } = createContext(createTestIosJob());
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
'/workingdir/observe-source-maps/main.jsbundle.map'
);
});
it('preserves a configured path relative to the iOS project', async () => {
const { ctx } = createContext(createTestIosJob(), {
SOURCEMAP_FILE: 'build/custom/main.jsbundle.map',
});
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
'/workingdir/build/ios/build/custom/main.jsbundle.map'
);
});
});
@@ -0,0 +1,123 @@
import { BuildJob, ManagedArtifactType, Platform, SystemError } from '@expo/eas-build-job';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
const ANDROID_SOURCE_MAP_PATTERN = 'android/**/build/generated/sourcemaps/react/**/*.map';
const SOURCE_MAP_UPLOAD_DIRECTORY = 'observe-source-maps';
export function isSourceMapUploadEnabled(ctx: BuildContext<BuildJob>): boolean {
return !ctx.isLocal && ctx.job.experimental?.uploadSourceMaps === true;
}
export async function resolveIosSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
const configuredPath = ctx.env.SOURCEMAP_FILE;
const sourceMapPath = configuredPath
? path.resolve(ctx.getReactNativeProjectDirectory(), 'ios', configuredPath)
: path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY, 'main.jsbundle.map');
await fs.ensureDir(path.dirname(sourceMapPath));
return sourceMapPath;
}
export async function maybeUploadSourceMapAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!isSourceMapUploadEnabled(ctx)) {
return;
}
try {
const sourceMapPath = await resolveSourceMapPathAsync(ctx);
const strippedSourceMapPath = await stripSourcesContentAsync(ctx, sourceMapPath);
ctx.logger.info(`Uploading source map: ${sourceMapPath}`);
await ctx.uploadArtifact({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: [strippedSourceMapPath],
},
logger: ctx.logger,
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload source map.');
ctx.markBuildPhaseHasWarnings();
}
}
async function resolveSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
if (ctx.job.platform === Platform.IOS) {
const sourceMapPath = await resolveIosSourceMapPathAsync(ctx);
if (!(await fs.pathExists(sourceMapPath))) {
throw new SystemError(`The iOS source map was not generated at ${sourceMapPath}.`);
}
return sourceMapPath;
}
if (ctx.job.platform === Platform.ANDROID) {
const projectDir = ctx.getReactNativeProjectDirectory();
const sourceMapPaths = (
await fg(ANDROID_SOURCE_MAP_PATTERN, {
absolute: true,
cwd: projectDir,
onlyFiles: true,
})
).filter(sourceMapPath => !isIntermediateAndroidSourceMap(sourceMapPath));
if (sourceMapPaths.length === 0) {
throw new SystemError('The Android build did not generate a final composed source map.');
}
if (sourceMapPaths.length > 1) {
throw new SystemError(
`Found multiple final Android source maps: ${sourceMapPaths.join(', ')}. ` +
'Refusing to upload a source map that may not match the application archive.'
);
}
return sourceMapPaths[0];
}
throw new SystemError('Source-map upload is not supported for this build platform.');
}
function isIntermediateAndroidSourceMap(sourceMapPath: string): boolean {
return sourceMapPath.endsWith('.packager.map') || sourceMapPath.endsWith('.compiler.map');
}
async function stripSourcesContentAsync(
ctx: BuildContext<BuildJob>,
sourceMapPath: string
): Promise<string> {
const sourceMap = JSON.parse(await fs.readFile(sourceMapPath, 'utf8')) as unknown;
if (!isRecord(sourceMap) || sourceMap.version !== 3) {
throw new SystemError(`Invalid source map at ${sourceMapPath}.`);
}
removeSourcesContent(sourceMap);
const uploadDirectory = path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY);
const uploadPath = path.join(uploadDirectory, `${ctx.job.platform}.map`);
await fs.ensureDir(uploadDirectory);
await fs.writeFile(uploadPath, JSON.stringify(sourceMap), 'utf8');
return uploadPath;
}
function removeSourcesContent(value: unknown): void {
if (Array.isArray(value)) {
for (const child of value) {
removeSourcesContent(child);
}
return;
}
if (!isRecord(value)) {
return;
}
delete value.sourcesContent;
for (const child of Object.values(value)) {
removeSourcesContent(child);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.6.0",
"version": "22.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": "21.6.0"
"@expo/steps": "22.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
@@ -44,6 +44,7 @@
"@types/node": "20.14.2",
"@types/prompts": "^2.4.9",
"@vercel/ncc": "^0.38.1",
"arg": "^5.0.2",
"chalk": "4.1.2",
"fs-extra": "^11.2.0",
"ora": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "21.0.0",
"version": "22.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": "21.0.0",
"version": "22.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": "21.6.0",
"version": "22.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": "21.0.0",
"@expo/logger": "22.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "21.0.0",
"@expo/turtle-spawn": "22.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -47,6 +47,9 @@ describe('Android.JobSchema', () => {
},
},
expoBuildUrl: 'https://expo.dev/fake/build/url',
experimental: {
uploadSourceMaps: true,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -49,6 +49,9 @@ describe('Ios.JobSchema', () => {
},
},
expoBuildUrl: 'https://expo.dev/fake/build/url',
experimental: {
uploadSourceMaps: true,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
+2
View File
@@ -119,6 +119,7 @@ export interface Job {
experimental?: {
prebuildCommand?: string;
uploadSourceMaps?: boolean;
};
expoBuildUrl?: string;
githubTriggerOptions?: {
@@ -186,6 +187,7 @@ export const JobSchema = Joi.object({
experimental: Joi.object({
prebuildCommand: Joi.string(),
uploadSourceMaps: Joi.boolean(),
}),
expoBuildUrl: Joi.string().uri().optional(),
githubTriggerOptions: Joi.object({
+1
View File
@@ -1,6 +1,7 @@
export enum ManagedArtifactType {
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
SOURCE_MAP = 'SOURCE_MAP',
/**
* @deprecated
*/
+2
View File
@@ -133,6 +133,7 @@ export interface Job {
experimental?: {
prebuildCommand?: string;
uploadSourceMaps?: boolean;
};
expoBuildUrl?: string;
githubTriggerOptions?: {
@@ -222,6 +223,7 @@ export const JobSchema = Joi.object({
experimental: Joi.object({
prebuildCommand: Joi.string(),
uploadSourceMaps: Joi.boolean(),
}),
expoBuildUrl: Joi.string().uri().optional(),
githubTriggerOptions: Joi.object({
+254 -149
View File
File diff suppressed because it is too large. Load diff
+1199 -49
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.7.0",
"version": "22.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": "21.6.0",
"@expo/eas-json": "21.6.0",
"@expo/eas-build-job": "22.0.0",
"@expo/eas-json": "22.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "21.0.0",
"@expo/logger": "22.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": "21.6.0",
"@expo/steps": "22.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -118,6 +118,7 @@
"prompts": "2.4.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"sandbox-cli-detector": "0.2.0",
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
@@ -38,14 +38,9 @@ export function getMockAppFragment(): AppFragment {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
viewerUserPermission: {
role: Role.Owner,
},
},
};
}
@@ -180,7 +180,7 @@ describe(WorkflowLogView, () => {
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
isForIosSimulator: false,
project: {
app: {
id: mockProjectId,
__typename: 'App',
name: 'App',
@@ -7,6 +7,7 @@ import { Actor, getActorDisplayName } from '../user/User';
import UserSettings from '../user/UserSettings';
import { easCliVersion } from '../utils/easCli';
import { getAgentTelemetryContext } from './agent';
import { getSandboxTelemetryContext } from './sandbox';
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'Mac',
@@ -227,10 +228,12 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
private getRudderStackContext(): Record<string, any> {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
const agent = getAgentTelemetryContext();
const sandboxProvider = getSandboxTelemetryContext();
return {
os: { name: platform, version: os.release() },
device: { type: platform, model: platform },
app: { name: 'eas cli', version: easCliVersion ?? undefined },
...(sandboxProvider ? { sandbox_provider: sandboxProvider } : {}),
...(agent ? { agent } : {}),
};
}
@@ -3,6 +3,7 @@ import RudderAnalytics from '@expo/rudder-sdk-node';
import UserSettings from '../../user/UserSettings';
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
import { getAgentTelemetryContext } from '../agent';
import { getSandboxTelemetryContext } from '../sandbox';
const mockIdentify = jest.fn();
const mockTrack = jest.fn();
@@ -28,8 +29,12 @@ jest.mock('../../user/UserSettings', () => ({
jest.mock('../agent', () => ({
getAgentTelemetryContext: jest.fn(),
}));
jest.mock('../sandbox', () => ({
getSandboxTelemetryContext: jest.fn(),
}));
const getAgentTelemetryContextMock = jest.mocked(getAgentTelemetryContext);
const getSandboxTelemetryContextMock = jest.mocked(getSandboxTelemetryContext);
const userSettingsMock = jest.mocked(UserSettings);
const originalHttpsProxy = process.env.https_proxy;
@@ -42,6 +47,8 @@ beforeEach(() => {
jest.mocked(RudderAnalytics).mockClear();
getAgentTelemetryContextMock.mockReset();
getAgentTelemetryContextMock.mockReturnValue(null);
getSandboxTelemetryContextMock.mockReset();
getSandboxTelemetryContextMock.mockReturnValue(null);
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) => {
if (key === 'analyticsDeviceId') {
return 'persistent-device-id';
@@ -68,14 +75,17 @@ afterAll(() => {
}
});
it('omits agent context when no agent is detected', async () => {
it('omits agent and sandbox context when neither 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() }),
context: expect.not.objectContaining({
agent: expect.anything(),
sandbox_provider: expect.anything(),
}),
})
);
});
@@ -97,3 +107,18 @@ it('adds detected agent context to analytics events', async () => {
})
);
});
it('adds detected sandbox context to analytics events', async () => {
getSandboxTelemetryContextMock.mockReturnValue('e2b');
const analytics = await createAnalyticsAsync();
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
sandbox_provider: 'e2b',
}),
})
);
});
@@ -0,0 +1,60 @@
export {};
const detectSandboxMock = jest.fn();
const logDebugMock = jest.fn();
jest.mock('sandbox-cli-detector', () => ({
detectSandbox: detectSandboxMock,
}));
jest.mock('../../log', () => ({
__esModule: true,
default: {
debug: logDebugMock,
},
}));
beforeEach(() => {
jest.resetModules();
detectSandboxMock.mockReset();
logDebugMock.mockReset();
});
it('returns detected sandbox telemetry context', async () => {
detectSandboxMock.mockReturnValue({
detected: true,
sandbox: { id: 'e2b', name: 'E2B' },
});
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBe('e2b');
});
it('returns null when no known sandbox is detected', async () => {
detectSandboxMock.mockReturnValue({ detected: false, sandbox: null });
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBeNull();
});
it('caches the detected sandbox telemetry context', async () => {
detectSandboxMock.mockReturnValue({
detected: true,
sandbox: { id: 'e2b', name: 'E2B' },
});
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBe('e2b');
expect(getSandboxTelemetryContext()).toBe('e2b');
expect(detectSandboxMock).toHaveBeenCalledTimes(1);
});
it('returns null and logs debug details when detection throws', async () => {
detectSandboxMock.mockImplementation(() => {
throw new Error('boom');
});
const { getSandboxTelemetryContext } = await import('../sandbox');
expect(getSandboxTelemetryContext()).toBeNull();
expect(logDebugMock).toHaveBeenCalledWith('Failed to detect sandbox:', 'boom');
});
+29
View File
@@ -0,0 +1,29 @@
import { detectSandbox } from 'sandbox-cli-detector';
import Log from '../log';
type SandboxTelemetryContext = string | null;
let sandboxTelemetryContext: SandboxTelemetryContext | undefined;
export function getSandboxTelemetryContext(): SandboxTelemetryContext {
if (sandboxTelemetryContext === undefined) {
sandboxTelemetryContext = resolveSandboxTelemetryContext();
}
return sandboxTelemetryContext;
}
function resolveSandboxTelemetryContext(): SandboxTelemetryContext {
try {
const { detected, sandbox } = detectSandbox();
if (!detected || sandbox == null) {
return null;
}
return sandbox.id;
} catch (error: any) {
Log.debug('Failed to detect sandbox:', error?.message ?? error);
return null;
}
}
@@ -74,7 +74,7 @@ function createMockBuildFragment({
id: buildId ?? uuid(),
logFiles: [],
priority: BuildPriority.Normal,
project: {
app: {
__typename: 'App',
slug: 'test-project',
id: projectId,
@@ -74,7 +74,7 @@ function createMockBuildFragment({
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
project: {
app: {
__typename: 'App',
slug: 'test-project',
id: projectId,
@@ -104,6 +104,7 @@ export async function prepareJobAsync(
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
uploadSourceMaps: buildProfile.uploadSourceMaps,
},
mode: buildMode,
triggeredBy: BuildTrigger.EAS_CLI,
@@ -99,6 +99,7 @@ export async function prepareJobAsync(
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
uploadSourceMaps: buildProfile.uploadSourceMaps,
},
mode: buildMode,
triggeredBy: BuildTrigger.EAS_CLI,
-1
View File
@@ -76,7 +76,6 @@ export async function collectMetadataAsync<T extends Platform>(
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
developmentClient: ctx.developmentClient,
requiredPackageManager: ctx.requiredPackageManager ?? undefined,
selectedImage: ctx.buildProfile.image,
customNodeVersion: ctx.buildProfile.node,
simulator: 'simulator' in ctx.buildProfile && ctx.buildProfile.simulator,
};
@@ -597,7 +597,7 @@ export async function downloadAndRunAsync(
simulator?: SimulatorRunTarget
): Promise<void> {
assert(build.artifacts?.applicationArchiveUrl);
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
const cachedAppPath = getEasBuildRunCachedAppPath(build.app.id, build.id, build.platform);
if (await pathExists(cachedAppPath)) {
Log.newLine();
@@ -93,7 +93,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Channel',
value: build.channel,
value: build.updateChannel?.name,
},
{
label: 'SDK Version',
@@ -101,7 +101,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Runtime Version',
value: build.runtimeVersion,
value: build.runtime?.version,
},
{
label: 'Version',
+5 -8
View File
@@ -19,13 +19,10 @@ export function getProjectPageUrl(
}
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
const { project } = build;
const url =
project.__typename !== 'App'
? `/builds/${build.id}`
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
const { app } = build;
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
return new URL(url, getExpoWebsiteBaseUrl()).toString();
}
@@ -37,7 +34,7 @@ export function getArtifactUrl(artifactId: string): string {
export function getInternalDistributionInstallUrl(build: BuildFragment): string {
if (build.platform === AppPlatform.Ios) {
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/v2/projects/${
build.project.id
build.app.id
}/builds/${build.id}/manifest.plist`;
}
@@ -11,7 +11,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -37,7 +37,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -73,7 +73,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -99,7 +99,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -46,7 +46,11 @@ function makeInsights(
id: u.id,
group: u.group,
message: u.message,
runtimeVersion: u.runtimeVersion,
runtime: {
__typename: 'Runtime' as const,
id: `${u.id}-runtime`,
version: u.runtimeVersion,
},
platform: u.platform,
insights: {
__typename: 'UpdateInsights' as const,
@@ -0,0 +1,164 @@
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../graphql/types/SupabaseConnection';
import Log from '../../log';
import {
formatSupabaseOrganization,
formatSupabaseProject,
formatSupabaseProjectLabel,
getSupabaseProjectDashboardUrl,
logNoSupabaseProject,
parseSupabaseProjectRef,
} from '../supabase';
jest.mock('../../log', () => ({
__esModule: true,
default: {
warn: jest.fn(),
log: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
describe('commandUtils/supabase', () => {
const project: SupabaseProjectData = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Demo App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
it('getSupabaseProjectDashboardUrl encodes the project ref', () => {
expect(getSupabaseProjectDashboardUrl({ supabaseProjectRef: 'abc/def' })).toBe(
'https://supabase.com/dashboard/project/abc%2Fdef'
);
});
it('formatSupabaseOrganization prefers a distinct name', () => {
const connection: Pick<
SupabaseConnectionData,
'supabaseOrganizationSlug' | 'supabaseOrganizationName'
> = {
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: 'Org Name',
};
expect(formatSupabaseOrganization(connection)).toBe('Org Name (org-slug)');
});
it('formatSupabaseOrganization falls back to live org name', () => {
const connection = {
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: null as unknown as string,
};
const organizations: SupabaseOrganizationData[] = [
{ id: '1', slug: 'org-slug', name: 'Live Name' },
];
expect(formatSupabaseOrganization(connection, organizations)).toBe('Live Name (org-slug)');
});
it('formatSupabaseOrganization returns slug when name matches or is missing', () => {
expect(
formatSupabaseOrganization({
supabaseOrganizationSlug: 'same',
supabaseOrganizationName: 'same',
})
).toBe('same');
expect(
formatSupabaseOrganization({
supabaseOrganizationSlug: 'only-slug',
supabaseOrganizationName: '',
})
).toBe('only-slug');
});
it('formatSupabaseProjectLabel prefers a distinct name', () => {
expect(formatSupabaseProjectLabel(project)).toBe('Demo App (abcdefghijklmnop)');
});
it('formatSupabaseProjectLabel returns ref when name matches or is missing', () => {
expect(
formatSupabaseProjectLabel({
supabaseProjectRef: 'ref',
supabaseProjectName: 'ref',
})
).toBe('ref');
expect(
formatSupabaseProjectLabel({
supabaseProjectRef: 'ref',
supabaseProjectName: '',
})
).toBe('ref');
});
it('formatSupabaseProject includes key fields', () => {
const formatted = formatSupabaseProject(project);
expect(formatted).toContain('Demo App');
expect(formatted).toContain('abcdefghijklmnop');
expect(formatted).toContain('https://abcdefghijklmnop.supabase.co');
expect(formatted).toContain('us-east-1');
expect(formatted).toContain('https://supabase.com/dashboard/project/abcdefghijklmnop');
});
it('logNoSupabaseProject warns with the project name', () => {
logNoSupabaseProject('my-app');
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('my-app'));
});
});
describe(parseSupabaseProjectRef, () => {
it('accepts a bare reference ID', () => {
expect(parseSupabaseProjectRef('jfurmbuioogljwsqwnpd')).toBe('jfurmbuioogljwsqwnpd');
expect(parseSupabaseProjectRef(' jfurmbuioogljwsqwnpd ')).toBe('jfurmbuioogljwsqwnpd');
});
it('accepts a dashboard URL', () => {
expect(
parseSupabaseProjectRef('https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj')
).toBe('kwdfdxdzurxigtbtwddj');
expect(
parseSupabaseProjectRef(
'https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj/settings/general'
)
).toBe('kwdfdxdzurxigtbtwddj');
});
it('accepts a project API URL', () => {
expect(parseSupabaseProjectRef('https://kwdfdxdzurxigtbtwddj.supabase.co')).toBe(
'kwdfdxdzurxigtbtwddj'
);
});
it('lowercases the reference ID, which the server matches exactly', () => {
expect(parseSupabaseProjectRef('JFURMBUIOOGLJWSQWNPD')).toBe('jfurmbuioogljwsqwnpd');
expect(
parseSupabaseProjectRef('https://supabase.com/dashboard/project/KWDFDXDZURXIGTBTWDDJ')
).toBe('kwdfdxdzurxigtbtwddj');
});
it('rejects a project name with guidance', () => {
expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow(
/not a Supabase project reference ID/
);
expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow(
/Project Settings/
);
});
it('rejects an unrelated URL and an empty value', () => {
expect(() => parseSupabaseProjectRef('https://example.com/foo')).toThrow(
/not a Supabase project reference ID/
);
expect(() => parseSupabaseProjectRef(' ')).toThrow(/No Supabase project given/);
});
it('treats a malformed URL as a non-ref and rejects it', () => {
expect(() => parseSupabaseProjectRef('https://[')).toThrow(
/not a Supabase project reference ID/
);
});
});
@@ -4,7 +4,7 @@ import { anything, instance, mock, when } from 'ts-mockito';
import { Role } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { learnMore } from '../../../../log';
import Log, { learnMore } from '../../../../log';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../../project/projectUtils';
import { promptAsync } from '../../../../prompts';
@@ -32,6 +32,8 @@ describe(getProjectIdAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
@@ -46,18 +48,18 @@ describe(getProjectIdAsync, () => {
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
isExpoAdmin: false,
@@ -196,12 +198,12 @@ describe(getProjectIdAsync, () => {
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
viewerUserPermission: { role: Role.Admin },
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
isExpoAdmin: false,
@@ -379,6 +381,7 @@ describe(getProjectIdAsync, () => {
).resolves.toEqual('2345');
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(Log.warn).toHaveBeenCalledWith('EAS project not configured.');
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'dominik', projectName: 'test' },
@@ -420,13 +423,13 @@ describe(getProjectIdAsync, () => {
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
],
isExpoAdmin: false,
@@ -462,7 +465,7 @@ describe(getProjectIdAsync, () => {
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
viewerUserPermission: { role: Role.Admin },
},
],
isExpoAdmin: false,
@@ -478,7 +481,14 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: false }
)
).rejects.toThrow(
'Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: notnotbrent'
`EAS project not configured. A robot access token cannot configure it interactively. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account <account-name> --non-interactive\n\n` +
`Accounts you can create projects in: notnotbrent\n\n` +
`Alternatively, set the "owner" field in your app config.`
);
expect(promptAsync).not.toHaveBeenCalled();
@@ -493,12 +503,73 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: true }
)
).rejects.toThrow(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account <account-name> --non-interactive\n\n` +
`Accounts you can create projects in: notnotbrent`
);
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
} as any);
await expect(
getProjectIdAsync(
instance(sessionManagerMock),
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).resolves.toEqual('2345');
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'notnotbrent', projectName: 'test' },
{ nonInteractive: true },
expect.anything()
);
});
it('does not warn that the project is not configured in non-interactive mode', async () => {
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).rejects.toThrow();
expect(Log.warn).not.toHaveBeenCalledWith('EAS project not configured.');
});
});
it('throws if writing the ID back to the config fails', async () => {
@@ -17,9 +17,10 @@ import {
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { getUnconfiguredProjectError } from '../../../project/projectNotConfiguredError';
import { promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
import { Actor, getActorUsername } from '../../../user/User';
/**
* Save an EAS project ID to the appropriate field in the app config.
@@ -183,7 +184,9 @@ export async function validateOrSetProjectIdAsync({
throw new Error('This command must be run inside a project directory.');
}
Log.warn('EAS project not configured.');
if (!options.nonInteractive) {
Log.warn('EAS project not configured.');
}
const accountName = await chooseAccountNameForEASProjectAsync(exp, actor, options);
@@ -226,11 +229,11 @@ async function chooseAccountNameForEASProjectAsync(
}
if (actor.__typename === 'Robot') {
throw new Error(
`Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: ${getCreatableAccountNames(
actor
).join(', ')}`
);
throw getUnconfiguredProjectError({
actor,
reason: 'A robot access token cannot configure it interactively.',
additionalFix: 'Alternatively, set the "owner" field in your app config.',
});
}
const allAccounts = actor.accounts;
@@ -239,9 +242,7 @@ async function chooseAccountNameForEASProjectAsync(
}
if (options.nonInteractive) {
throw new Error(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
throw getUnconfiguredProjectError({ actor });
}
const choices = getAccountChoices(
@@ -2,7 +2,7 @@ import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { getEASUpdateURL } from '../../../api';
import { AppFragment } from '../../../graphql/generated';
import { AppFragment, Role } from '../../../graphql/generated';
import { easCliVersion } from '../../../utils/easCli';
import {
cleanAndPrefix,
@@ -62,7 +62,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'testowner-account-id',
name: 'testowner',
users: [],
viewerUserPermission: { role: Role.Owner },
},
...overrides,
});
@@ -126,7 +126,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'test-owner-account-id',
name: 'test-owner',
users: [],
viewerUserPermission: { role: Role.Owner },
},
});
@@ -151,7 +151,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'test-owner-123-account-id',
name: 'test-owner-123',
users: [],
viewerUserPermission: { role: Role.Owner },
},
});
@@ -80,8 +80,7 @@ function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
const permissionsMap = new Map<string, boolean>();
for (const account of actor.accounts) {
const hasPermission =
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
const hasPermission = account.viewerUserPermission.role !== Role.ViewOnly;
permissionsMap.set(account.name, hasPermission);
}
@@ -0,0 +1,96 @@
import chalk from 'chalk';
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../graphql/types/SupabaseConnection';
import Log, { link } from '../log';
export function getSupabaseProjectDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
): string {
return `https://supabase.com/dashboard/project/${encodeURIComponent(project.supabaseProjectRef)}`;
}
function extractProjectRefFromUrl(value: string): string | null {
if (!/^https?:\/\//i.test(value)) {
return null;
}
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
const projectHost = url.hostname.match(/^([a-z0-9]+)\.supabase\./i);
if (projectHost) {
return projectHost[1];
}
const dashboardPath = url.pathname.match(/\/dashboard\/project\/([^/]+)/);
if (dashboardPath) {
return decodeURIComponent(dashboardPath[1]);
}
return null;
}
/**
* Accepts whatever a user is likely to have on hand: the bare reference ID, the dashboard URL, or
* the project API URL. Supabase labels the ref "Reference ID" under Project Settings → General.
*/
export function parseSupabaseProjectRef(input: string): string {
const value = input.trim();
if (!value) {
throw new Error(
'No Supabase project given. Pass the project reference ID or its dashboard URL, for example --link jfurmbuioogljwsqwnpd.'
);
}
const ref = extractProjectRefFromUrl(value) ?? value;
if (!/^[a-z0-9]+$/i.test(ref)) {
throw new Error(
`"${input}" is not a Supabase project reference ID. Supabase shows it as "Reference ID" under Project Settings → General; you can also paste the dashboard URL (https://supabase.com/dashboard/project/<ref>). Note that a project name is not a reference ID.`
);
}
// Refs are lowercase, and the server matches them exactly.
return ref.toLowerCase();
}
/** Prefer human-readable org name; Supabase slugs are often opaque ids like `ycjmzsygzfkryaazoitp`. */
export function formatSupabaseOrganization(
connection: Pick<SupabaseConnectionData, 'supabaseOrganizationSlug' | 'supabaseOrganizationName'>,
organizations: readonly SupabaseOrganizationData[] = []
): string {
const slug = connection.supabaseOrganizationSlug;
const storedName = connection.supabaseOrganizationName;
const liveName = organizations.find(organization => organization.slug === slug)?.name;
const name = storedName || liveName;
if (name && name !== slug) {
return `${name} (${slug})`;
}
return slug;
}
/** Prefer human-readable project name over the opaque project ref. */
export function formatSupabaseProjectLabel(
project: Pick<SupabaseProjectData, 'supabaseProjectRef' | 'supabaseProjectName'>
): string {
const { supabaseProjectRef: ref, supabaseProjectName: name } = project;
if (name && name !== ref) {
return `${name} (${ref})`;
}
return ref;
}
export function formatSupabaseProject(project: SupabaseProjectData): string {
return [
`${chalk.bold('Name')}: ${project.supabaseProjectName}`,
`${chalk.bold('Ref')}: ${project.supabaseProjectRef}`,
`${chalk.bold('URL')}: ${project.supabaseProjectUrl}`,
`${chalk.bold('Region')}: ${project.supabaseRegion}`,
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoSupabaseProject(projectName: string): void {
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
@@ -1,5 +1,4 @@
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import EasCommand from '../../commandUtils/EasCommand';
import { Role } from '../../graphql/generated';
@@ -52,7 +51,7 @@ export default class AccountView extends EasCommand {
return Role.Owner;
}
return nullthrows(account.users.find(user => user.actor.id === actor.id)?.role);
return account.viewerUserPermission.role;
}
private static getLabelForRole(role: Role): string {
@@ -27,8 +27,8 @@ async function cancelBuildAsync(
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
gql`
mutation CancelBuildMutation($buildId: ID!) {
build(buildId: $buildId) {
cancel {
build {
cancelBuild(buildId: $buildId) {
id
status
}
@@ -39,7 +39,7 @@ async function cancelBuildAsync(
)
.toPromise()
);
return data.build.cancel;
return data.build.cancelBuild;
}
export async function selectBuildToCancelAsync(
@@ -235,7 +235,7 @@ export default class Download extends EasCommand {
async getPathToBuildArtifactAsync(build: BuildFragment, platform: AppPlatform): Promise<string> {
const cachedBuildArtifactPath = getEasBuildRunCachedAppPath(
build.project.id,
build.app.id,
build.id,
platform
);
@@ -26,7 +26,7 @@ const insightsResponse = {
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
runtime: { __typename: 'Runtime' as const, id: 'u1-runtime', version: '1.0.0' },
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
+3 -56
View File
@@ -1,5 +1,5 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import { App } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
@@ -15,6 +15,7 @@ import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey';
import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials';
import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey';
import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists';
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
import { Target } from '../credentials/ios/types';
import {
WorkflowJobStatus,
@@ -60,62 +61,8 @@ export async function detectProjectSdkVersionAsync(
}
}
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
const groups = await ascApp.getBetaGroupsAsync({
query: { includes: ['betaTesters'] },
});
group = groups.find(
g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME
);
if (!group) {
group = await ascApp.createBetaGroupAsync({
name: TESTFLIGHT_GROUP_NAME,
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
}
break;
} catch (error: any) {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await sleepAsync(10_000);
continue;
}
}
throw error;
}
}
if (!group) {
throw new Error('Failed to create TestFlight group');
}
const users = await User.getAsync(ascApp.context);
const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN));
const existingEmails = new Set(
group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? []
);
const newTesters = admins
.filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase()))
.map(u => ({
email: u.attributes.email!,
firstName: u.attributes.firstName ?? '',
lastName: u.attributes.lastName ?? '',
}));
if (newTesters.length > 0) {
await group.createBulkBetaTesterAssignmentsAsync(newTesters);
}
await ensureTestFlightGroupExistsAsync(ascApp);
}
/* eslint-disable no-console */
@@ -6,6 +6,7 @@ import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/grap
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import IntegrationsAscConnect from '../connect';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
@@ -18,7 +19,12 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -34,7 +40,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -8,6 +8,7 @@ import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import { toggleConfirmAsync } from '../../../../prompts';
import IntegrationsAscDisconnect from '../disconnect';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
@@ -19,7 +20,12 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -35,7 +41,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -6,6 +6,7 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import IntegrationsAscStatus from '../status';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../log');
@@ -15,7 +16,12 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -31,7 +37,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -9,6 +9,7 @@ import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
@@ -59,7 +60,7 @@ describe(IntegrationsConvexConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
users: [],
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -76,7 +77,7 @@ describe(IntegrationsConvexConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -32,7 +32,7 @@ describe(IntegrationsConvexTeamDelete, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -41,7 +41,7 @@ describe(IntegrationsConvexTeamInvite, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -21,7 +21,7 @@ describe(IntegrationsConvexTeam, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -62,7 +62,12 @@ describe(IntegrationsPostHogConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -70,7 +75,7 @@ describe(IntegrationsPostHogConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: Role.Owner },
};
const mockConnection: PostHogOrganizationConnectionData = {
@@ -0,0 +1,961 @@
import spawnAsync from '@expo/spawn-async';
import openBrowserAsync from 'better-opn';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import * as supabaseCommandUtils from '../../../../commandUtils/supabase';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../../../graphql/types/SupabaseConnection';
import Log from '../../../../log';
import { createOrModifyExpoConfigAsync } from '../../../../project/expoConfig';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
import { printJsonOnlyOutput } from '../../../../utils/json';
import {
BackgroundJobReceiptPollError,
BackgroundJobReceiptPollErrorType,
pollForBackgroundJobReceiptAsync,
} from '../../../../utils/pollForBackgroundJobReceiptAsync';
import IntegrationsSupabaseConnect from '../connect';
jest.mock('@expo/spawn-async');
jest.mock('better-opn');
jest.mock('fs-extra');
jest.mock('../../../../project/expoConfig');
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../../graphql/mutations/SupabaseMutation');
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../utils/pollForBackgroundJobReceiptAsync', () => ({
...jest.requireActual('../../../../utils/pollForBackgroundJobReceiptAsync'),
pollForBackgroundJobReceiptAsync: jest.fn(),
}));
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
warn: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
text: '',
}),
}));
describe(IntegrationsSupabaseConnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
};
const mockConnection: SupabaseConnectionData = {
id: 'connection-1',
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: 'Primary Org',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockOrganizations: SupabaseOrganizationData[] = [
{ id: 'org-1', slug: 'org-slug', name: 'Primary Org' },
{ id: 'org-2', slug: 'other-org', name: 'Other Org' },
];
const mockProject: SupabaseProjectData = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Test App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
function createCommand(argv: string[]): IntegrationsSupabaseConnect {
const command = new IntegrationsSupabaseConnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp', plugins: [] },
projectDir: '/test/project',
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.spyOn(Log, 'debug').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest.mocked(selectAsync).mockResolvedValue('americas');
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(promptAsync).mockResolvedValue({ linkValue: mockProject.supabaseProjectRef });
jest.mocked(spawnAsync).mockResolvedValue({} as any);
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as any);
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
jest
.mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync)
.mockResolvedValue(['production', 'preview', 'development']);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
id: 'env-var-1',
scope: EnvironmentVariableScope.Project,
} as any);
jest
.mocked(EnvironmentVariableMutation.updateAsync)
.mockResolvedValue({ id: 'env-var-1' } as any);
jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({ id: 'env-var-extra' });
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValue(mockConnection);
jest
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
.mockResolvedValue(mockOrganizations);
jest
.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync)
.mockResolvedValue('sb_publishable_test');
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.provisionSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-1',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: { supabaseProjectRef: mockProject.supabaseProjectRef },
} as any);
});
it('reuses an existing connection and project and writes public env vars', async () => {
await createCommand([]).runAsync();
expect(SupabaseMutation.beginSupabaseOAuthAsync).not.toHaveBeenCalled();
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(spawnAsync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['expo', 'install', '@supabase/supabase-js', 'expo-sqlite']),
expect.objectContaining({
cwd: '/test/project',
})
);
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
const createdNames = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.map(call => call[1].name);
expect(createdNames).toEqual([
'EXPO_PUBLIC_SUPABASE_URL',
'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY',
]);
expect(
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mock.calls[0][1].visibility
).toBe(EnvironmentVariableVisibility.Public);
});
it('loads organizations once when formatting the existing connection label', async () => {
await createCommand([]).runAsync();
expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalledTimes(1);
});
it('formats the connection when organization listing fails', async () => {
jest
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
.mockRejectedValue(new Error('skip'));
await createCommand(['--overwrite']).runAsync();
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Primary Org'));
});
it('strips EXPO_LOCAL / EXPO_STAGING / EXPO_UNIVERSE_DIR from the expo install env', async () => {
const previous = {
EXPO_LOCAL: process.env.EXPO_LOCAL,
EXPO_STAGING: process.env.EXPO_STAGING,
EXPO_UNIVERSE_DIR: process.env.EXPO_UNIVERSE_DIR,
};
process.env.EXPO_LOCAL = '1';
process.env.EXPO_STAGING = '1';
process.env.EXPO_UNIVERSE_DIR = '/tmp/universe';
try {
await createCommand([]).runAsync();
const spawnOptions = jest.mocked(spawnAsync).mock.calls[0]?.[2] as {
env?: NodeJS.ProcessEnv;
};
expect(spawnOptions.env?.EXPO_LOCAL).toBeUndefined();
expect(spawnOptions.env?.EXPO_STAGING).toBeUndefined();
expect(spawnOptions.env?.EXPO_UNIVERSE_DIR).toBeUndefined();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it('links an existing project and skips provisioning', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
await createCommand(['--link', mockProject.supabaseProjectRef]).runAsync();
expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
supabaseProjectRef: mockProject.supabaseProjectRef,
});
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalled();
expect(SupabaseQuery.getSupabaseProjectByAppIdAsync).toHaveBeenCalledTimes(1);
});
it('provisions via background receipt when no project is linked', async () => {
jest
.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockProject);
await createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync();
expect(SupabaseMutation.provisionSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
region: 'americas',
});
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(
graphqlClient,
expect.anything(),
expect.objectContaining({
maxChecks: 420,
maxConsecutiveFetchErrors: 3,
})
);
});
it('surfaces a permanent provision failure with a --link hint', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue(
new BackgroundJobReceiptPollError({
errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY,
receiptErrorMessage: 'name already taken',
})
);
await expect(
createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync()
).rejects.toThrow(/name already taken[\s\S]*--link/);
});
it('maps NULL_RECEIPT poll errors to a recoverable timeout hint', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue(
new BackgroundJobReceiptPollError({
errorType: BackgroundJobReceiptPollErrorType.NULL_RECEIPT,
})
);
await expect(
createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync()
).rejects.toThrow(/Timed out or lost contact[\s\S]*--link/);
});
it('switches organization when --organization differs from the connection', async () => {
jest
.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockProject);
jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue({
...mockConnection,
supabaseOrganizationSlug: 'other-org',
supabaseOrganizationName: 'Other Org',
});
await createCommand(['--region', 'americas', '--organization', 'other-org']).runAsync();
expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).toHaveBeenCalledWith(
graphqlClient,
{
supabaseConnectionId: mockConnection.id,
organizationSlug: 'other-org',
}
);
});
it('resets the connection with --reauth, then links an existing project by default', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(mockConnection)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
// Cascade delete removes the EAS project link with the connection.
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id);
jest.mocked(selectAsync).mockResolvedValueOnce('link').mockResolvedValue('americas');
jest.mocked(promptAsync).mockResolvedValue({ linkValue: mockProject.supabaseProjectRef });
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
await createCommand(['--reauth']).runAsync();
expect(SupabaseMutation.disconnectSupabaseAsync).toHaveBeenCalledWith(
graphqlClient,
mockConnection.id
);
expect(SupabaseMutation.beginSupabaseOAuthAsync).toHaveBeenCalled();
expect(openBrowserAsync).toHaveBeenCalled();
expect(selectAsync).toHaveBeenCalledWith(
expect.stringContaining('previous EAS project link was removed'),
expect.arrayContaining([
expect.objectContaining({ value: 'link' }),
expect.objectContaining({ value: 'provision' }),
]),
expect.objectContaining({ initial: 'link' })
);
const promptOpts = jest.mocked(promptAsync).mock.calls[0]?.[0] as {
validate?: (value: string) => true | string;
};
expect(promptOpts.validate?.(mockProject.supabaseProjectRef)).toBe(true);
expect(promptOpts.validate?.('not a ref')).toEqual(expect.any(String));
const parseSpy = jest
.spyOn(supabaseCommandUtils, 'parseSupabaseProjectRef')
.mockImplementationOnce(() => {
throw 'nope';
});
expect(promptOpts.validate?.('x')).toBe('Invalid project ref or URL');
parseSpy.mockRestore();
expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
supabaseProjectRef: mockProject.supabaseProjectRef,
});
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('provisions after --reauth when the user chooses provision', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(mockConnection)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest
.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id);
jest
.mocked(selectAsync)
.mockResolvedValueOnce('provision')
.mockResolvedValueOnce('org-slug')
.mockResolvedValue('americas');
await createCommand(['--reauth']).runAsync();
expect(SupabaseMutation.provisionSupabaseProjectAsync).toHaveBeenCalled();
expect(SupabaseMutation.linkSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('skips the post-reauth prompt when --link is passed', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(mockConnection)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id);
jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject);
await createCommand(['--reauth', '--link', mockProject.supabaseProjectRef]).runAsync();
expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
supabaseProjectRef: mockProject.supabaseProjectRef,
});
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(selectAsync).not.toHaveBeenCalledWith(
expect.stringContaining('previous EAS project link was removed'),
expect.anything(),
expect.anything()
);
});
it('rejects --reauth in non-interactive mode when a connection exists', async () => {
await expect(createCommand(['--reauth', '--non-interactive']).runAsync()).rejects.toThrow(
/non-interactive/
);
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
});
it('ignores --reauth when there is no connection', async () => {
jest
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({
url: 'https://api.supabase.com/v1/oauth/authorize',
});
await createCommand(['--reauth']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('--reauth ignored'));
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
});
it('prints json output for an already-connected project', async () => {
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
project: expect.objectContaining({ ref: mockProject.supabaseProjectRef }),
envLocalWritten: true,
manualSteps: [],
})
);
});
it('requires --region in non-interactive mode when provisioning', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(/--region/);
});
it('provisions an additional project for --environment preview', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
await createCommand([
'--environment',
'preview',
'--region',
'americas',
'--overwrite',
]).runAsync();
expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
appId: testProjectId,
region: 'americas',
projectNameSuffix: 'preview',
})
);
expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://previewref123456.supabase.co',
environments: ['preview'],
}),
testProjectId
);
expect(spawnAsync).not.toHaveBeenCalled();
});
it('surfaces additional provision failures with --link guidance', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue(
new BackgroundJobReceiptPollError({
errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY,
receiptErrorMessage: 'maximum limits for the number of active free projects',
})
);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/maximum limits for the number of active free projects[\s\S]*--environment preview(?!.*--link)/
);
});
it('peels preview off the primary env var when adding --environment preview', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'primary-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://primary.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'preview', 'development'],
},
{
id: 'primary-key',
name: 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY',
value: 'sb_publishable_primary',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'preview', 'development'],
},
] as any);
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
await createCommand([
'--environment',
'preview',
'--region',
'americas',
'--overwrite',
]).runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
id: 'primary-url',
environments: ['production', 'development'],
})
);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://previewref123456.supabase.co',
environments: ['preview'],
}),
testProjectId
);
});
it('rejects empty --environment', async () => {
await expect(createCommand(['--environment', ' ']).runAsync()).rejects.toThrow(
/at least one EAS environment/
);
});
it('rejects unknown --environment in non-interactive mode', async () => {
await expect(
createCommand([
'--environment',
'prevew',
'--region',
'americas',
'--non-interactive',
]).runAsync()
).rejects.toThrow(/EAS environment\(s\) not found/);
});
it('proceeds with unknown --environment after confirmation', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'stagingref123456',
supabaseProjectUrl: 'https://stagingref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_staging',
},
} as any);
jest.mocked(confirmAsync).mockResolvedValue(true);
await createCommand(['--environment', 'staging', '--region', 'americas']).runAsync();
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/EAS environment "staging" is not used on this project yet.*Continue provisioning\?/i
),
})
);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
name: 'EXPO_PUBLIC_SUPABASE_URL',
environments: ['staging'],
}),
testProjectId
);
});
it('aborts when declining to create an unknown --environment', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
createCommand(['--environment', 'staging', '--region', 'americas']).runAsync()
).rejects.toThrow(/Canceled\. No additional Supabase project was provisioned/);
expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('rejects --environment with --link', async () => {
await expect(
createCommand(['--environment', 'preview', '--link', 'abcdefghijklmnop']).runAsync()
).rejects.toThrow(/Cannot combine --environment with --link/);
});
it('reconciles split env vars on a plain reconnect', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'primary-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://primary.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'development'],
},
{
id: 'preview-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://preview.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['preview'],
},
] as any);
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(
graphqlClient,
'preview-url'
);
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
id: 'primary-url',
value: mockProject.supabaseProjectUrl,
environments: ['production', 'preview', 'development'],
})
);
});
it('rejects --environment in non-interactive mode when env vars would be skipped', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'primary-url',
name: 'EXPO_PUBLIC_SUPABASE_URL',
value: 'https://primary.supabase.co',
scope: EnvironmentVariableScope.Project,
environments: ['production', 'preview', 'development'],
},
] as any);
await expect(
createCommand([
'--environment',
'preview',
'--region',
'americas',
'--non-interactive',
]).runAsync()
).rejects.toThrow(/--overwrite/);
expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('rejects --environment without a primary project', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas']).runAsync()
).rejects.toThrow(/without --environment first/);
});
it('continues and still writes env vars when SDK installation fails', async () => {
jest.mocked(spawnAsync).mockRejectedValue(new Error('npm exploded'));
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install'));
});
it('on a dynamic config, shows Expo install guidance and skips a second plugin rewrite', async () => {
const expoGuidance = [
'Cannot automatically write to dynamic config at: app.config.js',
'Add the following to your Expo config',
'',
'{',
' "plugins": [',
' "expo-sqlite"',
' ]',
'}',
].join('\n');
jest.mocked(spawnAsync).mockRejectedValue(
Object.assign(new Error('Process exited with non-zero code: 1'), {
stdout: `${expoGuidance}\n`,
stderr: '',
})
);
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Cannot automatically write to dynamic config at: app.config.js')
);
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('"expo-sqlite"'));
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining("didn't install"));
});
it('rejects --environment with --reauth', async () => {
await expect(
createCommand(['--environment', 'preview', '--reauth']).runAsync()
).rejects.toThrow(/Cannot combine --environment with --reauth/);
});
it('warns when --link differs from an already linked project', async () => {
await createCommand(['--link', 'differentref12345', '--overwrite']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring --link'));
expect(SupabaseMutation.linkSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('warns when --region is passed for an already linked project', async () => {
await createCommand(['--region', 'emea', '--overwrite']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Ignoring --region/--organization')
);
});
it('throws when primary provision succeeds without a project link', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: { supabaseProjectRef: 'newref1234567890' },
} as any);
await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow(
/link with --link newref1234567890/
);
});
it('throws when primary provision succeeds without a project ref in the receipt', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: {},
} as any);
await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow(
/Provision succeeded but the Expo project link was not found/
);
});
it('throws when primary provision receipt data is not an object', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-1',
resultData: 'not-an-object',
} as any);
await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow(
/Provision succeeded but the Expo project link was not found/
);
});
it('rejects --environment without a connection', async () => {
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(null);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas']).runAsync()
).rejects.toThrow(/No Supabase connection found/);
});
it('rejects --organization with --environment', async () => {
await expect(
createCommand([
'--environment',
'preview',
'--region',
'americas',
'--organization',
'other-org',
'--overwrite',
]).runAsync()
).rejects.toThrow(/Cannot use --organization with --environment/);
});
it('throws when additional provision omits credentials', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: { supabaseProjectRef: 'previewref123456' },
} as any);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/did not return project credentials[\s\S]*Do not re-run connect --environment/
);
});
it('throws when additional env writes partially skip', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValueOnce([]) // ensure: URL
.mockResolvedValueOnce([]) // ensure: KEY
.mockResolvedValueOnce([]) // upsert URL: create
.mockResolvedValueOnce([
{
id: 'existing-key',
name: 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY',
value: 'different',
scope: EnvironmentVariableScope.Project,
environments: ['preview'],
},
] as any); // upsert KEY: exact match
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
createCommand(['--environment', 'preview', '--region', 'americas']).runAsync()
).rejects.toThrow(
/did not write all EAS environment variables[\s\S]*Do not re-run connect --environment[\s\S]*sb_publishable_preview/
);
});
it('prints json for additional environment setup', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest
.mocked(SupabaseMutation.listSupabaseOrganizationsAsync)
.mockRejectedValue(new Error('skip'));
await createCommand([
'--environment',
'preview',
'--region',
'americas',
'--overwrite',
'--json',
]).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
additionalProject: expect.objectContaining({
ref: 'previewref123456',
region: 'americas',
}),
environments: ['preview'],
})
);
});
it('wraps env write failures after additional provision', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mockRejectedValue(new Error('graphql down'));
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/could not write EAS environment variables.*graphql down[\s\S]*Do not re-run connect --environment[\s\S]*EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_preview/
);
});
it('wraps non-Error env write failures after additional provision', async () => {
jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({
id: 'receipt-additional',
} as any);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({
id: 'receipt-additional',
resultData: {
supabaseProjectRef: 'previewref123456',
supabaseProjectUrl: 'https://previewref123456.supabase.co',
supabaseRegion: 'us-east-1',
publishableKey: 'sb_publishable_preview',
},
} as any);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockRejectedValue('graphql down');
await expect(
createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync()
).rejects.toThrow(
/could not write EAS environment variables.*graphql down[\s\S]*Do not re-run connect --environment/
);
});
});
@@ -0,0 +1,130 @@
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 { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import { SupabaseProjectData } from '../../../../graphql/types/SupabaseConnection';
import Log from '../../../../log';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsSupabaseDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsSupabaseDashboard, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockProject: SupabaseProjectData = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Test App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
function createCommand(argv: string[]): IntegrationsSupabaseDashboard {
const command = new IntegrationsSupabaseDashboard(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
});
it('opens the Supabase project dashboard', async () => {
await createCommand([]).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://supabase.com/dashboard/project/abcdefghijklmnop'
);
});
it('prints the dashboard URL as JSON', async () => {
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
dashboardUrl: 'https://supabase.com/dashboard/project/abcdefghijklmnop',
});
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('warns when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('emits a null dashboardUrl as JSON when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ dashboardUrl: null });
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('prints the dashboard URL in non-interactive mode', async () => {
await createCommand(['--non-interactive']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith('https://supabase.com/dashboard/project/abcdefghijklmnop');
});
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
ora.mockReturnValue(spinner);
await createCommand([]).runAsync();
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
it('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
ora.mockReturnValue(spinner);
await expect(createCommand([]).runAsync()).rejects.toThrow('no browser');
expect(spinner.fail).toHaveBeenCalled();
});
});
@@ -0,0 +1,135 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
import { SupabaseProjectData } from '../../../../graphql/types/SupabaseConnection';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsSupabaseDisconnect from '../disconnect';
jest.mock('../../../../graphql/queries/SupabaseQuery');
jest.mock('../../../../graphql/mutations/SupabaseMutation');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: jest.fn(),
}));
import { ora } from '../../../../ora';
function mockOraSpinner(): {
start: jest.Mock;
succeed: jest.Mock;
fail: jest.Mock;
} {
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as never);
return spinner;
}
describe(IntegrationsSupabaseDisconnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockProject: SupabaseProjectData = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Test App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
function createCommand(argv: string[]): IntegrationsSupabaseDisconnect {
const command = new IntegrationsSupabaseDisconnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
mockOraSpinner();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(SupabaseMutation.deleteSupabaseProjectAsync).mockResolvedValue(mockProject.id);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('removes the project link after confirmation', async () => {
await createCommand([]).runAsync();
expect(SupabaseMutation.deleteSupabaseProjectAsync).toHaveBeenCalledWith(
graphqlClient,
mockProject.id
);
});
it('skips confirmation with --yes', async () => {
await createCommand(['--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(SupabaseMutation.deleteSupabaseProjectAsync).toHaveBeenCalled();
});
it('cancels when confirmation is declined', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(SupabaseMutation.deleteSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('prints json when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ id: null });
});
it('warns when no project is linked', async () => {
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
expect(SupabaseMutation.deleteSupabaseProjectAsync).not.toHaveBeenCalled();
});
it('prints json with id and ref on success', async () => {
await createCommand(['--json', '--yes']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
id: mockProject.id,
ref: mockProject.supabaseProjectRef,
});
});
it('fails the spinner and rethrows when delete fails', async () => {
jest
.mocked(SupabaseMutation.deleteSupabaseProjectAsync)
.mockRejectedValue(new Error('delete failed'));
const spinner = mockOraSpinner();
await expect(createCommand(['--yes']).runAsync()).rejects.toThrow('delete failed');
expect(spinner.fail).toHaveBeenCalledWith('Failed to remove Supabase project link');
});
});
@@ -0,0 +1,663 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { z } from 'zod';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
formatSupabaseOrganization,
formatSupabaseProjectLabel,
getSupabaseProjectDashboardUrl,
parseSupabaseProjectRef,
} from '../../../commandUtils/supabase';
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../../graphql/types/SupabaseConnection';
import Log, { link } from '../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { promptAsync, selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
parseEnvironmentFlag,
resolveTargetEnvironmentsAsync,
} from '../../../environments/resolve';
import { upsertEnvVarAsync, upsertEnvVarsSequentiallyAsync } from '../../../environments/variables';
import { writeEnvLocalAsync } from '../../../integrations/shared/envFile';
import { setupSdkAndConfigAsync } from '../../../integrations/shared/sdk';
import {
EAS_SUPABASE_ENVIRONMENTS,
EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME,
EAS_SUPABASE_URL_ENV_VAR_NAME,
SUPABASE_ENV_LABEL,
confirmOverwriteForAdditionalProjectAsync,
createSupabaseEnvVars,
supabaseEnvironmentCancelMessage,
supabaseMoveConfirmMessage,
} from '../../../integrations/supabase/env';
import {
additionalProvisionFailureHint,
authorizeViaBrowserAsync,
loadOrganizationsBestEffortAsync,
pollProvisionReceiptAsync,
primaryProvisionFailureHint,
projectNameSuffixForEnvironments,
resolveOrganizationAsync,
resolvePublishableKeyAsync,
resolveRegionAsync,
} from '../../../integrations/supabase/provision';
const SDK_PACKAGES = ['@supabase/supabase-js', 'expo-sqlite'];
const CONFIG_PLUGIN = 'expo-sqlite';
const PrimaryProvisionResultSchema = z.object({
supabaseProjectId: z.string().optional(),
supabaseProjectRef: z.string().optional(),
});
const AdditionalProvisionResultSchema = z.object({
supabaseProjectRef: z.string(),
supabaseProjectName: z.string().optional(),
supabaseProjectUrl: z.string(),
supabaseRegion: z.string().optional(),
publishableKey: z.string(),
});
type AdditionalProvisionResult = {
supabaseProjectRef: string;
supabaseProjectName: string;
supabaseProjectUrl: string;
supabaseRegion: string;
publishableKey: string;
};
export default class IntegrationsSupabaseConnect extends EasCommand {
static override description =
'authorize Supabase, link or provision a project, install the SDK, and write EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --link <project-url>',
'<%= config.bin %> <%= command.id %> --environment preview',
'<%= config.bin %> <%= command.id %> --environment preview --region americas --non-interactive --overwrite',
'<%= config.bin %> <%= command.id %> --reauth',
];
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
region: Flags.string({
description:
'Region when provisioning (americas | emea | apac, or e.g. us-east-1). Required with --non-interactive if provisioning. Ignored with --link or if already linked',
}),
organization: Flags.string({
description:
'Supabase org slug for a new primary project. Ignored with --link; incompatible with --environment',
}),
link: Flags.string({
description:
'Existing project URL or Reference ID (Project Settings → General). Sets the app primary instead of provisioning. Incompatible with --environment',
}),
reauth: Flags.boolean({
description:
'Disconnect the existing Supabase OAuth connection, then re-authorize in the browser. Removes the EAS connection and primary project link (Supabase projects are kept). Then prompts to link an existing project (default) or provision a new one; pass --link to skip the prompt. Interactive only; incompatible with --environment',
default: false,
}),
overwrite: Flags.boolean({
description:
'Replace existing EXPO_PUBLIC_SUPABASE_* in .env.local and EAS without prompting',
default: false,
}),
environment: Flags.string({
description:
'EAS environments for a separate hosted project (e.g. preview). Requires prior primary connect; writes URL/key only there. Incompatible with --link and --reauth',
}),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseConnect);
const {
region: regionFlag,
organization: organizationFlag,
link: linkRefFlag,
reauth,
overwrite,
environment: environmentFlag,
} = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const requestedEnvironments = parseEnvironmentFlag(environmentFlag);
const linkProjectRef = linkRefFlag ? parseSupabaseProjectRef(linkRefFlag) : undefined;
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, projectDir, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseConnect, {
nonInteractive,
withServerSideEnvironment: null,
});
if (requestedEnvironments) {
if (linkRefFlag) {
throw new Error(
'Cannot combine --environment with --link. --link sets the primary project; --environment provisions an additional one for the listed EAS environments.'
);
}
if (reauth) {
throw new Error(
'Cannot combine --environment with --reauth. Re-authorize first, then re-run with --environment.'
);
}
if (organizationFlag) {
throw new Error(
'Cannot use --organization with --environment. The additional project uses the organization from the existing connection.'
);
}
const targetEnvironments = await resolveTargetEnvironmentsAsync(
graphqlClient,
projectId,
requestedEnvironments,
nonInteractive,
{
defaultEnvironments: EAS_SUPABASE_ENVIRONMENTS,
cancelMessage: supabaseEnvironmentCancelMessage,
}
);
await this.runAdditionalEnvironmentSetupAsync({
graphqlClient,
projectId,
targetEnvironments,
regionFlag,
nonInteractive,
overwrite,
jsonFlag,
});
return;
}
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
graphqlClient,
account.id
);
let didReauth = false;
if (reauth) {
if (!connection) {
if (!nonInteractive) {
Log.warn('--reauth ignored: no existing Supabase connection to reset.');
}
} else if (nonInteractive) {
throw new Error(
"--reauth re-authorizes in the browser, which isn't possible in non-interactive mode. Re-run `eas integrations:supabase:connect --reauth` interactively."
);
} else {
Log.warn(
'Resetting the Supabase connection. This removes the EAS connection and its project link (your Supabase projects are preserved). After authorizing, you will be asked to link an existing project (recommended) or provision a new one.'
);
await SupabaseMutation.disconnectSupabaseAsync(graphqlClient, connection.id);
Log.withTick('Reset the existing Supabase connection');
connection = null;
didReauth = true;
}
}
let organizations: SupabaseOrganizationData[] | null = null;
let authorizedConnection: SupabaseConnectionData;
if (connection) {
organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
Log.withTick(
`Using existing Supabase organization ${chalk.bold(
formatSupabaseOrganization(connection, organizations ?? undefined)
)}`
);
authorizedConnection = connection;
} else {
authorizedConnection = await authorizeViaBrowserAsync(graphqlClient, account, nonInteractive);
}
connection = authorizedConnection;
let project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (project) {
Log.withTick(
`Using existing Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`
);
if (linkProjectRef && linkProjectRef !== project.supabaseProjectRef) {
Log.warn(
`Ignoring --link ${chalk.bold(linkProjectRef)}: this app is already linked to ${chalk.bold(
project.supabaseProjectRef
)}. Run \`eas integrations:supabase:disconnect\` first to link a different project.`
);
} else if (regionFlag || organizationFlag) {
Log.warn(
'Ignoring --region/--organization: this app is already linked to a Supabase project.'
);
}
} else if (linkProjectRef) {
// No org picker here: the project ref determines its organization, and the server validates
// that it matches the connection's organization.
project = await SupabaseMutation.linkSupabaseProjectAsync(graphqlClient, {
appId: projectId,
supabaseProjectRef: linkProjectRef,
});
Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`);
} else if (didReauth) {
const afterReauth = await this.resolveProjectAfterReauthAsync({
graphqlClient,
projectId,
accountId: account.id,
connection: authorizedConnection,
organizationFlag,
regionFlag,
nonInteractive,
organizations,
});
project = afterReauth.project;
authorizedConnection = afterReauth.connection;
connection = authorizedConnection;
} else {
authorizedConnection = await resolveOrganizationAsync(
graphqlClient,
account.id,
authorizedConnection,
organizationFlag,
nonInteractive,
organizations
);
connection = authorizedConnection;
const region = await resolveRegionAsync(regionFlag, nonInteractive);
project = await this.provisionAndPollPrimaryProjectAsync(graphqlClient, projectId, region);
}
const publishableKey = await resolvePublishableKeyAsync(graphqlClient, projectId, project);
const envVars = createSupabaseEnvVars(project.supabaseProjectUrl, publishableKey);
const manualSteps = await setupSdkAndConfigAsync(projectDir, exp, {
packages: SDK_PACKAGES,
plugin: CONFIG_PLUGIN,
label: SUPABASE_ENV_LABEL,
jsonFlag,
});
const envLocalWritten = await writeEnvLocalAsync(projectDir, envVars, {
label: SUPABASE_ENV_LABEL,
nonInteractive,
overwrite,
});
const easWritten = await upsertEnvVarsSequentiallyAsync(envVars, envVar =>
upsertEnvVarAsync(
graphqlClient,
projectId,
envVar,
EAS_SUPABASE_ENVIRONMENTS,
nonInteractive,
overwrite,
{ mode: 'replaceOtherEnvironments' }
)
);
if (jsonFlag) {
printJsonOnlyOutput({
organizationConnection: {
id: connection.id,
organizationSlug: connection.supabaseOrganizationSlug,
},
project: {
id: project.id,
ref: project.supabaseProjectRef,
url: project.supabaseProjectUrl,
region: project.supabaseRegion,
},
dashboardUrl: getSupabaseProjectDashboardUrl(project),
envLocalWritten,
environmentVariables: envVars.map((v, i) => ({ name: v.name, easWritten: easWritten[i] })),
manualSteps,
});
return;
}
this.printNextSteps(project, manualSteps);
}
private async resolveProjectAfterReauthAsync({
graphqlClient,
projectId,
accountId,
connection,
organizationFlag,
regionFlag,
nonInteractive,
organizations,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
accountId: string;
connection: SupabaseConnectionData;
organizationFlag: string | undefined;
regionFlag: string | undefined;
nonInteractive: boolean;
organizations: SupabaseOrganizationData[] | null;
}): Promise<{ project: SupabaseProjectData; connection: SupabaseConnectionData }> {
// --reauth already required interactive mode when a connection existed.
const choice = await selectAsync<'link' | 'provision'>(
'The previous EAS project link was removed. What next?',
[
{ title: 'Link an existing Supabase project (recommended)', value: 'link' },
{ title: 'Provision a new Supabase project', value: 'provision' },
],
{ initial: 'link' }
);
if (choice === 'link') {
const { linkValue } = await promptAsync({
type: 'text',
name: 'linkValue',
message: 'Supabase project ref or URL',
validate: (value: string) => {
try {
parseSupabaseProjectRef(value);
return true;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid project ref or URL';
}
},
});
const project = await SupabaseMutation.linkSupabaseProjectAsync(graphqlClient, {
appId: projectId,
supabaseProjectRef: parseSupabaseProjectRef(linkValue),
});
Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`);
return { project, connection };
}
const nextConnection = await resolveOrganizationAsync(
graphqlClient,
accountId,
connection,
organizationFlag,
nonInteractive,
organizations
);
const region = await resolveRegionAsync(regionFlag, nonInteractive);
const project = await this.provisionAndPollPrimaryProjectAsync(
graphqlClient,
projectId,
region
);
return { project, connection: nextConnection };
}
private async provisionAndPollPrimaryProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
region: string
): Promise<SupabaseProjectData> {
const receipt = await SupabaseMutation.provisionSupabaseProjectAsync(graphqlClient, {
appId: projectId,
region,
});
const finalized = await pollProvisionReceiptAsync(graphqlClient, receipt, {
startMessage: 'Provisioning Supabase project (this can take a minute)…',
waitingMessage: 'Waiting for Expo to finish Supabase setup…',
failureMessage: 'Failed to provision the Supabase project',
failureHint: primaryProvisionFailureHint(),
});
const parsedResult = PrimaryProvisionResultSchema.safeParse(finalized.resultData);
const resultData = parsedResult.success ? parsedResult.data : undefined;
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId, {
useCache: false,
});
if (!project) {
throw new Error(
resultData?.supabaseProjectRef
? `Provision succeeded for project ${resultData.supabaseProjectRef}, but the Expo project link was not found. Try again, or link with --link ${resultData.supabaseProjectRef}.`
: 'Provision succeeded but the Expo project link was not found. Try again, or link an existing project with --link.'
);
}
Log.withTick(`Provisioned Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`);
return project;
}
private async runAdditionalEnvironmentSetupAsync({
graphqlClient,
projectId,
targetEnvironments,
regionFlag,
nonInteractive,
overwrite,
jsonFlag,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
targetEnvironments: string[];
regionFlag: string | undefined;
nonInteractive: boolean;
overwrite: boolean;
jsonFlag: boolean;
}): Promise<void> {
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
graphqlClient,
account.id
);
if (!connection) {
throw new Error(
'No Supabase connection found. Run `eas integrations:supabase:connect` first, then re-run with --environment.'
);
}
const primaryProject = await SupabaseQuery.getSupabaseProjectByAppIdAsync(
graphqlClient,
projectId
);
if (!primaryProject) {
throw new Error(
'No primary Supabase project is linked yet. Run `eas integrations:supabase:connect` without --environment first.'
);
}
const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
Log.withTick(
`Using existing Supabase organization ${chalk.bold(
formatSupabaseOrganization(connection, organizations ?? undefined)
)}`
);
Log.withTick(
`Primary project remains ${chalk.bold(formatSupabaseProjectLabel(primaryProject))}; provisioning an additional project for ${targetEnvironments.join(', ')}`
);
const region = await resolveRegionAsync(regionFlag, nonInteractive);
const projectNameSuffix = projectNameSuffixForEnvironments(targetEnvironments);
// Fail closed before billing: refuse to provision if we already know env writes will be skipped.
const { forceOverwrite } = await confirmOverwriteForAdditionalProjectAsync(
graphqlClient,
projectId,
targetEnvironments,
nonInteractive,
overwrite
);
const effectiveOverwrite = overwrite || forceOverwrite;
const additional = await this.provisionAndPollAdditionalProjectAsync(graphqlClient, projectId, {
region,
projectNameSuffix,
targetEnvironments,
});
const envVars = createSupabaseEnvVars(additional.supabaseProjectUrl, additional.publishableKey);
const dashboardUrl = getSupabaseProjectDashboardUrl({
supabaseProjectRef: additional.supabaseProjectRef,
});
let easWritten: boolean[] = [];
try {
easWritten = await upsertEnvVarsSequentiallyAsync(envVars, envVar =>
upsertEnvVarAsync(
graphqlClient,
projectId,
envVar,
targetEnvironments,
nonInteractive,
effectiveOverwrite,
{
mode: 'keepOtherEnvironments',
moveConfirmMessage: supabaseMoveConfirmMessage,
}
)
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw this.additionalEnvWriteFailureError({
additional,
targetEnvironments,
dashboardUrl,
headline: `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but could not write EAS environment variables for ${targetEnvironments.join(', ')}: ${message}`,
});
}
if (easWritten.some(written => !written)) {
throw this.additionalEnvWriteFailureError({
additional,
targetEnvironments,
dashboardUrl,
headline: `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but did not write all EAS environment variables for ${targetEnvironments.join(', ')}.`,
});
}
if (jsonFlag) {
printJsonOnlyOutput({
additionalProject: {
ref: additional.supabaseProjectRef,
url: additional.supabaseProjectUrl,
region: additional.supabaseRegion,
},
environments: targetEnvironments,
dashboardUrl,
environmentVariables: envVars.map((v, i) => ({ name: v.name, easWritten: easWritten[i] })),
});
return;
}
Log.addNewLineIfNone();
Log.log(chalk.green('Additional Supabase project is ready!'));
Log.newLine();
Log.log(`${chalk.bold('Dashboard')}: ${link(dashboardUrl, { dim: false })}`);
Log.log(
`EAS environment variables updated for: ${targetEnvironments.map(env => chalk.bold(env)).join(', ')}`
);
Log.log(
`Primary linked project is unchanged (${chalk.bold(primaryProject.supabaseProjectRef)}).`
);
}
private async provisionAndPollAdditionalProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
{
region,
projectNameSuffix,
targetEnvironments,
}: { region: string; projectNameSuffix: string; targetEnvironments: string[] }
): Promise<AdditionalProvisionResult> {
const receipt = await SupabaseMutation.provisionAdditionalSupabaseProjectAsync(graphqlClient, {
appId: projectId,
region,
projectNameSuffix,
});
const finalized = await pollProvisionReceiptAsync(graphqlClient, receipt, {
startMessage: `Provisioning additional Supabase project for ${targetEnvironments.join(', ')}…`,
waitingMessage: 'Waiting for Expo to finish additional Supabase setup…',
failureMessage: 'Failed to provision the additional Supabase project',
failureHint: additionalProvisionFailureHint(targetEnvironments),
});
const parsedResult = AdditionalProvisionResultSchema.safeParse(finalized.resultData);
if (!parsedResult.success) {
throw new Error(
'Additional provision succeeded but did not return project credentials. Open your Supabase dashboard, copy the project URL and publishable key, and set EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on the target EAS environments. Do not re-run connect --environment — that would create another project.'
);
}
const resultData = parsedResult.data;
const additional: AdditionalProvisionResult = {
supabaseProjectRef: resultData.supabaseProjectRef,
supabaseProjectName: resultData.supabaseProjectName ?? resultData.supabaseProjectRef,
supabaseProjectUrl: resultData.supabaseProjectUrl,
supabaseRegion: resultData.supabaseRegion ?? region,
publishableKey: resultData.publishableKey,
};
Log.withTick(
`Provisioned additional Supabase project ${chalk.bold(formatSupabaseProjectLabel(additional))}`
);
return additional;
}
private additionalEnvWriteFailureError({
additional,
targetEnvironments,
dashboardUrl,
headline,
}: {
additional: Pick<AdditionalProvisionResult, 'supabaseProjectUrl' | 'publishableKey'>;
targetEnvironments: string[];
dashboardUrl: string;
headline: string;
}): Error {
return new Error(
[
headline,
`Do not re-run connect --environment — the Supabase project already exists. Set these on ${targetEnvironments.join(', ')}:`,
` EXPO_PUBLIC_SUPABASE_URL=${additional.supabaseProjectUrl}`,
` EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${additional.publishableKey}`,
`Dashboard: ${dashboardUrl}`,
].join('\n')
);
}
private printNextSteps(project: SupabaseProjectData, manualSteps: string[]): void {
Log.addNewLineIfNone();
Log.log(chalk.green('Supabase is connected!'));
Log.newLine();
Log.log(
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}`
);
Log.newLine();
Log.log('Next steps:');
Log.log(
` 1. Create a Supabase client (e.g. ${chalk.bold('src/lib/supabase.ts')}) following our guide (${chalk.cyan('https://docs.expo.dev/guides/using-supabase')}). It reads ${chalk.bold(EAS_SUPABASE_URL_ENV_VAR_NAME)} and ${chalk.bold(EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME)}, and runs in Expo Go.`
);
Log.log(
` 2. For local development, run ${chalk.cyan('supabase start')} and point ${chalk.bold(EAS_SUPABASE_URL_ENV_VAR_NAME)} at the local stack (see the guide).`
);
Log.log(
` 3. For a separate hosted project for Preview (or other EAS environments), re-run ${chalk.cyan('eas integrations:supabase:connect --environment preview')}.`
);
if (manualSteps.length > 0) {
Log.newLine();
Log.warn('Finish setup manually:');
for (const step of manualSteps) {
if (step.includes('\n')) {
for (const line of step.split('\n')) {
Log.warn(` ${line}`);
}
} else {
Log.warn(` • ${step}`);
}
}
}
}
}
@@ -0,0 +1,82 @@
import openBrowserAsync from 'better-opn';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
getSupabaseProjectDashboardUrl,
logNoSupabaseProject,
} from '../../../commandUtils/supabase';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsSupabaseDashboard extends EasCommand {
static override description =
"open this app's primary Supabase project in the dashboard (or print the URL with --non-interactive)";
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --non-interactive',
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseDashboard);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseDashboard, {
nonInteractive,
withServerSideEnvironment: null,
});
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (!project) {
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl: null });
} else {
logNoSupabaseProject(exp.slug);
}
return;
}
const dashboardUrl = getSupabaseProjectDashboardUrl(project);
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl });
return;
}
if (nonInteractive) {
Log.log(dashboardUrl);
return;
}
const spinner = ora('Opening your Supabase dashboard').start();
try {
const opened = await openBrowserAsync(dashboardUrl);
if (opened) {
spinner.succeed('Opened your Supabase dashboard');
} else {
spinner.fail(`Unable to open a web browser. Supabase dashboard: ${dashboardUrl}`);
}
} catch (error) {
spinner.fail(`Unable to open a web browser. Supabase dashboard: ${dashboardUrl}`);
throw error;
}
}
}
@@ -0,0 +1,113 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
formatSupabaseProject,
formatSupabaseProjectLabel,
logNoSupabaseProject,
} from '../../../commandUtils/supabase';
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsSupabaseDisconnect extends EasCommand {
static override description =
"remove this app's Supabase project link from EAS (keeps the Supabase project; does not delete EXPO_PUBLIC_SUPABASE_* env vars)";
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --yes',
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsSupabaseDisconnect);
const { yes } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsSupabaseDisconnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
if (!project) {
if (jsonFlag) {
printJsonOnlyOutput({ id: null });
} else {
logNoSupabaseProject(exp.slug);
}
return;
}
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.log(formatSupabaseProject(project));
Log.newLine();
}
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message:
'Remove this Supabase project link from EAS servers? This does not delete the project on Supabase.',
});
if (!confirmed) {
Log.warn('Canceled removal of the Supabase project link.');
return;
}
} else if (!jsonFlag) {
Log.warn(
'Removing the Supabase project link from EAS servers. This does not delete the project on Supabase.'
);
}
const spinner = jsonFlag ? null : ora('Removing Supabase project link').start();
try {
await SupabaseMutation.deleteSupabaseProjectAsync(graphqlClient, project.id);
spinner?.succeed(
`Removed Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))} from EAS servers`
);
} catch (error) {
spinner?.fail('Failed to remove Supabase project link');
throw error;
}
if (jsonFlag) {
printJsonOnlyOutput({ id: project.id, ref: project.supabaseProjectRef });
return;
}
Log.newLine();
Log.log(
`The ${chalk.bold('EXPO_PUBLIC_SUPABASE_URL')} and ${chalk.bold(
'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY'
)} values remain in ${chalk.bold('.env.local')} and in your EAS environment variables. Remove them if you no longer need them.`
);
}
}
@@ -15,6 +15,7 @@ import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { maybeSetProjectIconFromAppConfigAsync } from '../../../project/projectIcon';
import { isExpoInstalled } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
@@ -36,6 +37,7 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../project/projectIcon');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../project/projectUtils');
jest.mock('../../../utils/json');
@@ -887,4 +889,67 @@ describe(ProjectInit.name, () => {
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
});
describe('project icon', () => {
beforeEach(() => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
});
it('sets the icon from the app config', async () => {
await new ProjectInit([], commandOptions).run();
expect(jest.mocked(maybeSetProjectIconFromAppConfigAsync).mock.calls[0][1]).toEqual({
projectId: '1234',
projectDir: '/test-project',
});
});
it('does not set the icon with --no-icon', async () => {
await new ProjectInit(['--no-icon'], commandOptions).run();
expect(maybeSetProjectIconFromAppConfigAsync).not.toHaveBeenCalled();
});
it('reports the icon source in JSON output', async () => {
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
status: 'set',
icon: { field: 'icon', path: '/test-project/assets/icon.png' },
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
icon: { source: 'icon' },
});
});
it('omits the icon from JSON output when it was not set', async () => {
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
status: 'skipped',
reason: 'no-icon-in-app-config',
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
});
});
@@ -1,23 +1,17 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getProjectDashboardUrl } from '../../../build/utils/url';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AppUploadSessionType } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads';
import { sleepAsync } from '../../../utils/promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
import {
pollForProfileImageChangeAsync,
uploadProjectIconAsync,
validateIconAsync,
} from '../../../project/projectIcon';
export default class ProjectIconSet extends EasCommand {
static override description = 'set the project icon displayed on the EAS dashboard';
@@ -51,7 +45,7 @@ export default class ProjectIconSet extends EasCommand {
nonInteractive: flags['non-interactive'],
});
await validateImageAsync(imagePath);
await validateIconAsync(imagePath);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
@@ -62,11 +56,7 @@ export default class ProjectIconSet extends EasCommand {
const spinner = ora('Uploading project icon').start();
try {
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: imagePath,
});
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath });
// The icon is processed asynchronously (resized and assigned to the
// project by the server), so poll until the icon URL changes.
@@ -84,48 +74,3 @@ export default class ProjectIconSet extends EasCommand {
}
}
}
async function validateImageAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
async function pollForProfileImageChangeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
previousProfileImageUrl,
projectDashboardUrl,
}: {
projectId: string;
previousProfileImageUrl: string | null;
projectDashboardUrl: string;
}
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
projectDashboardUrl
)}`
);
}
@@ -6,6 +6,7 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { maybeSetProjectIconFromAppConfigAsync } from '../../project/projectIcon';
import {
ProjectInitResult,
ensureOwnerSlugConsistencyAsync,
@@ -37,6 +38,12 @@ export default class ProjectInit extends EasCommand {
description:
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
}),
icon: Flags.boolean({
description:
'Set the icon shown on the EAS dashboard from the app config, when the project does not have one yet',
default: true,
allowNo: true,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -82,6 +89,13 @@ export default class ProjectInit extends EasCommand {
}
);
const iconResult = flags.icon
? await maybeSetProjectIconFromAppConfigAsync(graphqlClient, {
projectId: result.projectId,
projectDir,
})
: null;
if (jsonFlag) {
printJsonOnlyOutput({
status: result.status,
@@ -89,6 +103,7 @@ export default class ProjectInit extends EasCommand {
owner,
slug,
dashboardUrl: getProjectDashboardUrl(owner, slug),
...(iconResult?.status === 'set' ? { icon: { source: iconResult.icon.field } } : {}),
});
}
}
+10
View File
@@ -34,6 +34,7 @@ interface RawCommandFlags {
verbose: boolean;
wait: boolean;
'non-interactive': boolean;
'auto-testflight-setup': boolean;
'verbose-fastlane': boolean;
groups?: string[];
}
@@ -46,6 +47,7 @@ interface CommandFlags {
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
}
@@ -106,6 +108,11 @@ export default class Submit extends EasCommand {
default: false,
description: 'Run command in non-interactive mode',
}),
'auto-testflight-setup': Flags.boolean({
default: true,
allowNo: true,
description: 'Set up an internal TestFlight group for the app (iOS only)',
}),
};
static override contextDefinition = {
@@ -152,6 +159,7 @@ export default class Submit extends EasCommand {
profile: submissionProfile.profile,
archiveFlags: flagsWithPlatform.archiveFlags,
nonInteractive: flagsWithPlatform.nonInteractive,
autoTestFlightSetup: flagsWithPlatform.autoTestFlightSetup,
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
groups: flagsWithPlatform.groups,
actor,
@@ -198,6 +206,7 @@ export default class Submit extends EasCommand {
wait,
profile,
'non-interactive': nonInteractive,
'auto-testflight-setup': autoTestFlightSetup,
'verbose-fastlane': isVerboseFastlaneEnabled,
groups,
'what-to-test': whatToTest,
@@ -221,6 +230,7 @@ export default class Submit extends EasCommand {
wait,
profile,
nonInteractive,
autoTestFlightSetup,
whatToTest,
isVerboseFastlaneEnabled,
groups,
@@ -35,7 +35,7 @@ const updateStub: UpdateFragment = {
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -30,7 +30,7 @@ const updateStub: UpdateFragment = {
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -154,7 +154,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -225,7 +225,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -270,7 +270,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -313,7 +313,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -357,7 +357,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: 'other-id',
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -401,7 +401,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: 'blah-id',
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -30,7 +30,7 @@ const updateStub: UpdateFragment = {
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -24,7 +24,7 @@ const updateStub: UpdateFragment = {
group: 'group-source',
branch: { id: 'branch-1234', name: 'main' },
message: 'source message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -33,7 +33,7 @@ const updateGroup = [
actor: null,
createdAt: '2026-04-09T00:00:00.000Z',
message: 'first',
runtimeVersion: '1.0.0',
runtime: { __typename: 'Runtime' as const, id: 'runtime-1', version: '1.0.0' },
platform: 'ios',
manifestPermalink: 'https://expo.dev/manifest/u1',
isRollBackToEmbedded: false,
@@ -670,7 +670,7 @@ export default class UpdatePublish extends EasCommand {
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
update => update.runtime.version === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
@@ -322,7 +322,7 @@ export default class UpdateRevertUpdateRollout extends EasCommand {
});
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
const runtimeVersion = rolloutUpdateGroup[0].runtime.version;
const targetBranch = {
name: rolloutUpdateGroup[0].branchName,
id: rolloutUpdateGroup[0].branchId,
@@ -147,7 +147,7 @@ async function getSourceUpdateGroupAsync(
return {
groupId,
branchName: arbitraryUpdate.branch.name,
runtimeVersion: arbitraryUpdate.runtimeVersion,
runtimeVersion: arbitraryUpdate.runtime.version,
};
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more