mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3784a50399 | ||
|
|
ddf386597c | ||
|
|
a2cf4c658d | ||
|
|
23d1ed3af5 | ||
|
|
417f0df466 | ||
|
|
591db3b142 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -10,6 +10,9 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--account` flag to `eas init` to choose the account that should own the project, enabling non-interactive project creation (`eas init --account <name> --non-interactive`), and improve unconfigured-project error messages to list the accounts you can create projects in. ([#4057](https://github.com/expo/eas-cli/pull/4057) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add `--json` flag to `eas init` to print the linked project (status, project ID, owner, slug, dashboard URL) as JSON to stdout, and add usage examples to `eas init --help`. ([#4079](https://github.com/expo/eas-cli/pull/4079) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `eas testflight:feedback` and `eas testflight:crashes` to read TestFlight tester feedback (comments, device info, screenshot URLs) and crash reports (including full crash logs) from App Store Connect. Both support `--offset`/`--limit` pagination, `--json`, and looking up a single submission by ID or by the App Store Connect URL that EAS workflow triggers expose as `${{ app_store_connect.beta_feedback.url }}`. ([#4084](https://github.com/expo/eas-cli/pull/4084) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.2.0",
|
||||
"version": "21.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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,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
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.`
|
||||
);
|
||||
});
|
||||
});
|
||||
+11
-7
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user