Compare commits

..
Author SHA1 Message Date
expo-tuft[bot]andide d3202a7003 [eas-cli] Apply suggested wording for missing expo package error
Co-authored-by: ide <[email protected]>
2026-07-24 21:30:56 -07:00
expo-tuft[bot]andide cc19ec8ecf [eas-cli] Apply review suggestions to error message and comment
Co-authored-by: ide <[email protected]>
2026-07-24 21:28:21 -07:00
expo-tuft[bot]andide ffc24c92c5 [eas-cli] Clarify comment on bundled @expo/config fallback
Co-authored-by: ide <[email protected]>
2026-07-24 16:14:44 -07:00
expo-tuft[bot]andide a8240eb7ce [eas-cli] Remove test pinning the bundled @expo/config fallback
Co-authored-by: ide <[email protected]>
2026-07-24 16:02:42 -07:00
expo-tuft[bot]andide c8e1799100 Format init.test.ts
Co-authored-by: ide <[email protected]>
2026-07-24 15:05:50 -07:00
expo-tuft[bot]andide 262f8fb01b [eas-cli] Reserve bundled @expo/config fallback for old SDKs without Expo CLI
Co-authored-by: ide <[email protected]>
2026-07-24 15:01:53 -07:00
expo-tuft[bot]andide b3baf0ad7c [eas-cli] Tolerate mocked getPackageJson returning undefined
Co-authored-by: ide <[email protected]>
2026-07-24 14:53:05 -07:00
expo-tuft[bot]andide 6defce7119 [eas-cli] Check for uninstalled dependencies upfront instead of wrapping getConfig
Co-authored-by: ide <[email protected]>
2026-07-24 14:43:03 -07:00
expo-tuft[bot]andide 4880dce0ab [eas-cli] Print actionable error instead of skipping plugins per feedback
Co-authored-by: ide <[email protected]>
2026-07-24 14:36:39 -07:00
expo-tuft[bot]andide 48b0e70af1 Update CHANGELOG.md
Co-authored-by: ide <[email protected]>
2026-07-24 14:30:46 -07:00
expo-tuft[bot]andide 93fc345630 [eas-cli] Read app config without plugins when dependencies aren't installed
Co-authored-by: ide <[email protected]>
2026-07-24 14:29:50 -07:00
45 changed files with 356 additions and 2614 deletions

No files matched your search

+5 -4
View File
@@ -1,10 +1,11 @@
---
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
alwaysRun: true
# No model override: security runs on the config default (gpt-5.5, on the
# subscription). To give it a stronger model, use the metered openai-api alias
# from the mixed auth.providers setup (see the @expo/code-review-cli README) —
# pro-tier models are excluded from the subscription.
# Security is the highest-stakes agent and benefits most from stronger threat-model
# reasoning, so it runs on Opus even though the other specialists use the Sonnet
# default. Scoped to this one agent to limit the extra latency/rate-limit cost;
# subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
model: anthropic/claude-opus-4-8
---
# Security & secrets
+11 -13
View File
@@ -1,9 +1,10 @@
{
// Default model for every pass — served by the ChatGPT/Codex subscription (see
// auth below), so reviews have no marginal cost. Per-agent frontmatter can
// override this. Override everything at runtime with REVIEWER_MODEL
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini for a cheaper local run).
"model": "openai/gpt-5.5",
// Default model for the specialist reviewers (and the cross-file pass). Sonnet
// is the quality/speed sweet spot for finding real bugs. Per-agent frontmatter
// can override this; the coordinator runs on Haiku (see coordinator.md) since it
// only consolidates text. Override everything at runtime with REVIEWER_MODEL
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini-fast for a cheaper local run).
"model": "anthropic/claude-sonnet-5",
// Agents are every markdown file in agents/. shared.md (prepended to all) and
// coordinator.md are reserved. Per-agent overrides go in each file's frontmatter.
@@ -30,15 +31,12 @@
"breakGlass": { "marker": "/skip-review" },
"commentTag": "expo-ai-code-reviewer",
// ChatGPT/Codex subscription (OAuth): tokenEnv holds the REFRESH token from an
// `opencode auth login` ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's
// auth.json); OpenCode mints short-lived access tokens from it on demand. All
// models above are on the subscription's allowlist, so reviews draw no metered
// spend. (To add pro-tier models later, see the mixed auth.providers setup in
// the @expo/code-review-cli README.)
// Use a Claude Pro/Max OAuth token (from `claude setup-token`) rather than an
// x-api-key. The token is read from this env var and injected into an isolated
// OpenCode auth.json as a bearer credential.
"auth": {
"mode": "oauth",
"provider": "openai",
"tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN"
"provider": "anthropic",
"tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
}
}
+4 -3
View File
@@ -1,7 +1,8 @@
---
# No model override: the coordinator runs on the config default (gpt-5.5, on the
# subscription). It only consolidates text (no repo tools), so the default is
# plenty; override here if consolidation quality ever needs a stronger model.
# The coordinator makes the final call — de-duping, re-judging severity, and
# deciding — so it runs on Opus: consolidation quality matters more here than the
# small serial-tail latency it adds (no repo tools, so it's a single bounded pass).
model: anthropic/claude-opus-4-8
---
# Coordinator — consolidation & decision
@@ -19,7 +19,7 @@ permissions:
env:
# Published reviewer run via npx (override with repo variable ECR_VERSION).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
concurrency:
group: ai-code-review-cmd-${{ github.event.issue.number }}
@@ -104,8 +104,7 @@ jobs:
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
@@ -18,7 +18,7 @@ permissions:
env:
# Published reviewer run via npx (override with repo variable ECR_VERSION).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
concurrency:
group: ai-code-review-dismiss-${{ github.event.issue.number }}
+6 -10
View File
@@ -13,8 +13,8 @@ permissions:
env:
# The reviewer is the published @expo/code-review-cli package, run via npx — the
# repo only carries the `.expo-code-review/` config, not the engine source.
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.5.x.
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.2.x.
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
concurrency:
group: ai-code-review-${{ github.event.pull_request.number }}
@@ -98,7 +98,7 @@ jobs:
- name: Guard config.jsonc tokenEnv
if: steps.resolve.outputs.run == 'true'
env:
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'ANTHROPIC_OAUTH_API_KEY' }}
run: |
values=$(grep -oE '"tokenEnv"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_]+"' .expo-code-review/config.jsonc | sed -E 's/.*"([A-Za-z0-9_]+)"$/\1/')
count=$(printf '%s\n' "$values" | grep -c .)
@@ -135,13 +135,9 @@ jobs:
env:
# GitHub token scoped by the permissions block above (PR comments only).
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ChatGPT/Codex subscription refresh token (auth.mode "oauth" in
# .expo-code-review/config.jsonc reads this env var); OpenCode mints
# access tokens from it. All reviews run on the subscription.
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
# Layer-1 runtime auth lock (mirrors the guard step): the CLI refuses to
# run when the tokenEnv the loaded config honors differs from this.
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
# Claude Pro/Max OAuth token (auth.mode "oauth" in .expo-code-review/
# config.jsonc reads this env var). Not an x-api-key.
ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
# Optional: override the model used by every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.resolve.outputs.agents }}
+4 -48
View File
@@ -6,34 +6,9 @@ on:
name: Release workflow
jobs:
# A prerelease tag (any version carrying a SemVer prerelease identifier, e.g.
# v21.3.0-beta.0) must NOT be treated like a stable release: npm does not
# special-case prerelease versions, so publishing one without an explicit dist-tag
# would point `latest` at it and hand it to every `npx eas-cli` user. Every job below
# branches on this one output instead of re-deriving it from the tag.
resolve:
name: Resolve release kind
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
steps:
- id: resolve
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# SemVer puts the prerelease identifier after a hyphen: 21.3.0-beta.0.
if [[ "$VERSION" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
echo "::notice::$VERSION is a prerelease: publishing under the 'next' dist-tag, leaving 'latest', the EAS Build tags and CHANGELOG.md untouched."
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
release:
name: Create a release
runs-on: ubuntu-latest
needs: resolve
permissions:
contents: write
outputs:
@@ -46,15 +21,11 @@ jobs:
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
# A stable release stays a draft until the changelog job publishes it with
# notes. A prerelease skips that job, so publish it immediately and flag it so
# GitHub never presents it as the repo's latest release.
draft: ${{ needs.resolve.outputs.prerelease == 'false' }}
prerelease: ${{ needs.resolve.outputs.prerelease == 'true' }}
draft: true
publish-to-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: [resolve, release]
needs: release
permissions:
id-token: write
steps:
@@ -64,18 +35,9 @@ jobs:
run: yarn install --immutable
- name: Build
run: yarn build
# Always pass the dist-tag explicitly. `latest` is what npm would have defaulted
# to, so stable releases are unchanged — but relying on that default is what made
# a prerelease publish unsafe, so the intent is stated rather than inherited.
- name: Publish packages to npm
env:
DIST_TAG: ${{ needs.resolve.outputs.prerelease == 'true' && 'next' || 'latest' }}
run: yarn run -T lerna publish from-package --yes --no-verify-access --dist-tag "$DIST_TAG"
# These two tags select the CLI version used by EAS Build. A prerelease must never
# move them (that would put untested code in production builds); `latest-eas-build`
# also has its own gated workflow for moving it deliberately.
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
if: needs.resolve.outputs.prerelease == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
run: |
@@ -83,22 +45,16 @@ jobs:
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
- name: Add latest-eas-build tag
if: needs.resolve.outputs.prerelease == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build
# Stable releases only. A prerelease must leave CHANGELOG.md alone: its entries stay
# under "unreleased" so the eventual stable release still announces them. Skipping this
# job also keeps a prerelease from committing to main and from Slack-announcing itself
# as a release — the GitHub prerelease is its announcement.
release-changelog:
name: Update changelog and publish release
if: needs.resolve.outputs.prerelease == 'false'
runs-on: ubuntu-latest
needs: [resolve, publish-to-npm]
needs: publish-to-npm
permissions:
contents: write
steps:
+1 -1
View File
@@ -4,7 +4,7 @@ on:
version:
type: string
required: false
description: 'Version for the release, e.g. "21.3.0". Prerelease: "21.3.0-beta.0" (publishes under the `next` dist-tag; see RELEASING.md). Leave empty to derive the next stable version from the changelog.'
description: 'Version number for the release'
dry_run:
type: boolean
required: true
+6 -11
View File
@@ -8,19 +8,14 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
### 🧹 Chores
## [21.3.0](https://github.com/expo/eas-cli/releases/tag/v21.3.0) - 2026-07-27
### 🎉 New features
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--account` flag to `eas init` to choose the account that should own the project, enabling non-interactive project creation (`eas init --account <name> --non-interactive`), and improve unconfigured-project error messages to list the accounts you can create projects in. ([#4057](https://github.com/expo/eas-cli/pull/4057) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add `--json` flag to `eas init` to print the linked project (status, project ID, owner, slug, dashboard URL) as JSON to stdout, and add usage examples to `eas init --help`. ([#4079](https://github.com/expo/eas-cli/pull/4079) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `eas testflight:feedback` and `eas testflight:crashes` to read TestFlight tester feedback (comments, device info, screenshot URLs) and crash reports (including full crash logs) from App Store Connect. Both support `--offset`/`--limit` pagination, `--json`, and looking up a single submission by ID or by the App Store Connect URL that EAS workflow triggers expose as `${{ app_store_connect.beta_feedback.url }}`. ([#4084](https://github.com/expo/eas-cli/pull/4084) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [eas-cli] Print an actionable error message that says to install the project's dependencies when they aren't installed and EAS CLI needs them to read the app config, instead of a confusing module resolution error. Reading the config with the copy of `@expo/config` bundled with EAS CLI is now reserved for old SDK versions whose `expo` package doesn't include Expo CLI; projects without the `expo` package get an error directing them to install it. ([#4080](https://github.com/expo/eas-cli/pull/4080) by [@ide](https://github.com/ide))
### 🧹 Chores
## [21.2.0](https://github.com/expo/eas-cli/releases/tag/v21.2.0) - 2026-07-24
-75
View File
@@ -12,78 +12,3 @@ The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- Otherwise, bump the PATCH version.
## Prereleases
A prerelease publishes a real version to npm that nobody gets by accident: it goes out
under the `next` dist-tag, so `latest` keeps pointing at the current stable release and
`npx eas-cli` is unaffected. Use one to get a risky change in front of users (or to dogfood
it in a real project) before committing to a stable release.
**Cutting one:** invoke the same ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml)
workflow, but pass an explicit prerelease version — e.g. `21.3.0-beta.0`. Any version with a
SemVer prerelease identifier (the part after the hyphen) is treated as a prerelease; the
identifier itself is free-form, so `-beta.0`, `-rc.1` and `-canary.0` all work. You must pass
the version explicitly: the changelog-based algorithm above only ever produces stable versions.
**Installing one:**
```sh
npm i -g eas-cli@next # whatever the current prerelease is
npm i -g [email protected] # a specific one
```
**What a prerelease does differently.** Everything is driven off the pushed tag by
`.github/workflows/release.yml`, which detects the prerelease identifier and then:
| | Stable | Prerelease |
| ------------------------------------ | ----------------- | -------------------------------- |
| npm dist-tag | `latest` | `next` |
| `latest-eas-build` / `-staging` tags | moved | **left alone** |
| CHANGELOG.md | cut and committed | **left alone** |
| GitHub release | draft → published | published, flagged as prerelease |
| Slack announcement in #eas-cli | yes | no |
The EAS Build tags are deliberately untouched: they select the CLI version used by EAS
Build, so moving them to a prerelease would put untested code into production builds.
Move them on purpose with the ["Move NPM tags used by EAS builds and workflows"](https://github.com/expo/eas-cli/actions/workflows/move-eas-build-tag.yml)
workflow if that is what you actually want.
CHANGELOG.md is also untouched, so a prerelease's entries stay under "unreleased" and are
announced with the stable release that follows.
### Prereleases in this monorepo
Prereleases version and publish packages exactly like stable releases do — lerna runs in
fixed mode, so it applies the single version from `lerna.json` to the packages that changed
since the last release and leaves the rest alone (which is why the packages sit at different
versions). `lerna publish from-package` then publishes whichever of those aren't on the
registry yet, and the `next` dist-tag applies to every package published in that run.
`latest` is untouched for all of them, so nothing that depends on `@expo/eas-json`,
`@expo/steps`, `@expo/eas-build-job` or friends picks up a prerelease implicitly.
Two consequences worth knowing:
- **The version line is shared.** After `21.3.0-beta.0`, the next stable release from main
is `21.3.0` — the prerelease consumes that version number for the whole repo. Cutting a
`21.2.1` patch afterwards means passing that version explicitly.
- **main carries the prerelease version.** `lerna version` commits the bump, so `lerna.json`
and the changed packages sit at `21.3.0-beta.0` until the next release. The changelog-based
algorithm still resolves correctly from there (a MINOR or PATCH bump off `21.3.0-beta.0`
both land on `21.3.0`).
### Testing an unmerged branch
Prereleases are cut from `main`, so they are not the tool for validating a PR before it
merges. For that, build the branch locally and run it directly:
```sh
yarn && yarn build
./packages/eas-cli/bin/run --version
```
or pack a tarball to install elsewhere:
```sh
cd packages/eas-cli && yarn pack # then: npm i -g ./eas-cli-*.tgz
```
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.3.0",
"version": "21.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.3.0",
"version": "21.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -39,14 +39,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.3.0",
"@expo/eas-build-job": "21.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.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.3.0",
"@expo/steps": "21.1.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.3.0",
"version": "21.1.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.3.0"
"@expo/steps": "21.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.3.0",
"version": "21.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+116 -223
View File
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.3.0",
"version": "21.2.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "21.3.0",
"@expo/eas-json": "21.3.0",
"@expo/eas-build-job": "21.1.0",
"@expo/eas-json": "21.1.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "21.0.0",
@@ -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.3.0",
"@expo/steps": "21.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -221,9 +221,6 @@
"secret": {
"description": "manage project and account secrets"
},
"testflight": {
"description": "read TestFlight feedback and crashes"
},
"update": {
"description": "manage individual updates"
},
@@ -1,5 +1,6 @@
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import { vol } from 'memfs';
import resolveFrom from 'resolve-from';
import { anything, instance, mock, when } from 'ts-mockito';
import { Role } from '../../../../graphql/generated';
@@ -14,6 +15,11 @@ import { getProjectIdAsync } from '../getProjectIdAsync';
jest.mock('@expo/config');
jest.mock('fs');
jest.mock('resolve-from', () => {
const resolveFrom: any = jest.fn();
resolveFrom.silent = jest.fn();
return resolveFrom;
});
jest.mock('../../../../graphql/queries/AppQuery');
jest.mock('../../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
@@ -36,6 +42,10 @@ describe(getProjectIdAsync, () => {
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the
// config is read with the copy of `@expo/config` bundled with EAS CLI.
jest.mocked(resolveFrom.silent).mockReturnValue('/app/node_modules/expo/package.json');
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
@@ -478,7 +488,7 @@ 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'
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
);
expect(promptAsync).not.toHaveBeenCalled();
@@ -19,7 +19,7 @@ import {
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
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.
@@ -227,9 +227,7 @@ 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(', ')}`
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
);
}
@@ -1,6 +1,7 @@
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
import chalk from 'chalk';
import { vol } from 'memfs';
import resolveFrom from 'resolve-from';
import { instance, mock } from 'ts-mockito';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
@@ -17,11 +18,15 @@ import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ProjectInit from '../init';
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('resolve-from', () => {
const resolveFrom: any = jest.fn();
resolveFrom.silent = jest.fn();
return resolveFrom;
});
jest.mock('../../../project/expoConfig', () => ({
...jest.requireActual('../../../project/expoConfig'),
createOrModifyExpoConfigAsync: jest.fn(),
@@ -38,7 +43,6 @@ jest.mock('../../../ora', () => ({
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../project/projectUtils');
jest.mock('../../../utils/json');
let originalProcessArgv: string[];
@@ -101,6 +105,9 @@ function mockTestProject(options: {
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the config
// is read with the copy of `@expo/config` bundled with EAS CLI.
jest.mocked(resolveFrom.silent).mockReturnValue(projectRoot + '/node_modules/expo/package.json');
}
const commandOptions = getMockOclifConfig({ root: '/test-project' });
@@ -537,354 +544,4 @@ describe(ProjectInit.name, () => {
});
});
});
describe('when account flag is provided', () => {
it('uses the specified account without prompting', async () => {
mockTestProject({});
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '0129',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester'], commandOptions).run();
expect(promptAsync).not.toHaveBeenCalled();
expect(jest.mocked(AppMutation.createAppAsync).mock.calls[0][1]).toEqual({
accountId: jester.accounts[0].id,
projectName: 'testing-123',
});
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
});
it('creates the project non-interactively without requiring --force', async () => {
mockTestProject({});
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '0129',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--non-interactive'], commandOptions).run();
expect(promptAsync).not.toHaveBeenCalled();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
});
it('links an existing project non-interactively without requiring --force', async () => {
mockTestProject({});
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--non-interactive'], commandOptions).run();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
});
it('throws non-interactively without --account when no owner is set', async () => {
mockTestProject({});
await expect(
new ProjectInit(['--non-interactive'], commandOptions).run()
).rejects.toThrowError(
'You have access to multiple accounts. Choose the account that should own this project with the --account flag:'
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('throws when the account does not exist or is not accessible', async () => {
mockTestProject({});
await expect(
new ProjectInit(['--account', 'nonexistent'], commandOptions).run()
).rejects.toThrowError(
`You are not able to create projects in the "nonexistent" account. Accounts you have permissions to create projects in: jester`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('throws when the actor only has a view-only role in the account', async () => {
mockTestProject({});
await expect(
new ProjectInit(['--account', 'other'], commandOptions).run()
).rejects.toThrowError(
`You are not able to create projects in the "other" account. Accounts you have permissions to create projects in: jester`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('is a no-op when the project is already linked to the specified account', async () => {
mockTestProject({ configuredProjectId: '1234' });
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('throws when the project is already linked to a different account without --force', async () => {
mockTestProject({ configuredProjectId: '1234' });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester2/testing-123',
ownerAccount: jester2.accounts[0],
});
await expect(
new ProjectInit(['--account', 'jester'], commandOptions).run()
).rejects.toThrowError(
`This project is already linked to @jester2 (ID: 1234). Pass --force to re-link it to a project owned by jester, or remove the "extra.eas.projectId" field from your app config.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('re-links to the specified account when the project is already linked to a different account with --force', async () => {
mockTestProject({ configuredProjectId: '1234' });
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest
.mocked(AppQuery.byIdAsync)
// ownership check for the currently linked project
.mockResolvedValueOnce({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester2/testing-123',
ownerAccount: jester2.accounts[0],
})
// owner/slug consistency check for the newly linked project
.mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--force'], commandOptions).run();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
});
it('re-links and overwrites a conflicting "owner" field with --force', async () => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: 'jester2' });
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest
.mocked(AppQuery.byIdAsync)
// ownership check for the currently linked project
.mockResolvedValueOnce({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester2/testing-123',
ownerAccount: jester2.accounts[0],
})
// owner/slug consistency check for the newly linked project
.mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--force'], commandOptions).run();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
owner: 'jester',
});
});
it('throws when the account conflicts with the owner field in the app config', async () => {
mockTestProject({ configuredOwner: jester.accounts[0].name });
await expect(
new ProjectInit(['--account', 'other'], commandOptions).run()
).rejects.toThrowError(
`The account specified with --account (other) does not match the "owner" field in your app config (jester). Provide a matching --account or update the "owner" field.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
});
describe('when json flag is provided', () => {
it('prints the created project as JSON', async () => {
mockTestProject({});
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '0129',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(
['--account', 'jester', '--json', '--non-interactive'],
commandOptions
).run();
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'created',
projectId: '0129',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('prints the linked project as JSON when an existing project is found on the server', async () => {
mockTestProject({});
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(
['--account', 'jester', '--json', '--non-interactive'],
commandOptions
).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'linked',
projectId: '123456',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('prints already-linked as JSON when the project is already configured', async () => {
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],
});
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',
});
});
it('prints the linked project as JSON when linking with an explicit id', async () => {
mockTestProject({});
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--id', '1234', '--json', '--non-interactive'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '1234');
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('implies non-interactive mode', async () => {
mockTestProject({});
await expect(new ProjectInit(['--json'], commandOptions).run()).rejects.toThrowError(
'You have access to multiple accounts. Choose the account that should own this project with the --account flag'
);
expect(promptAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
it('does not print JSON without the json flag', async () => {
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],
});
await new ProjectInit([], commandOptions).run();
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
});
});
+13 -49
View File
@@ -1,43 +1,26 @@
import { Flags } from '@oclif/core';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
ProjectInitResult,
ensureOwnerSlugConsistencyAsync,
initializeWithExplicitIDAsync,
initializeWithoutExplicitIDAsync,
} from '../../project/projectInitialization';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ProjectInit extends EasCommand {
static override description = 'create or link an EAS project';
static override aliases = ['init'];
static override examples = [
'$ eas init \t # Create or link a project interactively',
'$ eas init --id <project-id> \t # Link to the project with the given ID',
'$ eas init --account my-account --non-interactive \t # Create or link @my-account/<slug> without prompts',
'$ eas init --account my-account --json --non-interactive \t # Same, and print the result as JSON to stdout',
];
static override flags = {
id: Flags.string({
description: 'ID of the EAS project to link',
}),
account: Flags.string({
description: 'Name of the account that will own the project',
exclusive: ['id'],
}),
force: Flags.boolean({
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',
}),
...EasNonInteractiveAndJsonFlags,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
@@ -46,50 +29,31 @@ export default class ProjectInit extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ProjectInit);
const { id: idArgument, account: accountArgument, force } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
} = await this.parse(ProjectInit);
const {
loggedIn: { actor, graphqlClient },
projectDir,
} = await this.getContextAsync(ProjectInit, { nonInteractive });
let result: ProjectInitResult;
let idForConsistency: string;
if (idArgument) {
const status = await initializeWithExplicitIDAsync(idArgument, projectDir, {
await initializeWithExplicitIDAsync(idArgument, projectDir, {
force,
nonInteractive,
});
result = { projectId: idArgument, status };
idForConsistency = idArgument;
} else {
result = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
idForConsistency = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
force,
nonInteractive,
accountName: accountArgument,
});
}
const { owner, slug } = await ensureOwnerSlugConsistencyAsync(
graphqlClient,
result.projectId,
projectDir,
{
force,
nonInteractive,
}
);
if (jsonFlag) {
printJsonOnlyOutput({
status: result.status,
projectId: result.projectId,
owner,
slug,
dashboardUrl: getProjectDashboardUrl(owner, slug),
});
}
await ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
force,
nonInteractive,
});
}
}
@@ -1,108 +0,0 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { resolveTestFlightAppAsync } from '../../testflight/app';
import {
TESTFLIGHT_PAGE_SIZE,
listAndRenderTestFlightCrashesAsync,
viewAndRenderBetaFeedbackAsync,
} from '../../testflight/queries';
import { resolveBetaFeedbackTarget } from '../../testflight/target';
import { enableJsonOutput } from '../../utils/json';
export default class TestFlightCrashes extends EasCommand {
static override description =
'display crashes reported by TestFlight testers, or the full crash log of a single crash';
static override examples = [
'$ eas testflight:crashes \t # Show the most recent crashes',
'$ eas testflight:crashes --limit 50 --offset 20 \t # Page through crashes',
'$ eas testflight:crashes AAo2eIIfGzcb1BzuUv3xrh4 \t # Show the full crash log for one crash',
'$ eas testflight:crashes --json \t # Print a page of crashes, with paging metadata, as JSON',
'$ eas testflight:crashes ${{ app_store_connect.beta_feedback.url }} --json \t # Look up whatever an EAS workflow trigger reported',
];
static override args = {
id: Args.string({
description:
'ID or App Store Connect API URL of a single submission to show. Accepts ${{ app_store_connect.beta_feedback.id }} or ${{ app_store_connect.beta_feedback.url }} from an EAS workflow trigger.',
required: false,
}),
};
static override flags = {
type: Flags.option({
description:
'Kind of feedback the ID refers to. Only needed when passing a bare ID for screenshot feedback; a URL already encodes it.',
options: ['crash', 'screenshot'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json used to resolve the bundle identifier and App Store Connect API key. Defaults to "production".',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: TESTFLIGHT_PAGE_SIZE, limit: 200 }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(TestFlightCrashes);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json, nonInteractive } = paginatedQueryOptions;
if (json) {
enableJsonOutput();
}
if (flags.type && !args.id) {
throw new Error('--type only applies when looking up a single submission by ID or URL.');
}
// Resolved before authenticating so a malformed ID or URL fails fast.
const target = args.id
? resolveBetaFeedbackTarget({ idOrUrl: args.id, type: flags.type, defaultType: 'crash' })
: null;
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(TestFlightCrashes, {
nonInteractive,
withServerSideEnvironment: null,
});
const app = await resolveTestFlightAppAsync({
actor,
analytics,
exp,
graphqlClient,
nonInteractive,
profileName: flags.profile,
projectDir,
projectId,
vcsClient,
});
if (target) {
await viewAndRenderBetaFeedbackAsync(app, target, { json });
return;
}
await listAndRenderTestFlightCrashesAsync(app, paginatedQueryOptions);
}
}
@@ -1,108 +0,0 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { resolveTestFlightAppAsync } from '../../testflight/app';
import {
TESTFLIGHT_PAGE_SIZE,
listAndRenderTestFlightFeedbackAsync,
viewAndRenderBetaFeedbackAsync,
} from '../../testflight/queries';
import { resolveBetaFeedbackTarget } from '../../testflight/target';
import { enableJsonOutput } from '../../utils/json';
export default class TestFlightFeedback extends EasCommand {
static override description =
'display screenshot feedback submitted by TestFlight testers, including their comments, device information, and screenshot URLs';
static override examples = [
'$ eas testflight:feedback \t # Show the most recent feedback submissions',
'$ eas testflight:feedback --limit 50 --offset 20 \t # Page through submissions',
'$ eas testflight:feedback --json \t # Print a page of feedback, with paging metadata, as JSON',
'$ eas testflight:feedback AD8JvKbr0BK0Cj9OnM6WO6I \t # Show a single submission by ID',
'$ eas testflight:feedback ${{ app_store_connect.beta_feedback.url }} --json \t # Look up whatever an EAS workflow trigger reported',
];
static override args = {
id: Args.string({
description:
'ID or App Store Connect API URL of a single submission to show. Accepts ${{ app_store_connect.beta_feedback.id }} or ${{ app_store_connect.beta_feedback.url }} from an EAS workflow trigger.',
required: false,
}),
};
static override flags = {
type: Flags.option({
description:
'Kind of feedback the ID refers to. Only needed when passing a bare ID for a crash; a URL already encodes it.',
options: ['crash', 'screenshot'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json used to resolve the bundle identifier and App Store Connect API key. Defaults to "production".',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: TESTFLIGHT_PAGE_SIZE, limit: 200 }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(TestFlightFeedback);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json, nonInteractive } = paginatedQueryOptions;
if (json) {
enableJsonOutput();
}
if (flags.type && !args.id) {
throw new Error('--type only applies when looking up a single submission by ID or URL.');
}
// Resolved before authenticating so a malformed ID or URL fails fast.
const target = args.id
? resolveBetaFeedbackTarget({ idOrUrl: args.id, type: flags.type, defaultType: 'screenshot' })
: null;
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(TestFlightFeedback, {
nonInteractive,
withServerSideEnvironment: null,
});
const app = await resolveTestFlightAppAsync({
actor,
analytics,
exp,
graphqlClient,
nonInteractive,
profileName: flags.profile,
projectDir,
projectId,
vcsClient,
});
if (target) {
await viewAndRenderBetaFeedbackAsync(app, target, { json });
return;
}
await listAndRenderTestFlightFeedbackAsync(app, paginatedQueryOptions);
}
}
@@ -1,12 +1,20 @@
import { getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import { getConfig, getConfigFilePaths, getPackageJson, modifyConfigAsync } from '@expo/config';
import JsonFile from '@expo/json-file';
import { writeFileSync } from 'fs-extra';
import resolveFrom from 'resolve-from';
import { createOrModifyExpoConfigAsync } from '../expoConfig';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../expoConfig';
import { isExpoInstalled } from '../projectUtils';
jest.mock('fs-extra');
jest.mock('@expo/config');
jest.mock('@expo/json-file');
jest.mock('resolve-from', () => {
const resolveFrom: any = jest.fn();
resolveFrom.silent = jest.fn();
return resolveFrom;
});
jest.mock('../projectUtils');
beforeEach(() => {
jest.resetAllMocks();
@@ -47,4 +55,34 @@ describe('expoConfig', () => {
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', { owner: 'ccheever' });
});
});
describe('getPrivateExpoConfigAsync when Expo CLI is not resolvable', () => {
beforeEach(() => {
jest.mocked(getConfigFilePaths).mockReturnValue({
staticConfigPath: '/app/app.json',
dynamicConfigPath: null,
});
jest.mocked(isExpoInstalled).mockReturnValue(false);
});
it('throws an actionable error when expo is declared in package.json but not installed', async () => {
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
jest.mocked(getPackageJson).mockReturnValue({ dependencies: { expo: '~53.0.0' } } as any);
await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
/dependencies to be installed/
);
expect(getConfig).not.toHaveBeenCalled();
});
it('throws an actionable error when expo is not declared in package.json', async () => {
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
jest.mocked(getPackageJson).mockReturnValue({ dependencies: {} } as any);
await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
/The "expo" package was not found/
);
expect(getConfig).not.toHaveBeenCalled();
});
});
});
@@ -15,7 +15,7 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
jester
)
).rejects.toThrow(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: jester\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
});
});
+28 -2
View File
@@ -1,10 +1,20 @@
import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import {
ExpoConfig,
getConfig,
getConfigFilePaths,
getPackageJson,
modifyConfigAsync,
} from '@expo/config';
import { Env } from '@expo/eas-build-job';
import { resolvePackageManager } from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs-extra';
import Joi from 'joi';
import path from 'path';
import resolveFrom from 'resolve-from';
import { isExpoInstalled } from './projectUtils';
import { link } from '../log';
import { spawnExpoCommand } from '../utils/expoCli';
export type PublicExpoConfig = Omit<
@@ -63,12 +73,28 @@ async function getExpoConfigInternalAsync(
}
);
exp = JSON.parse(stdout);
} else {
} else if (resolveFrom.silent(projectDir, 'expo/package.json')) {
// The `expo` package is installed but Expo CLI is not part of it. This happens with old
// SDK versions, which predate the local CLI. Since we can't run `expo config`, read the app
// config with the copy of `@expo/config` that ships with EAS CLI.
exp = getConfig(projectDir, {
skipSDKVersionRequirement: true,
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
...(opts.skipPlugins ? { skipPlugins: true } : {}),
}).exp;
} else if (getPackageJson(projectDir)?.dependencies?.expo) {
const installCommand = `${resolvePackageManager(projectDir) ?? 'npm'} install`;
throw new Error(
`EAS CLI needs your project's dependencies to be installed to read your app config. Run ${chalk.bold(
installCommand
)} in your project directory and run this command again.`
);
} else {
throw new Error(
`The "expo" package was not found in your project's dependencies, needed to read your app config. Add "expo" to your dependencies and install it. Refer to the version compatibility table at: ${link(
'https://docs.expo.dev/versions/latest/'
)}`
);
}
const { error } = MinimalAppConfigSchema.validate(exp, {
@@ -8,7 +8,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import { link } from '../log';
import { ora } from '../ora';
import { confirmAsync } from '../prompts';
import { Actor, getCreatableAccountNames } from '../user/User';
import { Actor } from '../user/User';
/**
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
@@ -30,22 +30,18 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
const { accountName, projectName } = projectInfo;
const projectFullName = `@${accountName}/${projectName}`;
if (options.nonInteractive) {
throw new Error(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPublishPermissions = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
if (options.nonInteractive) {
throw new Error(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(
', '
)}\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
);
}
const account = allAccounts.find(a => a.name === accountName);
if (!account) {
throw new Error(
@@ -17,20 +17,13 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { link } from '../log';
import { ora } from '../ora';
import { confirmAsync, promptAsync } from '../prompts';
import { Actor, getCreatableAccountNames } from '../user/User';
import { Actor } from '../user/User';
export type InitializeMethodOptions = {
force: boolean;
nonInteractive: boolean;
};
export type ProjectInitStatus = 'created' | 'linked' | 'already-linked';
export type ProjectInitResult = {
projectId: string;
status: ProjectInitStatus;
};
async function saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
@@ -92,7 +85,7 @@ export async function ensureOwnerSlugConsistencyAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<{ owner: string; slug: string }> {
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
const correctOwner = appForProjectId.ownerAccount.name;
@@ -141,60 +134,59 @@ export async function ensureOwnerSlugConsistencyAsync(
} else if (!exp.slug) {
await modifyExpoConfigAsync(projectDir, { slug: correctSlug });
}
return { owner: correctOwner, slug: correctSlug };
}
async function setExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<ProjectInitStatus> {
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (projectId === existingProjectId) {
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
return 'already-linked';
return;
}
if (!existingProjectId) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
return;
}
if (force) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
}
if (projectId !== existingProjectId) {
if (force) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
}
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
}
export async function initializeWithExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<ProjectInitStatus> {
return await setExplicitIDAsync(projectId, projectDir, {
): Promise<void> {
await setExplicitIDAsync(projectId, projectDir, {
force,
nonInteractive,
});
@@ -204,70 +196,39 @@ export async function initializeWithoutExplicitIDAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
{
force,
nonInteractive,
accountName: accountNameArgument,
}: InitializeMethodOptions & { accountName?: string }
): Promise<ProjectInitResult> {
{ force, nonInteractive }: InitializeMethodOptions
): Promise<string> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (existingProjectId) {
const ownerAccountName = accountNameArgument
? (await AppQuery.byIdAsync(graphqlClient, existingProjectId)).ownerAccount.name
: undefined;
if (!accountNameArgument || ownerAccountName === accountNameArgument) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return { projectId: existingProjectId, status: 'already-linked' };
}
if (!force) {
throw new Error(
`This project is already linked to @${ownerAccountName} (ID: ${existingProjectId}). Pass --force to re-link it to a project owned by ${accountNameArgument}, or remove the "extra.eas.projectId" field from your app config.`
);
}
// --force with a different --account: fall through to re-link under the new account
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return existingProjectId;
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp =
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(actor);
if (accountNameArgument) {
// with --force, a conflicting "owner" field is rewritten after linking
if (exp.owner && exp.owner !== accountNameArgument && !force) {
throw new Error(
`The account specified with --account (${accountNameArgument}) does not match the "owner" field in your app config (${exp.owner}). Provide a matching --account or update the "owner" field.`
);
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountNameArgument)) {
throw new Error(
`You are not able to create projects in the "${accountNameArgument}" account. Accounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(', ')}`
);
}
}
// if no --account flag or owner field, ask the user which account they want to use to create/link the project
let accountName = accountNameArgument ?? exp.owner;
// if no owner field, ask the user which account they want to use to create/link the project
let accountName = exp.owner;
if (!accountName) {
if (allAccounts.length === 1) {
accountName = allAccounts[0].name;
} else if (nonInteractive) {
if (!force) {
throw new Error(
`You have access to multiple accounts. Choose the account that should own this project with the --account flag:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(
', '
)}\n\nAlternatively, set the "owner" field in your app config. (Deprecated: --force without --account will proceed with the default account ${
`There are multiple accounts that you have access to: ${allAccounts
.map(a => a.name)
.join(
', '
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
allAccounts[0].name
}.)`
}.`
);
}
accountName = allAccounts[0].name;
@@ -296,18 +257,13 @@ export async function initializeWithoutExplicitIDAsync(
const projectName = validSlugName(exp.slug); // This filters out invalid characters
const projectFullName = `@${accountName}/${projectName}`;
validateFullNameAndSlug(projectFullName, projectName);
// An explicit --account fully specifies the intended project (@account/slug), so linking or
// creating it requires no confirmation in non-interactive mode.
const skipConfirmation = force || (nonInteractive && accountNameArgument !== undefined);
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
if (existingProjectIdOnServer) {
if (!skipConfirmation) {
if (!force) {
if (nonInteractive) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
@@ -325,7 +281,7 @@ export async function initializeWithoutExplicitIDAsync(
}
await saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
return { projectId: existingProjectIdOnServer, status: 'linked' };
return existingProjectIdOnServer;
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
@@ -334,7 +290,7 @@ export async function initializeWithoutExplicitIDAsync(
);
}
if (!skipConfirmation) {
if (!force) {
if (nonInteractive) {
throw new Error(
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
@@ -367,5 +323,5 @@ export async function initializeWithoutExplicitIDAsync(
}
await saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
return { projectId: createdProjectId, status: 'created' };
return createdProjectId;
}
@@ -1,78 +0,0 @@
import { App } from '@expo/apple-utils';
import { EasJsonUtils } from '@expo/eas-json';
import { resolveTestFlightAppAsync } from '../app';
import { CredentialsContext } from '../../credentials/context';
import { AppStoreConnectApiKeyQuery as AccountAppStoreConnectApiKeyQuery } from '../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { getRequestContext } from '../../credentials/ios/appstore/authenticate';
import { getAppStoreAuthAsync } from '../../metadata/auth';
jest.mock('@expo/apple-utils', () => ({
App: { findAsync: jest.fn() },
Token: jest.fn(),
}));
jest.mock('@expo/eas-json', () => ({
EasJsonAccessor: { fromProjectPath: jest.fn() },
EasJsonUtils: { getSubmitProfileAsync: jest.fn() },
}));
jest.mock('../../credentials/context', () => ({
CredentialsContext: jest.fn(),
}));
jest.mock('../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
getRequestContext: jest.fn(),
}));
jest.mock('../../metadata/auth');
jest.mock('../../project/ios/bundleIdentifier', () => ({
getBundleIdentifierAsync: jest.fn().mockResolvedValue('com.example.app'),
}));
jest.mock('../../project/projectUtils', () => ({
getOwnerAccountForProjectIdAsync: jest
.fn()
.mockResolvedValue({ id: 'account-id', name: 'account-name' }),
}));
describe(resolveTestFlightAppAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(EasJsonUtils.getSubmitProfileAsync).mockResolvedValue({} as any);
jest
.mocked(getAppStoreAuthAsync)
.mockRejectedValue(new Error('Configured API key cannot access the app'));
jest.mocked(AccountAppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValue([]);
});
it('forces Apple ID authentication for the final interactive fallback', async () => {
const userAuthCtx = { authState: { context: { token: 'user-token' } } };
const requestContext = { session: 'user' };
const app = { attributes: { bundleId: 'com.example.app' } };
const ensureUserAuthenticatedAsync = jest.fn().mockResolvedValue(userAuthCtx);
jest.mocked(CredentialsContext).mockImplementation(
() =>
({
appStore: { ensureUserAuthenticatedAsync },
}) as any
);
jest.mocked(getRequestContext).mockReturnValue(requestContext as any);
jest.mocked(App.findAsync).mockResolvedValue(app as any);
const result = await resolveTestFlightAppAsync({
actor: {} as any,
analytics: {} as any,
exp: { slug: 'example' } as any,
graphqlClient: {} as any,
nonInteractive: false,
projectDir: '/app',
projectId: 'project-id',
vcsClient: {} as any,
});
expect(getAppStoreAuthAsync).toHaveBeenCalledTimes(1);
expect(ensureUserAuthenticatedAsync).toHaveBeenCalledTimes(1);
expect(getRequestContext).toHaveBeenCalledWith(userAuthCtx);
expect(App.findAsync).toHaveBeenCalledWith(requestContext, {
bundleId: 'com.example.app',
});
expect(result).toBe(app);
});
});
@@ -1,93 +0,0 @@
import { App, BetaCrashLog, BetaFeedbackCrashSubmission } from '@expo/apple-utils';
import { fetchTestFlightCrashAsync } from '../fetch';
import { formatTestFlightCrashDetails } from '../format';
jest.mock('@expo/apple-utils', () => ({
BetaCrashLog: { getCrashLogAsync: jest.fn() },
BetaFeedbackCrashSubmission: { infoAsync: jest.fn() },
}));
const app = { context: {} } as unknown as App;
const submission = {
id: 'crash-id',
attributes: {
createdDate: '2026-07-20T10:00:00.000Z',
comment: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
},
};
describe(fetchTestFlightCrashAsync, () => {
beforeEach(() => {
jest.mocked(BetaFeedbackCrashSubmission.infoAsync).mockResolvedValue(submission as any);
jest.mocked(BetaCrashLog.getCrashLogAsync).mockReset();
});
it('returns the crash log when App Store Connect has one', async () => {
jest
.mocked(BetaCrashLog.getCrashLogAsync)
.mockResolvedValue({ attributes: { logText: 'Thread 0 Crashed:' } } as any);
const { logText } = await fetchTestFlightCrashAsync(app, 'crash-id');
expect(logText).toBe('Thread 0 Crashed:');
});
it('reports no log when App Store Connect answers without one', async () => {
jest
.mocked(BetaCrashLog.getCrashLogAsync)
.mockResolvedValue({ attributes: { logText: undefined } } as any);
const { logText } = await fetchTestFlightCrashAsync(app, 'crash-id');
expect(logText).toBeNull();
});
it('propagates a failed crash log request', async () => {
const error = new Error('Request failed with status code 403');
jest.mocked(BetaCrashLog.getCrashLogAsync).mockRejectedValue(error);
await expect(fetchTestFlightCrashAsync(app, 'crash-id')).rejects.toThrow(error);
});
it('preserves a non-Error rejection', async () => {
jest.mocked(BetaCrashLog.getCrashLogAsync).mockRejectedValue('socket hang up');
await expect(fetchTestFlightCrashAsync(app, 'crash-id')).rejects.toBe('socket hang up');
});
});
describe(`${formatTestFlightCrashDetails.name} log states`, () => {
const crash = {
id: 'crash-id',
createdDate: '2026-07-20T10:00:00.000Z',
comment: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
locale: null,
timeZone: null,
architecture: null,
connectionType: null,
deviceFamily: null,
appUptimeInMilliseconds: null,
batteryPercentage: null,
diskBytesAvailable: null,
diskBytesTotal: null,
buildVersion: null,
testerName: null,
testerEmail: null,
};
it('prints the log when present', () => {
expect(formatTestFlightCrashDetails(crash, 'Thread 0 Crashed:')).toContain('Thread 0 Crashed:');
});
it('says no log is available when there is none', () => {
expect(formatTestFlightCrashDetails(crash, null)).toContain(
'No crash log is available for this submission yet.'
);
});
});
@@ -1,189 +0,0 @@
import { App } from '@expo/apple-utils';
import { getTestFlightCrashesQuery, getTestFlightFeedbackQuery } from '../fetch';
function createSubmission(overrides: Record<string, any> = {}): any {
return {
id: 'submission-id',
attributes: {
createdDate: '2026-07-20T10:00:00.000Z',
comment: 'The login button does nothing',
email: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
locale: 'en-GB',
timeZone: 'Europe/London',
architecture: 'arm64e',
connectionType: 'WIFI',
deviceFamily: 'IPHONE',
appUptimeInMilliseconds: 12_000,
batteryPercentage: 42,
diskBytesAvailable: 1024 ** 3,
diskBytesTotal: 4 * 1024 ** 3,
build: { id: 'build-id', attributes: { version: '42' } },
tester: {
id: 'tester-id',
attributes: { firstName: 'Jane', lastName: 'Doe', email: '[email protected]' },
},
...overrides,
},
};
}
function createApp(submissions: any[]): App {
return {
getBetaFeedbackScreenshotSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
getBetaFeedbackCrashSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
} as unknown as App;
}
describe(getTestFlightFeedbackQuery, () => {
it('normalizes submissions, including the build and tester relationships', async () => {
const query = getTestFlightFeedbackQuery(createApp([createSubmission({ screenshots: [] })]));
const items = await query.queryAsync(20, 0);
expect(await query.getTotalAsync()).toBe(1);
expect(items[0]).toMatchObject({
id: 'submission-id',
comment: 'The login button does nothing',
deviceModel: 'iPhone15,2',
osVersion: '18.2',
buildVersion: '42',
testerName: 'Jane Doe',
testerEmail: '[email protected]',
screenshots: [],
});
});
it('maps screenshots', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: [
{
url: 'https://example.com/screenshot.png',
width: 1170,
height: 2532,
expirationDate: '2026-07-27T10:00:00.000Z',
},
],
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].screenshots).toEqual([
{
url: 'https://example.com/screenshot.png',
width: 1170,
height: 2532,
expirationDate: '2026-07-27T10:00:00.000Z',
},
]);
});
it('falls back to the submission email when the tester relationship has none', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: null,
email: '[email protected]',
tester: { id: 'tester-id', attributes: { firstName: 'Anonymous', lastName: null } },
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].testerName).toBe('Anonymous');
expect(items[0].testerEmail).toBe('[email protected]');
expect(items[0].screenshots).toEqual([]);
});
it('serves pages by limit and offset from a single underlying fetch', async () => {
const app = createApp([
createSubmission({ screenshots: [], comment: 'first' }),
createSubmission({ screenshots: [], comment: 'second' }),
createSubmission({ screenshots: [], comment: 'third' }),
]);
const query = getTestFlightFeedbackQuery(app);
expect((await query.queryAsync(2, 0)).map(item => item.comment)).toEqual(['first', 'second']);
expect((await query.queryAsync(2, 2)).map(item => item.comment)).toEqual(['third']);
expect(await query.queryAsync(2, 5)).toEqual([]);
expect(await query.getTotalAsync()).toBe(3);
// Paging must not re-hit App Store Connect for every page.
expect(app.getBetaFeedbackScreenshotSubmissionsAsync).toHaveBeenCalledTimes(1);
});
});
describe('control character sanitization', () => {
it('strips terminal escape sequences from tester-supplied text', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: [],
// A tester could submit a comment that clears the screen and spoofs a prompt.
comment: 'looks fine\u001b[2J\u001b[HFATAL: run `rm -rf /` to continue',
tester: {
id: 'tester-id',
attributes: { firstName: 'Jane\u001b[31m', lastName: 'Doe', email: '[email protected]' },
},
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].comment).toBe('looks fine[2J[HFATAL: run `rm -rf /` to continue');
expect(items[0].comment).not.toContain('\u001b');
expect(items[0].testerName).toBe('Jane[31m Doe');
expect(items[0].testerName).not.toContain('\u001b');
});
it('keeps newlines and tabs, and normalizes CRLF', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: [],
comment: 'line one\r\nline two\tindented\rline three',
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].comment).toBe('line one\nline two\tindented\nline three');
});
});
describe(getTestFlightCrashesQuery, () => {
it('normalizes crash submissions', async () => {
const query = getTestFlightCrashesQuery(createApp([createSubmission({ comment: null })]));
const items = await query.queryAsync(20, 0);
expect(await query.getTotalAsync()).toBe(1);
expect(items[0]).toMatchObject({
id: 'submission-id',
comment: null,
buildVersion: '42',
testerName: 'Jane Doe',
});
});
it('tolerates submissions without build or tester relationships', async () => {
const query = getTestFlightCrashesQuery(
createApp([createSubmission({ build: undefined, tester: undefined, email: null })])
);
const items = await query.queryAsync(20, 0);
expect(items[0].buildVersion).toBeNull();
expect(items[0].testerName).toBeNull();
expect(items[0].testerEmail).toBeNull();
});
});
@@ -1,100 +0,0 @@
import { TestFlightCrash, TestFlightFeedback } from '../fetch';
import {
formatTestFlightCrash,
formatTestFlightCrashDetails,
formatTestFlightFeedback,
} from '../format';
const submission: TestFlightCrash = {
id: 'f7a1c2d3-1111-2222-3333-444455556666',
createdDate: '2026-07-24T10:00:00.000Z',
comment: 'Tapping Continue does nothing.\nHappens every time on cellular.',
deviceModel: 'iPhone15,2',
osVersion: '18.2',
locale: 'en-GB',
timeZone: 'Europe/London',
architecture: 'arm64e',
connectionType: 'MOBILE_DATA',
deviceFamily: 'IPHONE',
appUptimeInMilliseconds: 42_300,
batteryPercentage: 61,
diskBytesAvailable: 12 * 1024 ** 3,
diskBytesTotal: 128 * 1024 ** 3,
buildVersion: '128',
testerName: 'Jane Doe',
testerEmail: '[email protected]',
};
const feedback: TestFlightFeedback = {
...submission,
screenshots: [
{
url: 'https://example.com/screenshot.png',
width: 1179,
height: 2556,
expirationDate: '2026-07-31T10:00:00.000Z',
},
],
};
describe(formatTestFlightFeedback, () => {
it('includes the tester, device, build, comment, and screenshots', () => {
const output = formatTestFlightFeedback(feedback, 1);
expect(output).toContain('1. Feedback f7a1c2d3-1111-2222-3333-444455556666');
expect(output).toContain('Jane Doe <[email protected]>');
expect(output).toContain('iPhone15,2, iOS 18.2');
expect(output).toContain('Build');
expect(output).toContain('128');
expect(output).toContain('battery 61%, disk 12.0 GB free of 128.0 GB, mobile data');
expect(output).toContain('https://example.com/screenshot.png (1179x2556)');
});
it('indents multi-line comments under their label', () => {
const output = formatTestFlightFeedback(feedback, 1);
expect(output).toContain('Tapping Continue does nothing.\n Happens every time');
});
it('marks feedback without a comment', () => {
const output = formatTestFlightFeedback({ ...feedback, comment: null }, 1);
expect(output).toContain('(no comment)');
});
it('omits the position when rendering a single submission', () => {
const output = formatTestFlightFeedback(feedback);
expect(output).toContain('Feedback f7a1c2d3-1111-2222-3333-444455556666');
expect(output).not.toContain('1. Feedback');
});
});
describe(formatTestFlightCrash, () => {
it('numbers the crash by its absolute position and omits absent optional fields', () => {
const output = formatTestFlightCrash({ ...submission, comment: null, locale: null }, 5);
expect(output).toContain('5. Crash f7a1c2d3-1111-2222-3333-444455556666');
expect(output).not.toContain('Comment');
expect(output).not.toContain('Locale');
});
});
describe(formatTestFlightCrashDetails, () => {
it('appends the crash log', () => {
const output = formatTestFlightCrashDetails(submission, 'Thread 0 Crashed:\n0 MyApp main + 32');
expect(output).toContain('Arch');
expect(output).toContain('arm64e');
expect(output).toContain('Uptime');
expect(output).toContain('42.3s');
expect(output).toContain('Crash log');
expect(output).toContain('Thread 0 Crashed:\n0 MyApp main + 32');
});
it('explains when no crash log is available', () => {
const output = formatTestFlightCrashDetails(submission, null);
expect(output).toContain('No crash log is available for this submission yet.');
});
});
@@ -1,112 +0,0 @@
import { App } from '@expo/apple-utils';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { listAndRenderTestFlightCrashesAsync } from '../queries';
jest.mock('../../log');
jest.mock('../../prompts', () => ({
selectAsync: jest.fn(),
confirmAsync: jest.fn(),
}));
function createApp(count: number): App {
const submissions = Array.from({ length: count }).map((_, index) => ({
id: `crash-${index + 1}`,
attributes: {
createdDate: '2026-07-20T10:00:00.000Z',
comment: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
},
}));
return {
attributes: { bundleId: 'com.example.app' },
getBetaFeedbackCrashSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
} as unknown as App;
}
function loggedOutput(): string {
return jest
.mocked(Log.log)
.mock.calls.map(call => String(call[0]))
.join('\n');
}
describe(listAndRenderTestFlightCrashesAsync, () => {
beforeEach(() => {
jest.mocked(Log.log).mockClear();
jest.mocked(confirmAsync).mockReset();
});
it('numbers items by their absolute position across interactive pages', async () => {
// Accept the first "Load more?" prompt, decline the second.
jest.mocked(confirmAsync).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
await listAndRenderTestFlightCrashesAsync(createApp(6), {
limit: 2,
offset: 0,
json: false,
nonInteractive: false,
});
const output = loggedOutput();
expect(output).toContain('1. Crash crash-1');
expect(output).toContain('2. Crash crash-2');
expect(output).toContain('3. Crash crash-3');
expect(output).toContain('4. Crash crash-4');
expect(output).not.toContain('5. Crash');
expect(jest.mocked(confirmAsync)).toHaveBeenCalledTimes(2);
});
it('stops prompting once the last page is rendered', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(2), {
limit: 2,
offset: 0,
json: false,
nonInteractive: false,
});
expect(loggedOutput()).toContain('2. Crash crash-2');
expect(jest.mocked(confirmAsync)).not.toHaveBeenCalled();
});
it('honors offset when numbering a single non-interactive page', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(6), {
limit: 2,
offset: 4,
json: false,
nonInteractive: true,
});
const output = loggedOutput();
expect(output).toContain('5. Crash crash-5');
expect(output).toContain('6. Crash crash-6');
expect(output).not.toContain('1. Crash');
expect(jest.mocked(confirmAsync)).not.toHaveBeenCalled();
});
it('hints at remaining items when non-interactive', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(6), {
limit: 2,
offset: 0,
json: false,
nonInteractive: true,
});
expect(loggedOutput()).toContain(
'Showing 2 of 6 crashes. Use --offset and --limit to see more.'
);
});
it('reports an empty collection', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(0), {
limit: 2,
offset: 0,
json: false,
nonInteractive: true,
});
expect(loggedOutput()).toContain('No TestFlight crashes have been reported for');
});
});
@@ -1,65 +0,0 @@
import { resolveBetaFeedbackTarget } from '../target';
describe(resolveBetaFeedbackTarget, () => {
it('reads the id and type out of a crash submission URL', () => {
expect(
resolveBetaFeedbackTarget({
idOrUrl:
'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/AAo2eIIfGzcb1BzuUv3xrh4',
defaultType: 'screenshot',
})
).toEqual({ id: 'AAo2eIIfGzcb1BzuUv3xrh4', type: 'crash' });
});
it('reads the id and type out of a screenshot submission URL', () => {
expect(
resolveBetaFeedbackTarget({
idOrUrl:
'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
defaultType: 'crash',
})
).toEqual({ id: 'AD8JvKbr0BK0Cj9OnM6WO6I', type: 'screenshot' });
});
it('falls back to the command default for a bare id', () => {
expect(resolveBetaFeedbackTarget({ idOrUrl: 'some-id', defaultType: 'crash' })).toEqual({
id: 'some-id',
type: 'crash',
});
});
it('lets --type override the default for a bare id', () => {
expect(
resolveBetaFeedbackTarget({ idOrUrl: 'some-id', type: 'screenshot', defaultType: 'crash' })
).toEqual({ id: 'some-id', type: 'screenshot' });
});
it('rejects a --type that contradicts the URL', () => {
expect(() =>
resolveBetaFeedbackTarget({
idOrUrl: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/abc',
type: 'screenshot',
defaultType: 'screenshot',
})
).toThrow('--type screenshot does not match the URL, which points at crash feedback');
});
it('rejects a URL that is not a beta feedback submission', () => {
expect(() =>
resolveBetaFeedbackTarget({
idOrUrl: 'https://appstoreconnect.apple.com/apps/6794186602/testflight/ios',
defaultType: 'crash',
})
).toThrow('Could not tell whether');
});
it('ignores query strings when reading the id', () => {
expect(
resolveBetaFeedbackTarget({
idOrUrl:
'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/abc?include=build',
defaultType: 'screenshot',
})
).toEqual({ id: 'abc', type: 'crash' });
});
});
-191
View File
@@ -1,191 +0,0 @@
import { App, RequestContext, Token } from '@expo/apple-utils';
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils, SubmitProfile } from '@expo/eas-json';
import { MissingEasJsonError } from '@expo/eas-json/build/errors';
import assert from 'assert';
import { Analytics } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { AppStoreConnectApiKeyQuery as AccountAppStoreConnectApiKeyQuery } from '../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../log';
import { getAppStoreAuthAsync } from '../metadata/auth';
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { Actor } from '../user/User';
import { Client } from '../vcs/vcs';
/** How long the ASC JWTs we mint stay valid, matching the credentials service default. */
const ASC_TOKEN_DURATION_SECONDS = 1200;
/**
* TestFlight data lives in App Store Connect, so we resolve the bundle identifier and the ASC API
* key from the submit profile the same way `eas metadata` does. When the project has no eas.json
* we fall back to an empty profile, which resolves the bundle identifier from the app config and
* the API key from env vars or the EAS credentials service.
*/
async function resolveSubmitProfileAsync({
projectDir,
profileName,
}: {
projectDir: string;
profileName?: string;
}): Promise<SubmitProfile<Platform.IOS>> {
try {
return await EasJsonUtils.getSubmitProfileAsync(
EasJsonAccessor.fromProjectPath(projectDir),
Platform.IOS,
profileName
);
} catch (error: any) {
if (error instanceof MissingEasJsonError && !profileName) {
return {} as SubmitProfile<Platform.IOS>;
}
throw error;
}
}
/**
* Look for the app in every ASC API key registered on the account that owns the project. This is
* the resolution the Expo MCP server uses for its TestFlight tools, and it covers projects that
* have no ASC API key assigned to their iOS submission credentials.
*/
async function findAppWithAccountAscApiKeysAsync({
bundleId,
graphqlClient,
projectId,
}: {
bundleId: string;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<App | null> {
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const keys = await AccountAppStoreConnectApiKeyQuery.getAllForAccountAsync(
graphqlClient,
account.name
);
for (const key of keys) {
try {
const { keyP8, keyIdentifier, issuerIdentifier } =
await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, key.id);
const context: RequestContext = {
token: new Token({
key: keyP8,
keyId: keyIdentifier,
issuerId: issuerIdentifier,
duration: ASC_TOKEN_DURATION_SECONDS,
}),
};
const app = await App.findAsync(context, { bundleId });
if (app) {
Log.log(
`Using App Store Connect API Key ${key.name ?? key.keyIdentifier} from your Expo account.`
);
return app;
}
} catch (error: unknown) {
// Never read `.message` off an unknown rejection: a non-Error value (a string, or
// null from a wrapped fetch) would make this handler throw a TypeError, hiding the
// real failure and aborting the loop instead of falling through to the next key.
Log.debug(
`ASC API Key ${key.keyIdentifier} cannot access ${bundleId}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
return null;
}
/** Resolve the App Store Connect app that TestFlight feedback and crashes should be read from. */
export async function resolveTestFlightAppAsync({
actor,
analytics,
exp,
graphqlClient,
nonInteractive,
profileName,
projectDir,
projectId,
vcsClient,
}: {
actor: Actor;
analytics: Analytics;
exp: ExpoConfig;
graphqlClient: ExpoGraphqlClient;
nonInteractive: boolean;
profileName?: string;
projectDir: string;
projectId: string;
vcsClient: Client;
}): Promise<App> {
const profile = await resolveSubmitProfileAsync({ projectDir, profileName });
const createCredentialsContext = (contextNonInteractive: boolean): CredentialsContext =>
new CredentialsContext({
projectInfo: { exp, projectId },
projectDir,
user: actor,
graphqlClient,
analytics,
nonInteractive: contextNonInteractive,
vcsClient,
});
// Prefer the key configured for this project: ASC env vars, the submit profile, or the key
// assigned to its iOS submission credentials. Forced non-interactive so that a project without
// any of those falls through to the account-wide lookup instead of prompting for an Apple login.
try {
const { app } = await getAppStoreAuthAsync({
exp,
credentialsCtx: createCredentialsContext(true),
graphqlClient,
nonInteractive: true,
profile,
projectDir,
projectId,
});
return app;
} catch (error: unknown) {
Log.debug(
`Could not resolve an App Store Connect API Key for this project: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const bundleId =
('bundleIdentifier' in profile ? profile.bundleIdentifier : undefined) ??
(await getBundleIdentifierAsync(projectDir, exp, vcsClient));
const appFromAccountKeys = await findAppWithAccountAscApiKeysAsync({
bundleId,
graphqlClient,
projectId,
});
if (appFromAccountKeys) {
return appFromAccountKeys;
}
if (nonInteractive) {
throw new Error(
`Could not find "${bundleId}" in App Store Connect with any App Store Connect API Key available to this project.\n` +
'Provide a key with access to the app via:\n' +
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
' - EAS credentials service: run `eas credentials` to set up an API key'
);
}
// Last resort: an interactive Apple ID login.
const credentialsCtx = createCredentialsContext(false);
const authCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync();
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
return app;
}
-210
View File
@@ -1,210 +0,0 @@
import {
App,
BetaCrashLog,
BetaFeedbackCrashSubmission,
BetaFeedbackScreenshotSubmission,
} from '@expo/apple-utils';
/**
* The largest page size the App Store Connect API accepts. The apple-utils helpers always walk
* every page, so we ask for the largest pages possible and paginate client-side from there.
*/
const MAX_PAGE_SIZE = 200;
/** Which kind of TestFlight beta feedback a submission is. Matches the workflow trigger context. */
export type BetaFeedbackType = 'crash' | 'screenshot';
/** Screenshot attached to a piece of TestFlight feedback. Its URL expires after a short while. */
export type TestFlightScreenshot = {
url: string;
width: number;
height: number;
expirationDate: string;
};
/** Fields shared by TestFlight screenshot feedback and TestFlight crash submissions. */
export type TestFlightSubmission = {
id: string;
createdDate: string;
comment: string | null;
deviceModel: string;
osVersion: string;
locale: string | null;
timeZone: string | null;
architecture: string | null;
connectionType: string | null;
deviceFamily: string | null;
appUptimeInMilliseconds: number | null;
batteryPercentage: number | null;
diskBytesAvailable: number | null;
diskBytesTotal: number | null;
buildVersion: string | null;
testerName: string | null;
testerEmail: string | null;
};
export type TestFlightFeedback = TestFlightSubmission & {
screenshots: TestFlightScreenshot[];
};
export type TestFlightCrash = TestFlightSubmission;
/**
* A paginated view over one App Store Connect collection. App Store Connect has no offset-based
* paging and apple-utils always walks every page, so the underlying fetch happens once and pages
* are served from that result — which also makes the total count available for free.
*/
export type TestFlightQuery<T> = {
queryAsync(limit: number, offset: number): Promise<T[]>;
getTotalAsync(): Promise<number>;
};
function createQuery<T>(fetchAllAsync: () => Promise<T[]>): TestFlightQuery<T> {
let all: Promise<T[]> | undefined;
const allAsync = async (): Promise<T[]> => {
all ??= fetchAllAsync();
return await all;
};
return {
async queryAsync(limit: number, offset: number): Promise<T[]> {
return (await allAsync()).slice(offset, offset + limit);
},
async getTotalAsync(): Promise<number> {
return (await allAsync()).length;
},
};
}
/**
* The `build` and `tester` relationships are requested through `includes`, so they land on the
* model attributes at runtime without being part of the attribute types.
*/
type SubmissionRelationships = {
build?: { id: string; attributes?: { version?: string | null } } | null;
tester?: {
id: string;
attributes?: { firstName?: string | null; lastName?: string | null; email?: string | null };
} | null;
};
/**
* Everything here is attacker-influenced text: any TestFlight tester can put arbitrary bytes in a
* feedback comment or their own name, and crash logs carry thread and symbol names from the build.
* Terminal escape sequences in that text could clear the screen or spoof output when it is printed,
* so strip control characters at the boundary — before the data reaches either the formatters or
* `--json`. Newlines and tabs survive because multi-line comments and crash logs need them.
*/
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
function sanitizeText(value: string): string {
return value.replace(/\r\n?/g, '\n').replace(CONTROL_CHARACTERS, '');
}
function sanitizeOptionalText(value: unknown): string | null {
return typeof value === 'string' ? sanitizeText(value) : null;
}
function formatTesterName(tester: SubmissionRelationships['tester']): string | null {
const parts = [tester?.attributes?.firstName, tester?.attributes?.lastName].filter(
(part): part is string => !!part
);
return parts.length > 0 ? sanitizeText(parts.join(' ')) : null;
}
function normalizeSubmission(submission: {
id: string;
attributes: Record<string, any>;
}): TestFlightSubmission {
const attributes = submission.attributes as Record<string, any> & SubmissionRelationships;
return {
id: submission.id,
createdDate: attributes.createdDate,
comment: sanitizeOptionalText(attributes.comment),
deviceModel: sanitizeText(attributes.deviceModel ?? ''),
osVersion: sanitizeText(attributes.osVersion ?? ''),
locale: sanitizeOptionalText(attributes.locale),
timeZone: sanitizeOptionalText(attributes.timeZone),
architecture: sanitizeOptionalText(attributes.architecture),
connectionType: sanitizeOptionalText(attributes.connectionType),
deviceFamily: sanitizeOptionalText(attributes.deviceFamily),
appUptimeInMilliseconds: attributes.appUptimeInMilliseconds ?? null,
batteryPercentage: attributes.batteryPercentage ?? null,
diskBytesAvailable: attributes.diskBytesAvailable ?? null,
diskBytesTotal: attributes.diskBytesTotal ?? null,
buildVersion: sanitizeOptionalText(attributes.build?.attributes?.version),
testerName: formatTesterName(attributes.tester),
// The tester relationship hides the email of testers who joined through a public link, in
// which case the submission itself still carries the address they submitted feedback with.
testerEmail: sanitizeOptionalText(attributes.tester?.attributes?.email ?? attributes.email),
};
}
function normalizeFeedback(submission: {
id: string;
attributes: Record<string, any>;
}): TestFlightFeedback {
return {
...normalizeSubmission(submission),
screenshots: ((submission.attributes.screenshots ?? []) as TestFlightScreenshot[]).map(
screenshot => ({
url: sanitizeText(screenshot.url ?? ''),
width: screenshot.width,
height: screenshot.height,
expirationDate: screenshot.expirationDate,
})
),
};
}
/** Screenshot feedback that TestFlight testers submitted for the app, newest first. */
export function getTestFlightFeedbackQuery(app: App): TestFlightQuery<TestFlightFeedback> {
return createQuery(async () => {
const submissions = await app.getBetaFeedbackScreenshotSubmissionsAsync({
query: { limit: MAX_PAGE_SIZE },
});
return submissions.map(normalizeFeedback);
});
}
/** Crashes that TestFlight testers reported for the app, newest first. */
export function getTestFlightCrashesQuery(app: App): TestFlightQuery<TestFlightCrash> {
return createQuery(async () => {
const submissions = await app.getBetaFeedbackCrashSubmissionsAsync({
query: { limit: MAX_PAGE_SIZE },
});
return submissions.map(normalizeSubmission);
});
}
/** Fetch a single screenshot feedback submission by ID. */
export async function fetchTestFlightFeedbackAsync(
app: App,
feedbackId: string
): Promise<TestFlightFeedback> {
const submission = await BetaFeedbackScreenshotSubmission.infoAsync(app.context, {
id: feedbackId,
});
return normalizeFeedback(submission);
}
export type TestFlightCrashDetails = {
crash: TestFlightCrash;
/** The crash log, or `null` when App Store Connect has none for this submission. */
logText: string | null;
};
/** Fetch a single crash submission together with its full crash log. */
export async function fetchTestFlightCrashAsync(
app: App,
crashId: string
): Promise<TestFlightCrashDetails> {
const submission = await BetaFeedbackCrashSubmission.infoAsync(app.context, { id: crashId });
const crashLog = await BetaCrashLog.getCrashLogAsync(app.context, {
betaFeedbackCrashSubmissionId: crashId,
});
return {
crash: normalizeSubmission(submission),
logText: sanitizeOptionalText(crashLog.attributes.logText),
};
}
-123
View File
@@ -1,123 +0,0 @@
import chalk from 'chalk';
import { TestFlightCrash, TestFlightFeedback, TestFlightSubmission } from './fetch';
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
import { fromNow } from '../utils/date';
function formatCreatedDate(createdDate: string): string {
const date = new Date(createdDate);
if (isNaN(date.getTime())) {
return createdDate;
}
return `${date.toLocaleString()} (${fromNow(date)} ago)`;
}
function formatDevice(submission: TestFlightSubmission): string {
return [submission.deviceModel, `iOS ${submission.osVersion}`].join(', ');
}
function formatTester(submission: TestFlightSubmission): string | null {
if (submission.testerName && submission.testerEmail) {
return `${submission.testerName} <${submission.testerEmail}>`;
}
return submission.testerName ?? submission.testerEmail ?? null;
}
function formatBytes(bytes: number): string {
const gigabytes = bytes / 1024 ** 3;
if (gigabytes >= 1) {
return `${gigabytes.toFixed(1)} GB`;
}
return `${(bytes / 1024 ** 2).toFixed(0)} MB`;
}
function formatDeviceState(submission: TestFlightSubmission): string | null {
const parts: string[] = [];
if (submission.batteryPercentage !== null) {
parts.push(`battery ${submission.batteryPercentage}%`);
}
if (submission.diskBytesAvailable !== null && submission.diskBytesTotal !== null) {
parts.push(
`disk ${formatBytes(submission.diskBytesAvailable)} free of ${formatBytes(
submission.diskBytesTotal
)}`
);
}
if (submission.connectionType) {
parts.push(submission.connectionType.toLowerCase().replace('_', ' '));
}
return parts.length > 0 ? parts.join(', ') : null;
}
/** Indent every line of a possibly multi-line value so it lines up under its label. */
function indentValue(value: string, indent: number): string {
return value.split('\n').join(`\n${' '.repeat(indent)}`);
}
function commonFields(submission: TestFlightSubmission): FormatFieldsItem[] {
const tester = formatTester(submission);
const deviceState = formatDeviceState(submission);
return [
{ label: 'Submitted', value: formatCreatedDate(submission.createdDate) },
...(submission.buildVersion ? [{ label: 'Build', value: submission.buildVersion }] : []),
{ label: 'Device', value: formatDevice(submission) },
...(tester ? [{ label: 'Tester', value: tester }] : []),
...(submission.locale ? [{ label: 'Locale', value: submission.locale }] : []),
...(deviceState ? [{ label: 'State', value: deviceState }] : []),
];
}
function formatBlock(heading: string, fields: FormatFieldsItem[]): string {
// `formatFields` pads labels to the widest one, so multi-line values must be indented by that
// width plus the two spaces it puts between label and value.
const labelWidth = Math.max(...fields.map(field => field.label.length)) + 2;
return [
chalk.bold(heading),
formatFields(fields.map(field => ({ ...field, value: indentValue(field.value, labelWidth) }))),
].join('\n');
}
/** Prefix list entries with their absolute position, so `--offset` numbering stays meaningful. */
function heading(kind: string, id: string, position?: number): string {
return position === undefined ? `${kind} ${id}` : `${position}. ${kind} ${id}`;
}
export function formatTestFlightFeedback(feedback: TestFlightFeedback, position?: number): string {
const screenshots = feedback.screenshots.map(
screenshot => `${screenshot.url} (${screenshot.width}x${screenshot.height})`
);
return formatBlock(heading('Feedback', feedback.id, position), [
...commonFields(feedback),
{ label: 'Comment', value: feedback.comment ?? chalk.dim('(no comment)') },
...(screenshots.length > 0 ? [{ label: 'Screenshots', value: screenshots.join('\n') }] : []),
]);
}
export function formatTestFlightCrash(crash: TestFlightCrash, position?: number): string {
return formatBlock(heading('Crash', crash.id, position), [
...commonFields(crash),
...(crash.comment ? [{ label: 'Comment', value: crash.comment }] : []),
]);
}
export function formatTestFlightCrashDetails(
crash: TestFlightCrash,
logText: string | null
): string {
const details = formatBlock(heading('Crash', crash.id), [
...commonFields(crash),
...(crash.architecture ? [{ label: 'Arch', value: crash.architecture }] : []),
...(crash.appUptimeInMilliseconds !== null
? [{ label: 'Uptime', value: `${(crash.appUptimeInMilliseconds / 1000).toFixed(1)}s` }]
: []),
...(crash.comment ? [{ label: 'Comment', value: crash.comment }] : []),
]);
return [
details,
'',
chalk.bold('Crash log'),
logText ?? chalk.dim('No crash log is available for this submission yet.'),
].join('\n');
}
-182
View File
@@ -1,182 +0,0 @@
import { App } from '@expo/apple-utils';
import chalk from 'chalk';
import {
TestFlightCrash,
TestFlightFeedback,
TestFlightQuery,
TestFlightSubmission,
fetchTestFlightCrashAsync,
fetchTestFlightFeedbackAsync,
getTestFlightCrashesQuery,
getTestFlightFeedbackQuery,
} from './fetch';
import {
formatTestFlightCrash,
formatTestFlightCrashDetails,
formatTestFlightFeedback,
} from './format';
import { BetaFeedbackTarget } from './target';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import Log from '../log';
import { printJsonOnlyOutput } from '../utils/json';
import { paginatedQueryWithConfirmPromptAsync } from '../utils/queries';
/** Feedback and crashes render as multi-line blocks, so default to a smaller page than lists do. */
export const TESTFLIGHT_PAGE_SIZE = 20;
type ListRendering<T extends TestFlightSubmission> = {
/** Key the items are nested under in `--json` output. */
jsonKey: string;
formatItem: (item: T, position?: number) => string;
emptyMessage: (bundleId: string) => string;
loadMorePrompt: string;
/** Hint printed after a page when more items exist but interactive paging is unavailable. */
moreAvailableHint: (shown: number, total: number) => string;
};
const FEEDBACK_RENDERING: ListRendering<TestFlightFeedback> = {
jsonKey: 'feedback',
formatItem: formatTestFlightFeedback,
emptyMessage: bundleId =>
`No TestFlight feedback has been submitted for ${chalk.bold(bundleId)}.`,
loadMorePrompt: 'Load more feedback?',
moreAvailableHint: (shown, total) =>
`Showing ${shown} of ${total} feedback submissions. Use --offset and --limit to see more.`,
};
const CRASHES_RENDERING: ListRendering<TestFlightCrash> = {
jsonKey: 'crashes',
formatItem: formatTestFlightCrash,
emptyMessage: bundleId => `No TestFlight crashes have been reported for ${chalk.bold(bundleId)}.`,
loadMorePrompt: 'Load more crashes?',
moreAvailableHint: (shown, total) =>
`Showing ${shown} of ${total} crashes. Use --offset and --limit to see more.`,
};
async function listAndRenderAsync<T extends TestFlightSubmission>({
bundleId,
query,
rendering,
paginatedQueryOptions,
}: {
bundleId: string;
query: TestFlightQuery<T>;
rendering: ListRendering<T>;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<void> {
const { json, nonInteractive, offset } = paginatedQueryOptions;
const limit = paginatedQueryOptions.limit ?? TESTFLIGHT_PAGE_SIZE;
if (json) {
const [items, total] = await Promise.all([
query.queryAsync(limit, offset),
query.getTotalAsync(),
]);
printJsonOnlyOutput({
[rendering.jsonKey]: items,
total,
offset,
limit,
hasNextPage: offset + items.length < total,
});
return;
}
const renderPage = (items: T[], pageOffset: number): void => {
Log.addNewLineIfNone();
if (items.length === 0) {
Log.log(
pageOffset === 0
? rendering.emptyMessage(bundleId)
: chalk.dim('No items past this offset.')
);
return;
}
Log.log(
items.map((item, index) => rendering.formatItem(item, pageOffset + index + 1)).join('\n\n')
);
};
if (nonInteractive) {
const items = await query.queryAsync(limit, offset);
renderPage(items, offset);
const total = await query.getTotalAsync();
if (offset + items.length < total) {
Log.newLine();
Log.log(chalk.dim(rendering.moreAvailableHint(offset + items.length, total)));
}
return;
}
// `paginatedQueryWithConfirmPromptAsync` renders one page at a time without reporting the offset
// it used, so track how many items have been rendered to keep the numbering absolute.
let renderedCount = 0;
await paginatedQueryWithConfirmPromptAsync({
limit,
offset,
queryToPerform: (pageLimit, pageOffset) => query.queryAsync(pageLimit, pageOffset),
promptOptions: {
title: rendering.loadMorePrompt,
renderListItems: items => {
renderPage(items, offset + renderedCount);
renderedCount += items.length;
},
},
});
}
/** List screenshot feedback submitted by TestFlight testers. */
export async function listAndRenderTestFlightFeedbackAsync(
app: App,
paginatedQueryOptions: PaginatedQueryOptions
): Promise<void> {
await listAndRenderAsync({
bundleId: app.attributes.bundleId,
query: getTestFlightFeedbackQuery(app),
rendering: FEEDBACK_RENDERING,
paginatedQueryOptions,
});
}
/** List crashes reported by TestFlight testers. */
export async function listAndRenderTestFlightCrashesAsync(
app: App,
paginatedQueryOptions: PaginatedQueryOptions
): Promise<void> {
await listAndRenderAsync({
bundleId: app.attributes.bundleId,
query: getTestFlightCrashesQuery(app),
rendering: CRASHES_RENDERING,
paginatedQueryOptions,
});
}
/**
* Render a single submission identified by an EAS workflow trigger's
* `app_store_connect.beta_feedback` payload. Crashes are rendered with their full crash log.
*/
export async function viewAndRenderBetaFeedbackAsync(
app: App,
target: BetaFeedbackTarget,
{ json }: { json: boolean }
): Promise<void> {
if (target.type === 'crash') {
const { crash, logText } = await fetchTestFlightCrashAsync(app, target.id);
if (json) {
printJsonOnlyOutput({ crash, logText });
return;
}
Log.addNewLineIfNone();
Log.log(formatTestFlightCrashDetails(crash, logText));
return;
}
const feedback = await fetchTestFlightFeedbackAsync(app, target.id);
if (json) {
printJsonOnlyOutput({ feedback });
return;
}
Log.addNewLineIfNone();
Log.log(formatTestFlightFeedback(feedback));
}
-52
View File
@@ -1,52 +0,0 @@
import { BetaFeedbackType } from './fetch';
/**
* App Store Connect self-links, which are what EAS workflow triggers put in
* `app_store_connect.beta_feedback.url`:
*
* https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/<id>
* https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/<id>
*/
const FEEDBACK_URL_PATTERN =
/\/(betaFeedbackCrashSubmissions|betaFeedbackScreenshotSubmissions)\/([^/?#]+)/i;
export type BetaFeedbackTarget = {
id: string;
type: BetaFeedbackType;
};
/**
* Resolve a single submission to look up from what an EAS workflow trigger provides in
* `app_store_connect.beta_feedback`: either the `url` (which encodes both `id` and `type`) or the
* bare `id` alongside `type`. Without either, the command's own kind of feedback is assumed.
*/
export function resolveBetaFeedbackTarget({
idOrUrl,
type,
defaultType,
}: {
idOrUrl: string;
type?: BetaFeedbackType;
defaultType: BetaFeedbackType;
}): BetaFeedbackTarget {
const match = idOrUrl.match(FEEDBACK_URL_PATTERN);
if (match) {
const urlType: BetaFeedbackType = match[1].toLowerCase().startsWith('betafeedbackcrash')
? 'crash'
: 'screenshot';
if (type && type !== urlType) {
throw new Error(
`--type ${type} does not match the URL, which points at ${urlType} feedback. Omit --type when passing a URL.`
);
}
return { id: decodeURIComponent(match[2]), type: urlType };
}
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(idOrUrl)) {
throw new Error(
`Could not tell whether "${idOrUrl}" points at crash or screenshot feedback. Pass the App Store Connect API URL from \${{ app_store_connect.beta_feedback.url }}, or pass the ID with --type.`
);
}
return { id: idOrUrl, type: type ?? defaultType };
}
+1 -20
View File
@@ -1,26 +1,7 @@
import { CurrentUserQuery, PartnerActor, Robot, Role, SsoUser, User } from '../graphql/generated';
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
export type Actor = NonNullable<CurrentUserQuery['meActor']>;
/**
* Names of the accounts where the actor can create projects (non-ViewOnly role), listed in the
* same order as account selection prompts: the personal account first, then team accounts,
* then organization accounts.
*/
export function getCreatableAccountNames(actor: Actor): string[] {
const creatableAccounts = actor.accounts.filter(
a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly
);
if (actor.__typename === 'Robot') {
return creatableAccounts.map(it => it.name);
}
return [
...creatableAccounts.filter(a => a.ownerUserActor?.id === actor.id),
...creatableAccounts.filter(a => a.ownerUserActor && a.ownerUserActor.id !== actor.id),
...creatableAccounts.filter(a => !a.ownerUserActor),
].map(it => it.name);
}
/**
* Resolve the name of the actor, either normal user, sso user or robot user.
* This should be used whenever the "current user" needs to be displayed.
@@ -1,5 +1,5 @@
import { Role } from '../../graphql/generated';
import { Actor, getActorDisplayName, getCreatableAccountNames } from '../User';
import { Actor, getActorDisplayName } from '../User';
const userStub: Actor = {
__typename: 'User',
@@ -72,37 +72,3 @@ describe('getActorDisplayName', () => {
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
});
});
describe('getCreatableAccountNames', () => {
it('lists the personal account first, then team accounts, then organizations, excluding view-only accounts', () => {
const actor: Actor = {
...userStub,
accounts: [
{
id: 'account_id_1',
name: 'some-org',
users: [{ role: Role.Owner, actor: { id: 'userId' } }],
},
{
id: 'account_id_2',
name: 'other-user-team',
ownerUserActor: { id: 'otherUserId', username: 'other-user' },
users: [{ role: Role.Admin, actor: { id: 'userId' } }],
},
{
id: 'account_id_3',
name: 'view-only-org',
users: [{ role: Role.ViewOnly, actor: { id: 'userId' } }],
},
{
id: 'account_id_4',
name: 'username',
ownerUserActor: { id: 'userId', username: 'username' },
users: [{ role: Role.Owner, actor: { id: 'userId' } }],
},
],
};
expect(getCreatableAccountNames(actor)).toEqual(['username', 'other-user-team', 'some-org']);
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "21.3.0",
"version": "21.1.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "21.3.0",
"@expo/eas-build-job": "21.1.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "21.3.0",
"version": "21.1.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "21.3.0",
"@expo/eas-build-job": "21.3.0",
"@expo/build-tools": "21.1.0",
"@expo/eas-build-job": "21.1.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "21.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "21.3.0",
"version": "21.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "21.3.0",
"@expo/eas-build-job": "21.1.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "21.3.0",
"version": "21.1.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "21.3.0",
"@expo/build-tools": "21.1.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.3.0",
"@expo/eas-build-job": "21.1.0",
"@expo/logger": "21.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.3.0",
"@expo/steps": "21.1.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@hapi/boom": "10.0.1",
Generated
+17 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:21.3.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.1.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:21.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.3.0"
"@expo/steps": "npm:21.1.0"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2547,7 +2547,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:21.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:21.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2566,12 +2566,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:21.3.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:21.1.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.1.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2884,11 +2884,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:21.3.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:21.1.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2957,16 +2957,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:21.3.0"
"@expo/build-tools": "npm:21.1.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/logger": "npm:21.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.3.0"
"@expo/steps": "npm:21.1.0"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9722,7 +9722,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:21.3.0"
"@expo/steps": "npm:21.1.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10294,8 +10294,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/build-tools": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:21.0.0"
@@ -10352,8 +10352,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-json": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/eas-json": "npm:21.1.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:21.0.0"
@@ -10368,7 +10368,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.3.0"
"@expo/steps": "npm:21.1.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"