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 }}
|
||||
|
||||
+3
-2
@@ -10,11 +10,12 @@ 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
|
||||
|
||||
- [eas-cli] Print an actionable error message that says to install the project's dependencies when they aren't installed and EAS CLI needs them to read the app config, instead of a confusing module resolution error. Reading the config with the copy of `@expo/config` bundled with EAS CLI is now reserved for old SDK versions whose `expo` package doesn't include Expo CLI; projects without the `expo` package get an error directing them to install it. ([#4080](https://github.com/expo/eas-cli/pull/4080) by [@ide](https://github.com/ide))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.2.0](https://github.com/expo/eas-cli/releases/tag/v21.2.0) - 2026-07-24
|
||||
|
||||
+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
-11
@@ -1,6 +1,5 @@
|
||||
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import resolveFrom from 'resolve-from';
|
||||
import { anything, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { Role } from '../../../../graphql/generated';
|
||||
@@ -15,11 +14,6 @@ import { getProjectIdAsync } from '../getProjectIdAsync';
|
||||
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('fs');
|
||||
jest.mock('resolve-from', () => {
|
||||
const resolveFrom: any = jest.fn();
|
||||
resolveFrom.silent = jest.fn();
|
||||
return resolveFrom;
|
||||
});
|
||||
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
@@ -42,10 +36,6 @@ describe(getProjectIdAsync, () => {
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the
|
||||
// config is read with the copy of `@expo/config` bundled with EAS CLI.
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue('/app/node_modules/expo/package.json');
|
||||
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
@@ -488,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(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import chalk from 'chalk';
|
||||
import { vol } from 'memfs';
|
||||
import resolveFrom from 'resolve-from';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
@@ -18,15 +17,11 @@ import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { isExpoInstalled } from '../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ProjectInit from '../init';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('resolve-from', () => {
|
||||
const resolveFrom: any = jest.fn();
|
||||
resolveFrom.silent = jest.fn();
|
||||
return resolveFrom;
|
||||
});
|
||||
jest.mock('../../../project/expoConfig', () => ({
|
||||
...jest.requireActual('../../../project/expoConfig'),
|
||||
createOrModifyExpoConfigAsync: jest.fn(),
|
||||
@@ -43,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[];
|
||||
|
||||
@@ -105,9 +101,6 @@ function mockTestProject(options: {
|
||||
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
|
||||
// This isn't a great solution, but the test is pretty involved
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(false);
|
||||
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the config
|
||||
// is read with the copy of `@expo/config` bundled with EAS CLI.
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue(projectRoot + '/node_modules/expo/package.json');
|
||||
}
|
||||
|
||||
const commandOptions = getMockOclifConfig({ root: '/test-project' });
|
||||
@@ -544,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,20 +1,12 @@
|
||||
import { getConfig, getConfigFilePaths, getPackageJson, modifyConfigAsync } from '@expo/config';
|
||||
import { getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { writeFileSync } from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../expoConfig';
|
||||
import { isExpoInstalled } from '../projectUtils';
|
||||
import { createOrModifyExpoConfigAsync } from '../expoConfig';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('@expo/json-file');
|
||||
jest.mock('resolve-from', () => {
|
||||
const resolveFrom: any = jest.fn();
|
||||
resolveFrom.silent = jest.fn();
|
||||
return resolveFrom;
|
||||
});
|
||||
jest.mock('../projectUtils');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
@@ -55,34 +47,4 @@ describe('expoConfig', () => {
|
||||
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', { owner: 'ccheever' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrivateExpoConfigAsync when Expo CLI is not resolvable', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(getConfigFilePaths).mockReturnValue({
|
||||
staticConfigPath: '/app/app.json',
|
||||
dynamicConfigPath: null,
|
||||
});
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('throws an actionable error when expo is declared in package.json but not installed', async () => {
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
|
||||
jest.mocked(getPackageJson).mockReturnValue({ dependencies: { expo: '~53.0.0' } } as any);
|
||||
|
||||
await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
|
||||
/dependencies to be installed/
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an actionable error when expo is not declared in package.json', async () => {
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
|
||||
jest.mocked(getPackageJson).mockReturnValue({ dependencies: {} } as any);
|
||||
|
||||
await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
|
||||
/The "expo" package was not found/
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+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.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,10 @@
|
||||
import {
|
||||
ExpoConfig,
|
||||
getConfig,
|
||||
getConfigFilePaths,
|
||||
getPackageJson,
|
||||
modifyConfigAsync,
|
||||
} from '@expo/config';
|
||||
import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { resolvePackageManager } from '@expo/package-manager';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import Joi from 'joi';
|
||||
import path from 'path';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
import { isExpoInstalled } from './projectUtils';
|
||||
import { link } from '../log';
|
||||
import { spawnExpoCommand } from '../utils/expoCli';
|
||||
|
||||
export type PublicExpoConfig = Omit<
|
||||
@@ -73,28 +63,12 @@ async function getExpoConfigInternalAsync(
|
||||
}
|
||||
);
|
||||
exp = JSON.parse(stdout);
|
||||
} else if (resolveFrom.silent(projectDir, 'expo/package.json')) {
|
||||
// The `expo` package is installed but Expo CLI is not part of it. This happens with old
|
||||
// SDK versions, which predate the local CLI. Since we can't run `expo config`, read the app
|
||||
// config with the copy of `@expo/config` that ships with EAS CLI.
|
||||
} else {
|
||||
exp = getConfig(projectDir, {
|
||||
skipSDKVersionRequirement: true,
|
||||
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
|
||||
...(opts.skipPlugins ? { skipPlugins: true } : {}),
|
||||
}).exp;
|
||||
} else if (getPackageJson(projectDir)?.dependencies?.expo) {
|
||||
const installCommand = `${resolvePackageManager(projectDir) ?? 'npm'} install`;
|
||||
throw new Error(
|
||||
`EAS CLI needs your project's dependencies to be installed to read your app config. Run ${chalk.bold(
|
||||
installCommand
|
||||
)} in your project directory and run this command again.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`The "expo" package was not found in your project's dependencies, needed to read your app config. Add "expo" to your dependencies and install it. Refer to the version compatibility table at: ${link(
|
||||
'https://docs.expo.dev/versions/latest/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = MinimalAppConfigSchema.validate(exp, {
|
||||
|
||||
+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