Compare commits

...
Author SHA1 Message Date
Brent Vatne 6ffce49f61 Add a prerelease release process
There was no way to ship a version for people to try. Running the existing release
with a prerelease version would have been actively harmful: `lerna publish
from-package` passed no --dist-tag and npm has not special-cased prerelease versions
since v7, so `latest` would have pointed at the prerelease and every `npx eas-cli`
user would have gotten it. The run would also have moved `latest-eas-build` and
`latest-eas-build-staging` — which select the CLI version for EAS Build, so a
prerelease would have reached production builds — and consumed the unreleased
CHANGELOG.md section, committing it to main and Slack-announcing it as a release.
The stale `next` (7.1.0) and `alpha` (0.16.1-alpha.0) dist-tags show how long it has
been since anyone tried.

Detect a SemVer prerelease identifier on the pushed tag and branch on it: publish
under `next`, skip both EAS Build dist-tag steps, and skip the changelog job so the
entries stay unreleased for the stable release that follows. The GitHub release is
published immediately and flagged as a prerelease instead of waiting for the
changelog job to un-draft it.

The dist-tag is now always passed explicitly. `latest` is what npm defaulted to, so
stable releases are unchanged — but that silent default is what made this unsafe, so
the intent is stated rather than inherited.

Documents the process, what differs from a stable release, how it interacts with the
other packages in the monorepo (fixed mode versions only what changed; `next` covers
every package published in the run), and that prereleases are cut from main so they
are not the tool for validating an unmerged branch.
2026-07-26 19:22:30 -07:00
Expo CI 8dc6bffe36 update CHANGELOG.md 2026-07-27 02:01:04 +00:00
Expo CI 3784a50399 v21.3.0 2026-07-27 01:58:10 +00:00
Brent Vatne ddf386597c [eas-cli] Add eas testflight:feedback and eas testflight:crashes (#4084) 2026-07-26 18:52:00 -07:00
Brent Vatne a2cf4c658d [reviewer] Use gpt-5.5 @expo/[email protected] (#4085) 2026-07-26 18:27:05 -07:00
Philippe Loulidi 23d1ed3af5 Bump @expo/code-review-cli default to ^0.4.0 (#4082) 2026-07-24 16:26:02 -07:00
Brent Vatne 417f0df466 [eas-cli] Add --json flag and usage examples to eas init (#4079) 2026-07-24 16:25:48 -07:00
William Grosset 591db3b142 [eas-cli] Add --account flag to eas init (#4057)
* [eas-cli] Add --account flag to eas init

* [eas-cli] Update CHANGELOG

* [eas-cli] Skip confirmations when eas init --account is run non-interactively

* [eas-cli] Apply review copy suggestions and make --account check view-only aware

* [eas-cli] Error or re-link when eas init --account targets an already-linked project

* [eas-cli] Allow eas init --account --force to overwrite a conflicting owner field

* [eas-cli] List accounts in prompt order instead of by creation date in error messages

* [eas-cli] Update robot user test expectation for the eas init --account error message
2026-07-24 14:39:18 -07:00
43 changed files with 2611 additions and 268 deletions

No files matched your search

+4 -5
View File
@@ -1,11 +1,10 @@
---
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
alwaysRun: true
# 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
# 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 & secrets
+13 -11
View File
@@ -1,10 +1,9 @@
{
// 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",
// 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",
// 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.
@@ -31,12 +30,15 @@
"breakGlass": { "marker": "/skip-review" },
"commentTag": "expo-ai-code-reviewer",
// 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.
// 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.)
"auth": {
"mode": "oauth",
"provider": "anthropic",
"tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
"provider": "openai",
"tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN"
}
}
+3 -4
View File
@@ -1,8 +1,7 @@
---
# 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
# 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.
---
# 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.2.3' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
concurrency:
group: ai-code-review-cmd-${{ github.event.issue.number }}
@@ -104,7 +104,8 @@ jobs:
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
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.2.3' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
concurrency:
group: ai-code-review-dismiss-${{ github.event.issue.number }}
+10 -6
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.2.x.
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.5.x.
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
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 || 'ANTHROPIC_OAUTH_API_KEY' }}
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
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,9 +135,13 @@ jobs:
env:
# GitHub token scoped by the permissions block above (PR comments only).
GH_TOKEN: ${{ secrets.GITHUB_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 }}
# 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' }}
# Optional: override the model used by every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.resolve.outputs.agents }}
+48 -4
View File
@@ -6,9 +6,34 @@ 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:
@@ -21,11 +46,15 @@ jobs:
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
# 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' }}
publish-to-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: release
needs: [resolve, release]
permissions:
id-token: write
steps:
@@ -35,9 +64,18 @@ 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
run: yarn run -T lerna publish from-package --yes --no-verify-access
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.
- name: Add latest-eas-build-staging tag
if: needs.resolve.outputs.prerelease == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
run: |
@@ -45,16 +83,22 @@ 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: publish-to-npm
needs: [resolve, publish-to-npm]
permissions:
contents: write
steps:
+1 -1
View File
@@ -4,7 +4,7 @@ on:
version:
type: string
required: false
description: 'Version number for the release'
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.'
dry_run:
type: boolean
required: true
+10 -3
View File
@@ -8,13 +8,20 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 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))
### 🐛 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))
## [21.2.0](https://github.com/expo/eas-cli/releases/tag/v21.2.0) - 2026-07-24
### 🎉 New features
+75
View File
@@ -12,3 +12,78 @@ 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.2.0",
"version": "21.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.1.0",
"version": "21.3.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.1.0",
"@expo/eas-build-job": "21.3.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.1.0",
"@expo/steps": "21.3.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.1.0",
"version": "21.3.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.1.0"
"@expo/steps": "21.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.1.0",
"version": "21.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+223 -116
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.2.0",
"version": "21.3.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.1.0",
"@expo/eas-json": "21.1.0",
"@expo/eas-build-job": "21.3.0",
"@expo/eas-json": "21.3.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.1.0",
"@expo/steps": "21.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -221,6 +221,9 @@
"secret": {
"description": "manage project and account secrets"
},
"testflight": {
"description": "read TestFlight feedback and crashes"
},
"update": {
"description": "manage individual updates"
},
@@ -478,7 +478,7 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: false }
)
).rejects.toThrow(
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
'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'
);
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 } from '../../../user/User';
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
/**
* Save an EAS project ID to the appropriate field in the app config.
@@ -227,7 +227,9 @@ async function chooseAccountNameForEASProjectAsync(
if (actor.__typename === 'Robot') {
throw new Error(
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
`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(', ')}`
);
}
@@ -17,6 +17,7 @@ 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');
@@ -37,6 +38,7 @@ jest.mock('../../../ora', () => ({
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../project/projectUtils');
jest.mock('../../../utils/json');
let originalProcessArgv: string[];
@@ -535,4 +537,354 @@ 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();
});
});
});
+49 -13
View File
@@ -1,26 +1,43 @@
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',
}),
...EASNonInteractiveFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
@@ -29,31 +46,50 @@ export default class ProjectInit extends EasCommand {
};
async runAsync(): Promise<void> {
const {
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
} = await this.parse(ProjectInit);
const { flags } = await this.parse(ProjectInit);
const { id: idArgument, account: accountArgument, force } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
loggedIn: { actor, graphqlClient },
projectDir,
} = await this.getContextAsync(ProjectInit, { nonInteractive });
let idForConsistency: string;
let result: ProjectInitResult;
if (idArgument) {
await initializeWithExplicitIDAsync(idArgument, projectDir, {
const status = await initializeWithExplicitIDAsync(idArgument, projectDir, {
force,
nonInteractive,
});
idForConsistency = idArgument;
result = { projectId: idArgument, status };
} else {
idForConsistency = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
result = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
force,
nonInteractive,
accountName: accountArgument,
});
}
await ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
force,
nonInteractive,
});
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),
});
}
}
}
@@ -0,0 +1,108 @@
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);
}
}
@@ -0,0 +1,108 @@
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);
}
}
@@ -15,7 +15,7 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
jester
)
).rejects.toThrow(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
`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.`
);
});
});
@@ -8,7 +8,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import { link } from '../log';
import { ora } from '../ora';
import { confirmAsync } from '../prompts';
import { Actor } from '../user/User';
import { Actor, getCreatableAccountNames } from '../user/User';
/**
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
@@ -30,18 +30,22 @@ 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,13 +17,20 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { link } from '../log';
import { ora } from '../ora';
import { confirmAsync, promptAsync } from '../prompts';
import { Actor } from '../user/User';
import { Actor, getCreatableAccountNames } 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
@@ -85,7 +92,7 @@ export async function ensureOwnerSlugConsistencyAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
): Promise<{ owner: string; slug: string }> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
const correctOwner = appForProjectId.ownerAccount.name;
@@ -134,59 +141,60 @@ 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<void> {
): Promise<ProjectInitStatus> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (projectId === existingProjectId) {
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
return;
return 'already-linked';
}
if (!existingProjectId) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
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(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
if (force) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
}
export async function initializeWithExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
await setExplicitIDAsync(projectId, projectDir, {
): Promise<ProjectInitStatus> {
return await setExplicitIDAsync(projectId, projectDir, {
force,
nonInteractive,
});
@@ -196,39 +204,70 @@ export async function initializeWithoutExplicitIDAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<string> {
{
force,
nonInteractive,
accountName: accountNameArgument,
}: InitializeMethodOptions & { accountName?: string }
): Promise<ProjectInitResult> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (existingProjectId) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return existingProjectId;
const 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
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp =
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(actor);
// if no owner field, ask the user which account they want to use to create/link the project
let accountName = exp.owner;
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 (!accountName) {
if (allAccounts.length === 1) {
accountName = allAccounts[0].name;
} else if (nonInteractive) {
if (!force) {
throw new Error(
`There are multiple accounts that you have access to: ${allAccounts
.map(a => a.name)
.join(
', '
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
`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 ${
allAccounts[0].name
}.`
}.)`
);
}
accountName = allAccounts[0].name;
@@ -257,13 +296,18 @@ 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 (!force) {
if (!skipConfirmation) {
if (nonInteractive) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
@@ -281,7 +325,7 @@ export async function initializeWithoutExplicitIDAsync(
}
await saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
return existingProjectIdOnServer;
return { projectId: existingProjectIdOnServer, status: 'linked' };
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
@@ -290,7 +334,7 @@ export async function initializeWithoutExplicitIDAsync(
);
}
if (!force) {
if (!skipConfirmation) {
if (nonInteractive) {
throw new Error(
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
@@ -323,5 +367,5 @@ export async function initializeWithoutExplicitIDAsync(
}
await saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
return createdProjectId;
return { projectId: createdProjectId, status: 'created' };
}
@@ -0,0 +1,78 @@
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);
});
});
@@ -0,0 +1,93 @@
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.'
);
});
});
@@ -0,0 +1,189 @@
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();
});
});
@@ -0,0 +1,100 @@
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.');
});
});
@@ -0,0 +1,112 @@
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');
});
});
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,191 @@
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
@@ -0,0 +1,210 @@
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
@@ -0,0 +1,123 @@
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
@@ -0,0 +1,182 @@
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
@@ -0,0 +1,52 @@
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 };
}
+20 -1
View File
@@ -1,7 +1,26 @@
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
import { CurrentUserQuery, PartnerActor, Robot, Role, 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 } from '../User';
import { Actor, getActorDisplayName, getCreatableAccountNames } from '../User';
const userStub: Actor = {
__typename: 'User',
@@ -72,3 +72,37 @@ 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.1.0",
"version": "21.3.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.1.0",
"@expo/eas-build-job": "21.3.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.1.0",
"version": "21.3.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.1.0",
"@expo/eas-build-job": "21.1.0",
"@expo/build-tools": "21.3.0",
"@expo/eas-build-job": "21.3.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.1.0",
"version": "21.3.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.1.0",
"@expo/eas-build-job": "21.3.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.1.0",
"version": "21.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "21.1.0",
"@expo/build-tools": "21.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.1.0",
"@expo/eas-build-job": "21.3.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.1.0",
"@expo/steps": "21.3.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.1.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.3.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.1.0"
"@expo/eas-build-job": "npm:21.3.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.1.0"
"@expo/steps": "npm:21.3.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.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:21.3.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.1.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:21.3.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.1.0"
"@expo/eas-build-job": "npm:21.3.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.1.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:21.3.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.1.0"
"@expo/eas-build-job": "npm:21.3.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.1.0"
"@expo/build-tools": "npm:21.3.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.1.0"
"@expo/eas-build-job": "npm:21.3.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.1.0"
"@expo/steps": "npm:21.3.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.1.0"
"@expo/steps": "npm:21.3.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.1.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/build-tools": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.3.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.1.0"
"@expo/eas-json": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-json": "npm:21.3.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.1.0"
"@expo/steps": "npm:21.3.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"