Compare commits

...
Author SHA1 Message Date
Expo CI 8fdf47aa7d v18.9.0 2026-04-30 00:08:37 +00:00
Douglas Lowder 2279b650d5 [eas-cli] Allow eas build:download to accept a build ID (#3655)
# Why

For certain workflows, it is useful to be able to download build artifacts for a known build ID.

# How

Added a new flag `--build-id` to the `eas build:download` command. When this flag is used,`--fingerprint`, `--dev-client`, and `--platform` are excluded.

Also added the `--all-artifacts`flag. When set, all available artifacts are downloaded. Otherwise, the previous behavior applies (only application archive URL is downloaded).

# Test Plan

- Tested in a real project
- New unit tests added
2026-04-29 16:43:28 -07:00
Gabe Debes c6a36e2e47 [steps] Fix job context interpolation when one string has multiple ${{ }} blocks (#3644) 2026-04-29 15:53:34 -07:00
Szymon Świerk 27cb8c12d6 [eas-cli] Add support for configuring app store connect integration (#3558)
* ASC app link GQL queries

* ASC app link GQL mutations

* ASC utils

* status command

* disconnect command

* select or create ASC API key util function

* connect command

* add oclif connections topic

* gql codegen

* changelog

* regenerate readme

* dedicated app store api key purpose

* prompt user to choose the api key role

* remove unncecessary defensiveness in getting links remoteAppStoreConnectApp

* handle GQL error when invalid connection

* format

* rename connections to integations
2026-04-29 15:14:40 +02:00
Szymon Dziedzic 1122a7f374 [eas-cli] prompt users to stop device run session when they are done by running eas simulator:stop command (#3633) 2026-04-28 20:24:20 +02:00
Stanisław Chmiela c06d18138c [worker] Set precompiled modules base URL (#3642)
## Summary

Sets `EXPO_PRECOMPILED_MODULES_BASE_URL` for iOS worker builds that opt into precompiled modules with `EAS_USE_PRECOMPILED_MODULES=1`.

When the CocoaPods cache is configured, the URL is routed through that cache using the same host-prefix style as other cached downloads. When the cache is not configured, it points directly to `https://storage.googleapis.com/eas-build-precompiled-modules/`.

Local builds are unchanged and do not receive this env var.

## Test Plan

- `yarn oxfmt packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
2026-04-28 19:33:42 +02:00
Szymon Dziedzic 3f4ff1dc3e [eas-cli] add eas simulator:stop command (#3626) 2026-04-27 14:11:13 +02:00
Mustafa Abban e852ab1cb7 [worker] - wrap error log fetch in try-catch to prevent unhandled rejections (#3631)
* [worker] - wrap error log fetch in try-catch to prevent unhandled rejections

* prevent real HTTP requests in integration tests
2026-04-24 12:28:11 -04:00
Mustafa Abban d941398ce6 [build-errors] - log all build errors with enriched metadata (#3612)
* enrich logs

* more metadata

* all error codes

* top level tags
2026-04-24 11:28:15 -04:00
Expo CI bae7684996 update CHANGELOG.md 2026-04-23 16:02:14 +00:00
Expo CI 714aec69c7 v18.8.1 2026-04-23 15:59:34 +00:00
Szymon Dziedzic bec6e670de [eas-cli] add eas simulator:start command (#3625) 2026-04-23 15:55:57 +00:00
47 changed files with 5309 additions and 158 deletions

No files matched your search

+7
View File
@@ -8,10 +8,17 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.8.0",
"version": "18.9.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.8.0",
"version": "18.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -45,7 +45,7 @@
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
@@ -38,6 +38,7 @@ import { runGradleFunction } from './functions/runGradle';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
@@ -77,6 +78,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -0,0 +1,267 @@
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sleepAsync } from '../../utils/retry';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger }, { inputs, env }) => {
const packageVersion = inputs.package_version.value as string | undefined;
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureCloudflaredInstalledAsync({ env, logger });
logger.info('Ensuring bun is installed.');
await ensureBunInstalledAsync({ env, logger });
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
async function ensureCloudflaredInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
}
async function ensureBunInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
});
}
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
pattern,
timeoutMs,
description,
}: {
logFile: string;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.8.0",
"version": "18.9.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.8.0"
"@expo/steps": "18.9.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+162 -94
View File
@@ -117,6 +117,9 @@ eas --help COMMAND
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas init`](#eas-init)
* [`eas init:onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-initonboarding-target_project_directory)
* [`eas integrations:asc:connect`](#eas-integrationsascconnect)
* [`eas integrations:asc:disconnect`](#eas-integrationsascdisconnect)
* [`eas integrations:asc:status`](#eas-integrationsascstatus)
* [`eas login`](#eas-login)
* [`eas logout`](#eas-logout)
* [`eas metadata:lint`](#eas-metadatalint)
@@ -180,7 +183,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -197,7 +200,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -219,7 +222,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -236,7 +239,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -250,7 +253,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -302,7 +305,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -323,7 +326,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -343,7 +346,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -363,7 +366,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -386,7 +389,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -424,7 +427,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -444,7 +447,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -462,7 +465,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -482,7 +485,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -504,28 +507,34 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
download simulator/emulator builds for a given fingerprint hash
download a simulator/emulator build by build ID or fingerprint hash
```
USAGE
$ eas build:download --fingerprint <value> [-p ios|android] [--dev-client] [--json] [--non-interactive]
$ eas build:download [--build-id <value> | --fingerprint <value> | -p ios|android | --dev-client]
[--all-artifacts] [--json] [--non-interactive]
FLAGS
-p, --platform=<option> <options: ios|android>
--all-artifacts Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in
addition to the application archive. Without this flag, only the application archive is
downloaded and the command errors if it is missing.
--build-id=<value> ID of the build to download. Mutually exclusive with --fingerprint, --platform, and
--dev-client; the platform is derived from the build itself.
--[no-]dev-client Filter only dev-client builds.
--fingerprint=<value> (required) Fingerprint hash of the build to download
--fingerprint=<value> Fingerprint hash of the build to download
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -563,7 +572,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -603,7 +612,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -633,7 +642,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -659,7 +668,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -711,7 +720,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -729,7 +738,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -747,7 +756,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -764,7 +773,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -785,7 +794,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -806,7 +815,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -828,7 +837,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -852,7 +861,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -872,7 +881,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -894,7 +903,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -916,7 +925,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -951,7 +960,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -974,7 +983,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -995,7 +1004,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1012,7 +1021,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1030,7 +1039,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1059,7 +1068,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1084,7 +1093,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1105,7 +1114,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1126,7 +1135,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1163,7 +1172,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1183,7 +1192,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1204,7 +1213,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1225,7 +1234,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1239,7 +1248,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1253,7 +1262,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1285,7 +1294,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1311,7 +1320,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1333,7 +1342,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1361,7 +1370,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1389,7 +1398,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1412,7 +1421,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1434,7 +1443,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1468,7 +1477,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1512,7 +1521,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1543,7 +1552,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1602,6 +1611,65 @@ ALIASES
$ eas onboarding
```
## `eas integrations:asc:connect`
connect a project to an App Store Connect app
```
USAGE
$ eas integrations:asc:connect [--api-key-id <value>] [--asc-app-id <value>] [--bundle-id <value>] [--json]
[--non-interactive]
FLAGS
--api-key-id=<value> Apple App Store Connect API Key ID
--asc-app-id=<value> App Store Connect app identifier
--bundle-id=<value> Filter discovered apps by bundle identifier
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
disconnect the current project from its App Store Connect app
```
USAGE
$ eas integrations:asc:disconnect [--yes] [--json] [--non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--yes Skip confirmation prompt
DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
show the App Store Connect app link status for the current project
```
USAGE
$ eas integrations:asc:status [--json] [--non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas login`
log in with your Expo account
@@ -1652,7 +1720,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1670,7 +1738,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1688,7 +1756,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1740,7 +1808,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1754,7 +1822,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1777,7 +1845,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1801,7 +1869,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1819,7 +1887,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1852,7 +1920,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1895,7 +1963,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1916,7 +1984,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1937,7 +2005,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1962,7 +2030,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -1989,7 +2057,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2016,7 +2084,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2054,7 +2122,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2082,7 +2150,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2112,7 +2180,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2133,7 +2201,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2157,7 +2225,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2178,7 +2246,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2200,7 +2268,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2220,7 +2288,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2239,7 +2307,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2262,7 +2330,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2279,7 +2347,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2400,7 +2468,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2420,7 +2488,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2444,7 +2512,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2484,7 +2552,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2506,7 +2574,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2534,7 +2602,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2554,7 +2622,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2576,5 +2644,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.8.0",
"version": "18.9.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.9.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -201,6 +201,9 @@
"env": {
"description": "manage project and account environment variables"
},
"integrations": {
"description": "manage service connections"
},
"metadata": {
"description": "manage store configuration"
},
+18
View File
@@ -64,6 +64,24 @@ export function getWorkflowRunUrl(
).toString();
}
/**
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
* or the feature-specific dashboard) that gives users more context. Use this only for internal
* tooling where no richer URL exists.
*/
export function getBareJobRunUrl(
accountName: string,
projectName: string,
jobRunId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/job-runs/${encodeURIComponent(jobRunId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
@@ -0,0 +1,197 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform, BuildFragment } from '../../../graphql/generated';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import Download from '../download';
jest.mock('../../../graphql/queries/BuildQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockByIdAsync = jest.mocked(BuildQuery.byIdAsync);
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-123',
platform: AppPlatform.Ios,
project: { id: 'project-1' },
artifacts: {
applicationArchiveUrl: 'https://example.com/app.tar.gz',
buildArtifactsUrl: null,
xcodeBuildLogsUrl: null,
buildUrl: null,
},
...overrides,
} as unknown as BuildFragment;
}
describe(Download, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): Download {
const command = new Download(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
// Stub the file-system-touching helpers so we don't hit disk.
jest
.spyOn(command as any, 'getPathToBuildArtifactAsync')
.mockResolvedValue('/cache/path/to/build.app');
jest.spyOn(command as any, 'downloadExtraArtifactsAsync').mockResolvedValue({});
return command;
}
it('errors when neither --build-id nor --fingerprint is provided', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
'Either --build-id or --fingerprint is required.'
);
});
it('fetches build by ID when --build-id is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand(['--build-id', 'build-123']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
});
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
const command = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
await command.runAsync();
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledTimes(1);
expect(mockViewBuildsOnAppAsync.mock.calls[0][1].filter?.fingerprintHash).toBe('fp-abc');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('uses platform from the build itself when --build-id is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild({ platform: AppPlatform.Android }));
const command = createCommand(['--build-id', 'build-123']);
await command.runAsync();
const getPathSpy = jest.spyOn(command as any, 'getPathToBuildArtifactAsync');
// The spy was set up after instantiation; just ensure runAsync used the build's platform
// by checking that no platform prompt was needed.
expect(getPathSpy).toBeDefined();
});
it('errors with a helpful message when applicationArchiveUrl is missing and other artifacts exist', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
xcodeBuildLogsUrl: 'https://example.com/xcode-logs.tar.gz',
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123']);
await expect(command.runAsync()).rejects.toThrow(
/Other artifacts are available \(buildArtifacts, xcodeBuildLogs\); re-run with --all-artifacts/
);
});
it('errors with the basic message when applicationArchiveUrl is missing and no other artifacts exist', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: null,
xcodeBuildLogsUrl: null,
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123']);
await expect(command.runAsync()).rejects.toThrow('Build does not have an application archive url');
});
it('does not throw when applicationArchiveUrl is missing but --all-artifacts is set', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
xcodeBuildLogsUrl: null,
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123', '--all-artifacts']);
const downloadExtrasSpy = jest
.spyOn(command as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/cache/build-123-artifacts/build-artifacts.tar.gz' });
await expect(command.runAsync()).resolves.not.toThrow();
expect(downloadExtrasSpy).toHaveBeenCalled();
});
it('downloads extras only when --all-artifacts is set, regardless of --build-id vs --fingerprint', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
const commandWithoutFlag = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
const downloadExtrasSpyWithout = jest
.spyOn(commandWithoutFlag as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({});
await commandWithoutFlag.runAsync();
expect(downloadExtrasSpyWithout).not.toHaveBeenCalled();
const commandWithFlag = createCommand([
'--fingerprint',
'fp-abc',
'--platform',
'ios',
'--all-artifacts',
]);
const downloadExtrasSpyWith = jest
.spyOn(commandWithFlag as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/path/to/extras' });
await commandWithFlag.runAsync();
expect(downloadExtrasSpyWith).toHaveBeenCalled();
});
it('emits JSON with path and extras when --json --all-artifacts are set', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand([
'--build-id',
'build-123',
'--all-artifacts',
'--json',
'--non-interactive',
]);
jest
.spyOn(command as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/cache/extras/build-artifacts.tar.gz' });
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
path: '/cache/path/to/build.app',
buildArtifacts: '/cache/extras/build-artifacts.tar.gz',
});
});
});
+164 -19
View File
@@ -1,7 +1,8 @@
import { Platform } from '@expo/eas-build-job';
import { Errors, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { pathExists } from 'fs-extra';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -15,24 +16,43 @@ import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getEasBuildRunCachedAppPath } from '../../run/run';
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
import {
downloadAndMaybeExtractAppAsync,
downloadFileWithProgressTrackerAsync,
} from '../../utils/download';
import { formatBytes } from '../../utils/files';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
export default class Download extends EasCommand {
static override description = 'download simulator/emulator builds for a given fingerprint hash';
static override description =
'download a simulator/emulator build by build ID or fingerprint hash';
static override flags = {
'build-id': Flags.string({
description:
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
exclusive: ['fingerprint', 'platform', 'dev-client'],
}),
fingerprint: Flags.string({
description: 'Fingerprint hash of the build to download',
required: true,
exclusive: ['build-id'],
}),
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
exclusive: ['build-id'],
})(),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
exclusive: ['build-id'],
}),
'all-artifacts': Flags.boolean({
description:
'Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in addition to the application archive. Without this flag, only the application archive is downloaded and the command errors if it is missing.',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -45,14 +65,20 @@ export default class Download extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: {
'build-id': buildId,
platform,
fingerprint,
'dev-client': developmentClient,
'all-artifacts': allArtifacts,
...rawFlags
},
} = await this.parse(Download);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
if (!buildId && !fingerprint) {
throw new Error('Either --build-id or --fingerprint is required.');
}
const {
loggedIn: { graphqlClient },
projectId,
@@ -64,24 +90,113 @@ export default class Download extends EasCommand {
enableJsonOutput();
}
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
const build = await this.getBuildAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint,
developmentClient,
});
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
let build: BuildFragment;
let selectedPlatform: AppPlatform;
if (buildId) {
build = await this.getBuildByIdAsync({ graphqlClient, buildId });
selectedPlatform = build.platform;
Log.succeed(`🎯 Found build ${chalk.bold(buildId)} on EAS servers.`);
} else {
selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
build = await this.getBuildByFingerprintAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint!,
developmentClient,
});
}
let buildArtifactPath: string | null = null;
if (build.artifacts?.applicationArchiveUrl) {
buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
} else if (!allArtifacts) {
const availableArtifacts = listAvailableExtraArtifactNames(build);
if (availableArtifacts.length > 0) {
throw new Error(
`Build does not have an application archive url. Other artifacts are available (${availableArtifacts.join(', ')}); re-run with --all-artifacts to download them.`
);
}
throw new Error('Build does not have an application archive url');
}
let extraArtifactPaths: Record<string, string> = {};
if (allArtifacts) {
extraArtifactPaths = await this.downloadExtraArtifactsAsync(build);
}
if (jsonFlag) {
const jsonResults = { path: buildArtifactPath };
const jsonResults = {
...(buildArtifactPath != null && { path: buildArtifactPath }),
...extraArtifactPaths,
};
printJsonOnlyOutput(jsonResults);
} else {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
if (buildArtifactPath != null) {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
}
for (const [name, filePath] of Object.entries(extraArtifactPaths)) {
Log.log(`${name} downloaded to ${chalk.bold(filePath)}`);
}
}
}
private async getBuildAsync({
private async downloadExtraArtifactsAsync(build: BuildFragment): Promise<Record<string, string>> {
const artifacts = build.artifacts;
if (!artifacts) {
return {};
}
const extraArtifacts: Array<{ name: string; url: string }> = [];
if (artifacts.buildArtifactsUrl) {
extraArtifacts.push({ name: 'buildArtifacts', url: artifacts.buildArtifactsUrl });
}
if (artifacts.xcodeBuildLogsUrl) {
extraArtifacts.push({ name: 'xcodeBuildLogs', url: artifacts.xcodeBuildLogsUrl });
}
if (artifacts.buildUrl && artifacts.buildUrl !== artifacts.applicationArchiveUrl) {
extraArtifacts.push({ name: 'build', url: artifacts.buildUrl });
}
if (extraArtifacts.length === 0) {
return {};
}
const outputDir = path.join(getEasBuildRunCacheDirectoryPath(), `${build.id}-artifacts`);
await fs.ensureDir(outputDir);
const downloaded: Record<string, string> = {};
for (const { name, url } of extraArtifacts) {
const fileName = getFileNameFromUrl(url, name);
const outputPath = path.join(outputDir, fileName);
await downloadFileWithProgressTrackerAsync(
url,
outputPath,
(ratio, total) =>
`Downloading ${name} (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
`Successfully downloaded ${name}`
);
downloaded[name] = outputPath;
}
return downloaded;
}
private async getBuildByIdAsync({
graphqlClient,
buildId,
}: {
graphqlClient: ExpoGraphqlClient;
buildId: string;
}): Promise<BuildFragment> {
try {
return await BuildQuery.byIdAsync(graphqlClient, buildId);
} catch (error: any) {
throw new Error(`Could not find build with ID ${buildId}: ${error.message}`);
}
}
private async getBuildByFingerprintAsync({
graphqlClient,
projectId,
platform,
@@ -108,7 +223,7 @@ export default class Download extends EasCommand {
limit: 1,
});
if (builds.length === 0) {
throw new Errors.CLIError(
throw new Error(
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
);
}
@@ -123,7 +238,7 @@ export default class Download extends EasCommand {
build.id,
platform
);
if (await pathExists(cachedBuildArtifactPath)) {
if (await fs.pathExists(cachedBuildArtifactPath)) {
Log.newLine();
Log.log(`Using cached build...`);
return cachedBuildArtifactPath;
@@ -167,3 +282,33 @@ async function resolvePlatformAsync({
});
return selectedPlatform;
}
function listAvailableExtraArtifactNames(build: BuildFragment): string[] {
const names: string[] = [];
if (build.artifacts?.buildArtifactsUrl) {
names.push('buildArtifacts');
}
if (build.artifacts?.xcodeBuildLogsUrl) {
names.push('xcodeBuildLogs');
}
if (
build.artifacts?.buildUrl &&
build.artifacts.buildUrl !== build.artifacts.applicationArchiveUrl
) {
names.push('build');
}
return names;
}
function getFileNameFromUrl(url: string, fallbackName: string): string {
try {
const pathname = new URL(url).pathname;
const basename = path.basename(pathname);
if (basename) {
return basename;
}
} catch {
// fall through to default
}
return fallbackName;
}
@@ -0,0 +1,371 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../../../commandUtils/errors';
import { selectOrCreateAscApiKeyIdAsync } from '../../../../integrations/asc/ascApiKey';
import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import IntegrationsAscConnect from '../connect';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
jest.mock('../../../../integrations/asc/ascApiKey');
jest.mock('../../../../log');
jest.mock('../../../../ora');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
const mockRemoteApps = [
{
ascAppIdentifier: '9876543210',
bundleIdentifier: 'com.test.newapp',
name: 'New App',
appStoreIconUrl: null,
},
];
describe(IntegrationsAscConnect, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const actor = { id: 'actor-id' } as any;
const analytics = {} as any;
const vcsClient = {} as any;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('fails when already connected', async () => {
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
const command = new IntegrationsAscConnect(
['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(EasCommandError);
});
it('connects successfully in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'key-id',
});
});
it('accepts Apple key identifier in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'eas-key-uuid',
});
});
it('requires --api-key-id in non-interactive mode', async () => {
const command = new IntegrationsAscConnect(
['--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('--api-key-id is required');
});
it('requires --asc-app-id in non-interactive mode', async () => {
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required');
});
it('fails when asc-app-id is not found among discovered apps', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps');
});
it('fails when passing EAS key id in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'No App Store Connect API key found with Apple key identifier'
);
});
it('fails when multiple keys match Apple key identifier', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid-1',
keyIdentifier: 'FAKEKEY000',
},
{
id: 'eas-key-uuid-2',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'Multiple App Store Connect API keys match Apple key identifier'
);
});
it('fails when no accessible apps are discovered', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'No accessible apps found on App Store Connect'
);
});
it('fails when app discovery throws', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest
.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync)
.mockRejectedValueOnce(new Error('discovery failed'));
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('discovery failed');
});
it('creates or selects key when no api-key-id is provided', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]);
jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id');
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
const command = new IntegrationsAscConnect(['--asc-app-id', '9876543210'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'generated-key-id',
});
});
});
@@ -0,0 +1,160 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import { toggleConfirmAsync } from '../../../../prompts';
import IntegrationsAscDisconnect from '../disconnect';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
jest.mock('../../../../log');
jest.mock('../../../../ora');
jest.mock('../../../../prompts');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe(IntegrationsAscDisconnect, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('disconnects successfully with --yes', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataConnected)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith(
graphqlClient,
'asc-app-link-id'
);
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith(
2,
graphqlClient,
testProjectId,
{
useCache: false,
}
);
});
it('no-op when already disconnected', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('prints json output when already disconnected in json mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscDisconnect(['--yes', '--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"action": "disconnect"')
);
});
it('handles invalid ASC API key gracefully', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
expect.stringContaining('revoked or is no longer valid')
);
});
it('cancels disconnection when confirmation is rejected', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false);
const command = new IntegrationsAscDisconnect([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await expect(command.runAsync()).rejects.toThrow('process.exit called');
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
processExitSpy.mockRestore();
});
});
@@ -0,0 +1,155 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import IntegrationsAscStatus from '../status';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe(IntegrationsAscStatus, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('displays connected status', async () => {
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('displays disconnected status', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('prints json output in json mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscStatus(['--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"action": "status"')
);
});
it('handles invalid ASC API key gracefully', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
expect.stringContaining('revoked or is no longer valid')
);
});
it('handles invalid ASC API key with json output', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscStatus(['--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"status": "invalid"')
);
});
it('throws when fetching status fails', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockRejectedValueOnce(new Error('status failed'));
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await expect(command.runAsync()).rejects.toThrow('status failed');
});
});
@@ -0,0 +1,197 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasCommandError } from '../../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { CredentialsContext } from '../../../credentials/context';
import { buildJsonOutput, formatAscAppLinkStatus } from '../../../integrations/asc/utils';
import { selectOrCreateAscApiKeyIdAsync } from '../../../integrations/asc/ascApiKey';
import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
type DiscoveredAscApps = Awaited<ReturnType<typeof AscAppLinkQuery.discoverAccessibleAppsAsync>>;
export default class IntegrationsAscConnect extends EasCommand {
static override description = 'connect a project to an App Store Connect app';
static override flags = {
'api-key-id': Flags.string({
description: 'Apple App Store Connect API Key ID',
}),
'asc-app-id': Flags.string({
description: 'App Store Connect app identifier',
}),
'bundle-id': Flags.string({
description: 'Filter discovered apps by bundle identifier',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscConnect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (nonInteractive) {
if (!flags['api-key-id']) {
throw new EasCommandError('--api-key-id is required in non-interactive mode.');
}
if (!flags['asc-app-id']) {
throw new EasCommandError('--asc-app-id is required in non-interactive mode.');
}
}
const {
projectId,
projectDir,
loggedIn: { actor, graphqlClient },
analytics,
vcsClient,
} = await this.getContextAsync(IntegrationsAscConnect, {
nonInteractive,
});
// Step 1: Check current status
const statusSpinner = ora('Checking current App Store Connect app link status').start();
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
statusSpinner.succeed('Checked current status');
if (metadata.appStoreConnectApp) {
throw new EasCommandError(
`Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas integrations:asc:disconnect')}.`
);
}
// Step 2: Get ASC API key
const keysSpinner = ora('Fetching App Store Connect API keys').start();
const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync(
graphqlClient,
metadata.ownerAccount.name
);
keysSpinner.succeed(`Found ${keys.length} API key(s)`);
let apiKeyId = flags['api-key-id'];
if (!apiKeyId) {
const credentialsContext = new CredentialsContext({
projectInfo: null,
nonInteractive,
projectDir,
user: actor,
graphqlClient,
analytics,
vcsClient,
});
apiKeyId = await selectOrCreateAscApiKeyIdAsync({
credentialsContext,
existingKeys: keys,
ownerAccount: metadata.ownerAccount,
});
} else {
const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId);
if (keysByAppleId.length > 1) {
throw new EasCommandError(
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
);
} else if (keysByAppleId.length === 1) {
apiKeyId = keysByAppleId[0].id;
} else {
throw new EasCommandError(
`No App Store Connect API key found with Apple key identifier "${apiKeyId}".`
);
}
}
if (!apiKeyId) {
throw new EasCommandError('No App Store Connect API key selected.');
}
// Step 3: Discover remote apps
const discoverSpinner = ora('Discovering App Store Connect apps').start();
let remoteApps: DiscoveredAscApps;
try {
remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync(
graphqlClient,
apiKeyId,
flags['bundle-id']
);
discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`);
} catch (err) {
discoverSpinner.fail('Failed to discover apps');
throw err;
}
if (remoteApps.length === 0) {
throw new EasCommandError(
'No accessible apps found on App Store Connect for the selected API key.' +
(flags['bundle-id']
? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".`
: '')
);
}
// Step 4: Select remote app
let selectedApp: DiscoveredAscApps[number];
if (flags['asc-app-id']) {
const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']);
if (!match) {
throw new EasCommandError(
`App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas integrations:asc:connect')} interactively to discover available apps.`
);
}
selectedApp = match;
} else {
selectedApp = await selectAsync<DiscoveredAscApps[number]>(
'Select an App Store Connect app:',
remoteApps.map(app => ({
title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`,
value: app,
}))
);
}
// Step 5: Create link
const createSpinner = ora('Connecting project to App Store Connect app').start();
try {
await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, {
appId: metadata.id,
ascAppIdentifier: selectedApp.ascAppIdentifier,
appStoreConnectApiKeyId: apiKeyId,
});
createSpinner.succeed('Connected project to App Store Connect app');
} catch (err) {
createSpinner.fail('Failed to connect project');
throw err;
}
// Step 6: Refetch and display
const refetchSpinner = ora('Verifying connection').start();
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
refetchSpinner.succeed('Verified connection');
if (json) {
printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(updatedMetadata));
}
}
}
@@ -0,0 +1,143 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../../../integrations/asc/utils';
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsAscDisconnect extends EasCommand {
static override description = 'disconnect the current project from its App Store Connect app';
static override flags = {
yes: Flags.boolean({
description: 'Skip confirmation prompt',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscDisconnect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsAscDisconnect, {
nonInteractive: nonInteractive || flags.yes,
});
// Step 1: Check current status
const metadata = await this.fetchCurrentStatusAsync(graphqlClient, projectId);
if (!metadata) {
if (json) {
printJsonOnlyOutput(buildInvalidJsonOutput('disconnect', projectId));
} else {
Log.addNewLineIfNone();
Log.warn(
'The App Store Connect API key linked to this project has been revoked or is no longer valid.\nThe connection cannot be resolved from the CLI. Please update the API key on the Expo dashboard.'
);
}
return;
}
if (!metadata.appStoreConnectApp) {
if (json) {
printJsonOnlyOutput(buildJsonOutput('disconnect', metadata));
} else {
Log.addNewLineIfNone();
Log.log(
`Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.`
);
}
return;
}
// Step 2: Confirm
if (!flags.yes && !nonInteractive) {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(metadata));
Log.newLine();
Log.warn(
'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.'
);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (!confirmed) {
Log.error('Canceled disconnection');
process.exit(1);
}
}
// Step 3: Delete
const deleteSpinner = ora('Disconnecting App Store Connect app').start();
try {
await AscAppLinkMutation.deleteAppStoreConnectAppAsync(
graphqlClient,
metadata.appStoreConnectApp.id
);
deleteSpinner.succeed('Disconnected App Store Connect app');
} catch (err) {
deleteSpinner.fail('Failed to disconnect App Store Connect app');
throw err;
}
// Step 4: Refetch and display
const refetchSpinner = ora('Verifying disconnection').start();
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, {
useCache: false,
});
refetchSpinner.succeed('Verified disconnection');
if (json) {
printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(updatedMetadata));
}
}
private async fetchCurrentStatusAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
const spinner = ora('Checking current App Store Connect app link status').start();
try {
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
spinner.succeed('Checked current status');
return metadata;
} catch (err) {
if (isAscAuthenticationError(err)) {
spinner.fail('App Store Connect connection is invalid');
return null;
}
spinner.fail('Failed to check current status');
throw err;
}
}
}
@@ -0,0 +1,84 @@
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../../../integrations/asc/utils';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsAscStatus extends EasCommand {
static override description =
'show the App Store Connect app link status for the current project';
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscStatus);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsAscStatus, {
nonInteractive,
});
const metadata = await this.fetchStatusAsync(graphqlClient, projectId);
if (!metadata) {
if (json) {
printJsonOnlyOutput(buildInvalidJsonOutput('status', projectId));
} else {
Log.addNewLineIfNone();
Log.warn(
'The App Store Connect API key linked to this project has been revoked or is no longer valid.'
);
}
return;
}
if (json) {
printJsonOnlyOutput(buildJsonOutput('status', metadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(metadata));
}
}
private async fetchStatusAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
const spinner = ora('Fetching App Store Connect app link status').start();
try {
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
spinner.succeed('Fetched App Store Connect app link status');
return metadata;
} catch (err) {
if (isAscAuthenticationError(err)) {
spinner.fail('App Store Connect connection is invalid');
return null;
}
spinner.fail('Failed to fetch App Store Connect app link status');
throw err;
}
}
}
@@ -0,0 +1,256 @@
import { Flags } from '@oclif/core';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
AppPlatform,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../graphql/generated';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { sleepAsync } from '../../utils/promise';
import nullthrows from 'nullthrows';
const POLL_INTERVAL_MS = 5_000; // 5 seconds
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
// so adding a new enum value in codegen fails the build until it is wired up here.
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
};
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
([type, value]) => [value, type]
)
) as Record<string, DeviceRunSessionType>;
type ReadinessResult = { ready: true; message: string } | { ready: false };
type ReadinessChecker = (logMessages: readonly string[]) => ReadinessResult;
export default class SimulatorStart extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get the credentials to connect to it with the CLI tool of your choice';
static override flags = {
platform: Flags.option({
description: 'Device platform',
options: ['android', 'ios'] as const,
required: true,
})(),
type: Flags.option({
description: 'Type of device run session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
})(),
'package-version': Flags.string({
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStart);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStart, {
nonInteractive: flags['non-interactive'],
});
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
const createSpinner = ora('🚀 Creating device run session').start();
let deviceRunSessionId: string;
try {
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
});
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
const jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
createSpinner.succeed(
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
);
} catch (err) {
createSpinner.fail('Failed to create device run session');
throw err;
}
const checkReadiness = getReadinessCheckerForType(flags.type);
const pollSpinner = ora(`⏳ Waiting for ${flags.type} daemon to start`).start();
const deadline = Date.now() + POLL_TIMEOUT_MS;
let result: ReadinessResult = { ready: false };
try {
while (Date.now() < deadline) {
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
if (
session.status === DeviceRunSessionStatus.Errored ||
session.status === DeviceRunSessionStatus.Stopped
) {
throw new Error(
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} daemon was ready.`
);
}
const jobRunStatus = session.turtleJobRun?.status;
if (
jobRunStatus === JobRunStatus.Errored ||
jobRunStatus === JobRunStatus.Canceled ||
jobRunStatus === JobRunStatus.Finished
) {
throw new Error(
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} daemon was ready.`
);
}
const logMessages = await fetchLogMessagesAsync(session.turtleJobRun?.logFileUrls ?? []);
result = checkReadiness(logMessages);
if (result.ready) {
pollSpinner.succeed(`🎉 ${flags.type} daemon is ready`);
break;
}
await sleepAsync(POLL_INTERVAL_MS);
}
} catch (err) {
pollSpinner.fail(`Failed while polling for ${flags.type} daemon logs`);
throw err;
}
if (!result.ready) {
pollSpinner.fail(`Timed out waiting for ${flags.type} daemon to start`);
throw new Error(
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} daemon to start.`
);
}
Log.newLine();
Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`);
Log.newLine();
Log.log(result.message);
Log.newLine();
Log.log(
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
);
}
}
function getReadinessCheckerForType(type: string): ReadinessChecker {
switch (type) {
case DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice]:
return checkAgentDeviceReadiness;
default:
throw new Error(`Unsupported device run session type: ${type}`);
}
}
const AGENT_DEVICE_BASE_URL_ENV_VAR = 'AGENT_DEVICE_DAEMON_BASE_URL';
const AGENT_DEVICE_AUTH_TOKEN_ENV_VAR = 'AGENT_DEVICE_DAEMON_AUTH_TOKEN';
function checkAgentDeviceReadiness(logMessages: readonly string[]): ReadinessResult {
let baseUrl: string | undefined;
let authToken: string | undefined;
for (const msg of logMessages) {
baseUrl = baseUrl ?? extractExportedEnvValue(msg, AGENT_DEVICE_BASE_URL_ENV_VAR);
authToken = authToken ?? extractExportedEnvValue(msg, AGENT_DEVICE_AUTH_TOKEN_ENV_VAR);
if (baseUrl && authToken) {
break;
}
}
if (baseUrl && authToken) {
return {
ready: true,
message: [
`export ${AGENT_DEVICE_BASE_URL_ENV_VAR}='${baseUrl}'`,
`export ${AGENT_DEVICE_AUTH_TOKEN_ENV_VAR}='${authToken}'`,
].join('\n'),
};
}
return { ready: false };
}
async function fetchLogMessagesAsync(logUrls: readonly string[]): Promise<string[]> {
const messages: string[] = [];
for (const url of logUrls) {
const text = await fetchLogTextAsync(url);
if (!text) {
continue;
}
for (const line of text.split('\n')) {
if (!line.trim()) {
continue;
}
messages.push(extractLogMessage(line));
}
}
return messages;
}
async function fetchLogTextAsync(url: string): Promise<string | undefined> {
try {
const response = await fetch(url);
if (!response.ok) {
return undefined;
}
return await response.text();
} catch {
return undefined;
}
}
function extractLogMessage(line: string): string {
// Turtle job run logs are JSONL (bunyan-shaped), e.g.
// {"msg":"export FOO=\"bar\"","time":"...","logId":"..."}
// Fall back to the raw line if it's not JSON or doesn't have a string msg.
const trimmed = line.trim();
if (!trimmed.startsWith('{')) {
return line;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed && typeof parsed === 'object' && 'msg' in parsed) {
const msg = (parsed as { msg: unknown }).msg;
if (typeof msg === 'string') {
return msg;
}
}
} catch {
// not JSON, fall through
}
return line;
}
function extractExportedEnvValue(text: string, varName: string): string | undefined {
// Matches: export NAME=value | export NAME="value" | export NAME='value'
const pattern = new RegExp(`export\\s+${escapeRegExp(varName)}=(?:"([^"]*)"|'([^']*)'|(\\S+))`);
const match = pattern.exec(text);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3];
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,46 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { ora } from '../../ora';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
static override flags = {
id: Flags.string({
description: 'Device run session ID',
required: true,
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStop);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStop, {
nonInteractive: flags['non-interactive'],
});
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
try {
const session = await DeviceRunSessionMutation.stopDeviceRunSessionAsync(
graphqlClient,
flags.id
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
throw err;
}
}
}
@@ -2,11 +2,12 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import path from 'path';
import { UserRole } from '@expo/apple-utils';
import { formatAppleTeam } from './AppleTeamFormatting';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
import { CredentialsContext } from '../../context';
import {
@@ -24,6 +25,7 @@ import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey
export enum AppStoreApiKeyPurpose {
SUBMISSION_SERVICE = 'EAS Submit',
ASC_APP_CONNECTION = 'EAS Connect',
}
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
@@ -99,12 +101,27 @@ async function generateAscApiKeyAsync(
purpose: AppStoreApiKeyPurpose
): Promise<MinimalAscApiKey> {
await ctx.appStore.ensureAuthenticatedAsync();
const role = await selectRoleForGeneratedAscApiKeyAsync();
const ascApiKey = await ctx.appStore.createAscApiKeyAsync(ctx.analytics, {
nickname: getAscApiKeyName(purpose),
roles: [role],
});
return await getMinimalAscApiKeyAsync(ascApiKey);
}
async function selectRoleForGeneratedAscApiKeyAsync(): Promise<UserRole> {
return await selectAsync<UserRole>('Select role for the generated API key:', [
{
title: 'ADMIN (default)',
value: UserRole.ADMIN,
},
{
title: 'APP_MANAGER (least privilege for app management)',
value: UserRole.APP_MANAGER,
},
]);
}
export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
const nameParts = ['[Expo]', purpose, nanoid(10)];
return nameParts.join(' ');
@@ -1,16 +1,24 @@
import { promptAsync } from '../../../../prompts';
import { UserRole } from '@expo/apple-utils';
import fs from 'fs-extra';
import { promptAsync, selectAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { testAscApiKey } from '../../../__tests__/fixtures-ios';
import { getCredentialsFromUserAsync } from '../../../utils/promptForCredentials';
import {
getCredentialsFromUserAsync,
shouldAutoGenerateCredentialsAsync,
} from '../../../utils/promptForCredentials';
import {
AppStoreApiKeyPurpose,
getAscApiKeyName,
promptForAscApiKeyPathAsync,
provideOrGenerateAscApiKeyAsync,
} from '../AscApiKeyUtils';
jest.mock('../../../../prompts');
jest.mock('../../../utils/promptForCredentials');
jest.mock('fs-extra');
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
return Object.keys(obj) as K[];
@@ -18,7 +26,10 @@ function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
afterEach(() => {
jest.mocked(promptAsync).mockClear();
jest.mocked(selectAsync).mockClear();
jest.mocked(getCredentialsFromUserAsync).mockClear();
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
jest.mocked(fs.readFile).mockClear();
});
describe(getAscApiKeyName, () => {
@@ -87,3 +98,84 @@ describe(promptForAscApiKeyPathAsync, () => {
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
});
});
describe(provideOrGenerateAscApiKeyAsync, () => {
it('creates a key with ADMIN role by default', async () => {
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
jest.mocked(selectAsync).mockResolvedValue(UserRole.ADMIN);
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
createAscApiKeyAsync,
},
});
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
nickname: expect.any(String),
roles: [UserRole.ADMIN],
});
});
it('allows creating a key with APP_MANAGER role', async () => {
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
jest.mocked(selectAsync).mockResolvedValue(UserRole.APP_MANAGER);
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
createAscApiKeyAsync,
},
});
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
nickname: expect.any(String),
roles: [UserRole.APP_MANAGER],
});
});
it('does not apply role override when using a user-provided key', async () => {
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(false);
jest.mocked(promptAsync).mockResolvedValue({ keyP8Path: '/asc-api-key.p8' });
jest.mocked(fs.readFile).mockImplementation(async () => 'test-key-p8' as any);
jest
.mocked(getCredentialsFromUserAsync)
.mockResolvedValueOnce({ keyId: 'test-key-id' })
.mockResolvedValueOnce({ issuerId: 'test-issuer-id' });
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: null,
createAscApiKeyAsync,
},
});
const result = await provideOrGenerateAscApiKeyAsync(
ctx,
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
);
expect(result).toEqual({
keyP8: expect.any(String),
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
});
expect(selectAsync).not.toHaveBeenCalled();
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { ProfileType } from '@expo/app-store';
import { UserRole } from '@expo/apple-utils';
import {
AscApiKey,
@@ -195,10 +196,10 @@ export default class AppStoreApi {
public async createAscApiKeyAsync(
analytics: Analytics,
{ nickname }: { nickname: string }
{ nickname, roles }: { nickname: string; roles?: UserRole[] }
): Promise<AscApiKey> {
const userCtx = await this.ensureUserAuthenticatedAsync();
return await createAscApiKeyAsync(analytics, userCtx, { nickname });
return await createAscApiKeyAsync(analytics, userCtx, { nickname, roles });
}
public async revokeAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo> {
@@ -90,6 +90,23 @@ test(`createAscApiKeyAsync`, async () => {
expect(result).toEqual({ ...mockAscApiKeyInfo, keyP8: 'super secret' });
});
test(`createAscApiKeyAsync forwards explicit roles`, async () => {
const analytics = instance(mock<Analytics>());
const mockRequestContext = {};
jest.spyOn(ApiKey, 'createAsync').mockImplementation(async () => mockApiKey);
jest.mocked(getRequestContext).mockImplementation(() => mockRequestContext);
await createAscApiKeyAsync(analytics, mockAuthCtx, {
nickname: 'test-name',
roles: [UserRole.APP_MANAGER],
});
expect(ApiKey.createAsync).toHaveBeenLastCalledWith(mockRequestContext, {
nickname: 'test-name',
allAppsVisible: true,
roles: [UserRole.APP_MANAGER],
keyType: ApiKeyType.PUBLIC_API,
});
});
test(`revokeAscApiKeyAsync`, async () => {
const mockRequestContext = {};
jest.spyOn(ApiKey, 'infoAsync').mockImplementation(async () => mockApiKey);
+286
View File
@@ -1399,6 +1399,7 @@ export type App = Project & {
*/
buildsReleaseChannels: Array<Scalars['String']['output']>;
channelsPaginated: AppChannelsConnection;
convexProject?: Maybe<ConvexProject>;
deployment?: Maybe<Deployment>;
/** Deployments associated with this app */
deployments: DeploymentsConnection;
@@ -2086,6 +2087,9 @@ export type AppNotificationPreferenceInput = {
export type AppObserve = {
__typename?: 'AppObserve';
appVersions: Array<AppObserveAppVersion>;
customEventCounts: AppObserveCustomEventCounts;
customEventList: AppObserveCustomEventListConnection;
customEventNames: AppObserveCustomEventNames;
environments: Array<Scalars['String']['output']>;
events: AppObserveEventsConnection;
timeSeries: AppObserveTimeSeries;
@@ -2098,6 +2102,28 @@ export type AppObserveAppVersionsArgs = {
};
export type AppObserveCustomEventCountsArgs = {
input: AppObserveCustomEventCountsInput;
};
export type AppObserveCustomEventListArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AppObserveCustomEventListFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
export type AppObserveCustomEventNamesArgs = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveEnvironmentsArgs = {
endTime?: InputMaybe<Scalars['DateTime']['input']>;
platform?: InputMaybe<AppObservePlatform>;
@@ -2163,6 +2189,96 @@ export type AppObserveAppVersionMetric = {
statistics: AppObserveVersionMarkerStatistics;
};
export type AppObserveCustomEvent = {
__typename?: 'AppObserveCustomEvent';
appBuildNumber: Scalars['String']['output'];
appEasBuildId?: Maybe<Scalars['String']['output']>;
appUpdateId?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
countryCode?: Maybe<Scalars['String']['output']>;
deviceModel: Scalars['String']['output'];
deviceOs: Scalars['String']['output'];
deviceOsVersion: Scalars['String']['output'];
easClientId: Scalars['String']['output'];
environment?: Maybe<Scalars['String']['output']>;
eventName: Scalars['String']['output'];
id: Scalars['ID']['output'];
properties: Array<AppObserveEventProperty>;
sessionId?: Maybe<Scalars['String']['output']>;
severityNumber?: Maybe<Scalars['Int']['output']>;
severityText?: Maybe<Scalars['String']['output']>;
timestamp: Scalars['DateTime']['output'];
};
export type AppObserveCustomEventCountBucket = {
__typename?: 'AppObserveCustomEventCountBucket';
bucket: Scalars['DateTime']['output'];
count: Scalars['Int']['output'];
};
export type AppObserveCustomEventCounts = {
__typename?: 'AppObserveCustomEventCounts';
buckets: Array<AppObserveCustomEventCountBucket>;
totalCount: Scalars['Int']['output'];
};
export type AppObserveCustomEventCountsInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
eventName: Scalars['String']['input'];
platform: AppObservePlatform;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveCustomEventEdge = {
__typename?: 'AppObserveCustomEventEdge';
cursor: Scalars['String']['output'];
node: AppObserveCustomEvent;
};
export type AppObserveCustomEventListConnection = {
__typename?: 'AppObserveCustomEventListConnection';
edges: Array<AppObserveCustomEventEdge>;
pageInfo: PageInfo;
};
export type AppObserveCustomEventListFilter = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime?: InputMaybe<Scalars['DateTime']['input']>;
environment?: InputMaybe<Scalars['String']['input']>;
eventName?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime?: InputMaybe<Scalars['DateTime']['input']>;
};
export type AppObserveCustomEventName = {
__typename?: 'AppObserveCustomEventName';
count: Scalars['Int']['output'];
eventName: Scalars['String']['output'];
};
export type AppObserveCustomEventNames = {
__typename?: 'AppObserveCustomEventNames';
isTruncated: Scalars['Boolean']['output'];
names: Array<AppObserveCustomEventName>;
};
export type AppObserveCustomEventPropertyFilter = {
key: Scalars['String']['input'];
value: Scalars['String']['input'];
};
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
@@ -2202,6 +2318,13 @@ export type AppObserveEventEdge = {
node: AppObserveEvent;
};
export type AppObserveEventProperty = {
__typename?: 'AppObserveEventProperty';
key: Scalars['String']['output'];
type: AppObservePropertyType;
value: Scalars['String']['output'];
};
export type AppObserveEventsConnection = {
__typename?: 'AppObserveEventsConnection';
edges: Array<AppObserveEventEdge>;
@@ -2242,6 +2365,13 @@ export enum AppObservePlatform {
Ios = 'IOS'
}
export enum AppObservePropertyType {
Boolean = 'BOOLEAN',
Json = 'JSON',
Number = 'NUMBER',
String = 'STRING'
}
export type AppObserveReleasesInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
@@ -2850,6 +2980,23 @@ export type AppleDeviceRegistrationRequestMutationCreateAppleDeviceRegistrationR
appleTeamId: Scalars['ID']['input'];
};
/** Publicly visible data for an AppleDeviceRegistrationRequest. */
export type AppleDeviceRegistrationRequestPublicData = {
__typename?: 'AppleDeviceRegistrationRequestPublicData';
id: Scalars['ID']['output'];
};
export type AppleDeviceRegistrationRequestPublicDataQuery = {
__typename?: 'AppleDeviceRegistrationRequestPublicDataQuery';
/** Get AppleDeviceRegistrationRequest public data by ID */
byId?: Maybe<AppleDeviceRegistrationRequestPublicData>;
};
export type AppleDeviceRegistrationRequestPublicDataQueryByIdArgs = {
id: Scalars['ID']['input'];
};
export type AppleDeviceRegistrationRequestQuery = {
__typename?: 'AppleDeviceRegistrationRequestQuery';
byId: AppleDeviceRegistrationRequest;
@@ -4072,6 +4219,18 @@ export type ConvexIntegrationQuery = {
clientIdentifier: Scalars['String']['output'];
};
export type ConvexProject = {
__typename?: 'ConvexProject';
app: App;
convexProjectIdentifier: Scalars['String']['output'];
convexProjectName: Scalars['String']['output'];
convexProjectSlug: Scalars['String']['output'];
convexTeamConnection: ConvexTeamConnection;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type ConvexTeamConnection = {
__typename?: 'ConvexTeamConnection';
account: Account;
@@ -4173,6 +4332,17 @@ export type CreateConvexTeamConnectionInput = {
deploymentRegion: Scalars['String']['input'];
};
export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
* The version of the package backing the device run session (e.g. "0.1.3-alpha.3").
* If omitted, consumers treat the session as pinned to "latest".
*/
packageVersion?: InputMaybe<Scalars['String']['input']>;
platform: AppPlatform;
type: DeviceRunSessionType;
};
export type CreateEchoChatInput = {
agentMetadata?: InputMaybe<Scalars['JSONObject']['input']>;
agentType?: InputMaybe<EchoAgentType>;
@@ -4770,6 +4940,65 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
deploymentIdentifier: Scalars['ID']['input'];
};
export type DeviceRunSession = {
__typename?: 'DeviceRunSession';
app: App;
createdAt: Scalars['DateTime']['output'];
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
initiatingActor?: Maybe<Actor>;
/**
* The version of the package backing the device run session. Null means the session is
* pinned to "latest" at the consumer side.
*/
packageVersion?: Maybe<Scalars['String']['output']>;
platform: AppPlatform;
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: DeviceRunSessionStatus;
turtleJobRun?: Maybe<JobRun>;
type: DeviceRunSessionType;
updatedAt: Scalars['DateTime']['output'];
};
export type DeviceRunSessionMutation = {
__typename?: 'DeviceRunSessionMutation';
/** Create a device run session */
createDeviceRunSession: DeviceRunSession;
/** Stop a device run session */
stopDeviceRunSession: DeviceRunSession;
};
export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
deviceRunSessionInput: CreateDeviceRunSessionInput;
};
export type DeviceRunSessionMutationStopDeviceRunSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionQuery = {
__typename?: 'DeviceRunSessionQuery';
byId: DeviceRunSession;
};
export type DeviceRunSessionQueryByIdArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export enum DeviceRunSessionStatus {
Errored = 'ERRORED',
InProgress = 'IN_PROGRESS',
New = 'NEW',
Stopped = 'STOPPED'
}
export enum DeviceRunSessionType {
AgentDevice = 'AGENT_DEVICE'
}
export type DiscordUser = {
__typename?: 'DiscordUser';
discordIdentifier: Scalars['String']['output'];
@@ -5523,6 +5752,8 @@ export enum EntityTypeName {
BillingContractEntity = 'BillingContractEntity',
BranchEntity = 'BranchEntity',
ChannelEntity = 'ChannelEntity',
ConvexProjectEntity = 'ConvexProjectEntity',
ConvexTeamConnectionEntity = 'ConvexTeamConnectionEntity',
CustomerEntity = 'CustomerEntity',
EchoProjectEntity = 'EchoProjectEntity',
EchoVersionEntity = 'EchoVersionEntity',
@@ -7610,6 +7841,8 @@ export type RootMutation = {
deployments: DeploymentsMutation;
/** Mutations that assign or modify DevDomainNames for apps */
devDomainName: AppDevDomainNameMutation;
/** Mutations that create and stop device run sessions */
deviceRunSession: DeviceRunSessionMutation;
/** Mutations for Discord users */
discordUser: DiscordUserMutation;
/** Mutations for Echo chats */
@@ -7745,6 +7978,8 @@ export type RootQuery = {
appStoreConnectApiKey: AppStoreConnectApiKeyQuery;
/** Top-level query object for querying Apple Device registration requests. */
appleDeviceRegistrationRequest: AppleDeviceRegistrationRequestQuery;
/** Top-level query object for querying AppleDeviceRegistrationRequest publicly. */
appleDeviceRegistrationRequestPublicData: AppleDeviceRegistrationRequestPublicDataQuery;
/** Top-level query object for querying Apple distribution certificates. */
appleDistributionCertificate?: Maybe<AppleDistributionCertificateQuery>;
/** Top-level query object for querying Apple provisioning profiles. */
@@ -7768,6 +8003,7 @@ export type RootQuery = {
convexIntegration: ConvexIntegrationQuery;
/** Top-level query object for querying Deployments. */
deployments: DeploymentQuery;
deviceRunSessions: DeviceRunSessionQuery;
/** Top-level query object for querying Echo chats. */
echoChat: EchoChatQuery;
/** Top-level query object for querying Echo messages. */
@@ -11618,6 +11854,20 @@ export type CreateAppVersionMutationVariables = Exact<{
export type CreateAppVersionMutation = { __typename?: 'RootMutation', appVersion: { __typename?: 'AppVersionMutation', createAppVersion: { __typename?: 'AppVersion', id: string } } };
export type CreateAppStoreConnectAppMutationVariables = Exact<{
appStoreConnectAppInput: AppStoreConnectAppInput;
}>;
export type CreateAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', createAppStoreConnectApp: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string } } };
export type DeleteAppStoreConnectAppMutationVariables = Exact<{
appStoreConnectAppId: Scalars['ID']['input'];
}>;
export type DeleteAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', deleteAppStoreConnectApp: { __typename?: 'DeleteAppStoreConnectAppResult', id: string } } };
export type CreateAndroidBuildMutationVariables = Exact<{
appId: Scalars['ID']['input'];
job: AndroidJobInput;
@@ -11654,6 +11904,20 @@ export type RetryIosBuildMutationVariables = Exact<{
export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type CreateDeviceRunSessionMutationVariables = Exact<{
deviceRunSessionInput: CreateDeviceRunSessionInput;
}>;
export type CreateDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', createDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string } | null } } };
export type StopDeviceRunSessionMutationVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
export type StopDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', stopDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus } } };
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String']['input'];
@@ -11963,6 +12227,21 @@ export type LatestAppVersionQueryVariables = Exact<{
export type LatestAppVersionQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, latestAppVersionByPlatformAndApplicationIdentifier?: { __typename?: 'AppVersion', id: string, storeVersion: string, buildVersion: string } | null } } };
export type AscAppLinkAppMetadataQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type AscAppLinkAppMetadataQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, appStoreConnectApp?: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string, remoteAppStoreConnectApp: { __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null } } | null } } };
export type DiscoverAccessibleAppStoreConnectAppsQueryVariables = Exact<{
appStoreConnectApiKeyId: Scalars['ID']['input'];
bundleIdentifier?: InputMaybe<Scalars['String']['input']>;
}>;
export type DiscoverAccessibleAppStoreConnectAppsQuery = { __typename?: 'RootQuery', appStoreConnectApiKey: { __typename?: 'AppStoreConnectApiKeyQuery', byId: { __typename?: 'AppStoreConnectApiKey', id: string, remoteAppStoreConnectApps: Array<{ __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null }> } } };
export type GetAssetSignedUrlsQueryVariables = Exact<{
updateId: Scalars['ID']['input'];
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
@@ -12106,6 +12385,13 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type DeviceRunSessionByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, logFileUrls: Array<string> } | null } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -0,0 +1,70 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
export const AscAppLinkMutation = {
async createAppStoreConnectAppAsync(
graphqlClient: ExpoGraphqlClient,
appStoreConnectAppInput: {
appId: string;
ascAppIdentifier: string;
appStoreConnectApiKeyId: string;
}
): Promise<{ id: string; ascAppIdentifier: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<{
appStoreConnectApp: {
createAppStoreConnectApp: {
id: string;
ascAppIdentifier: string;
};
};
}>(
gql`
mutation CreateAppStoreConnectApp($appStoreConnectAppInput: AppStoreConnectAppInput!) {
appStoreConnectApp {
createAppStoreConnectApp(appStoreConnectAppInput: $appStoreConnectAppInput) {
id
ascAppIdentifier
}
}
}
`,
{ appStoreConnectAppInput }
)
.toPromise()
);
return data.appStoreConnectApp.createAppStoreConnectApp;
},
async deleteAppStoreConnectAppAsync(
graphqlClient: ExpoGraphqlClient,
appStoreConnectAppId: string
): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<{
appStoreConnectApp: {
deleteAppStoreConnectApp: {
id: string;
};
};
}>(
gql`
mutation DeleteAppStoreConnectApp($appStoreConnectAppId: ID!) {
appStoreConnectApp {
deleteAppStoreConnectApp(appStoreConnectAppId: $appStoreConnectAppId) {
id
}
}
}
`,
{ appStoreConnectAppId }
)
.toPromise()
);
},
};
@@ -0,0 +1,73 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
CreateDeviceRunSessionInput,
CreateDeviceRunSessionMutation,
CreateDeviceRunSessionMutationVariables,
StopDeviceRunSessionMutation,
StopDeviceRunSessionMutationVariables,
} from '../generated';
export const DeviceRunSessionMutation = {
async createDeviceRunSessionAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionInput: CreateDeviceRunSessionInput
): Promise<CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession']> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateDeviceRunSessionMutation, CreateDeviceRunSessionMutationVariables>(
gql`
mutation CreateDeviceRunSessionMutation($deviceRunSessionInput: CreateDeviceRunSessionInput!) {
deviceRunSession {
createDeviceRunSession(deviceRunSessionInput: $deviceRunSessionInput) {
id
status
app {
id
slug
ownerAccount {
id
name
}
}
turtleJobRun {
id
}
}
}
}
`,
{ deviceRunSessionInput },
{ noRetry: true }
)
.toPromise()
);
return data.deviceRunSession.createDeviceRunSession;
},
async stopDeviceRunSessionAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
): Promise<StopDeviceRunSessionMutation['deviceRunSession']['stopDeviceRunSession']> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<StopDeviceRunSessionMutation, StopDeviceRunSessionMutationVariables>(
gql`
mutation StopDeviceRunSessionMutation($deviceRunSessionId: ID!) {
deviceRunSession {
stopDeviceRunSession(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`,
{ deviceRunSessionId },
{ noRetry: true }
)
.toPromise()
);
return data.deviceRunSession.stopDeviceRunSession;
},
};
@@ -0,0 +1,102 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AscAppLinkAppMetadataQuery,
AscAppLinkAppMetadataQueryVariables,
DiscoverAccessibleAppStoreConnectAppsQuery,
DiscoverAccessibleAppStoreConnectAppsQueryVariables,
} from '../generated';
import { AccountFragmentNode } from '../types/Account';
export const AscAppLinkQuery = {
async getAppMetadataAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options?: {
useCache?: boolean;
}
): Promise<AscAppLinkAppMetadataQuery['app']['byId']> {
const useCache = options?.useCache ?? true;
const data = await withErrorHandlingAsync(
graphqlClient
.query<AscAppLinkAppMetadataQuery, AscAppLinkAppMetadataQueryVariables>(
gql`
query AscAppLinkAppMetadata($appId: String!) {
app {
byId(appId: $appId) {
id
fullName
ownerAccount {
id
...AccountFragment
}
appStoreConnectApp {
id
ascAppIdentifier
remoteAppStoreConnectApp {
ascAppIdentifier
bundleIdentifier
name
appStoreIconUrl
}
}
}
}
}
${print(AccountFragmentNode)}
`,
{ appId },
{
requestPolicy: useCache ? 'cache-first' : 'network-only',
additionalTypenames: ['App', 'AppStoreConnectApp'],
}
)
.toPromise()
);
return data.app.byId;
},
async discoverAccessibleAppsAsync(
graphqlClient: ExpoGraphqlClient,
appStoreConnectApiKeyId: string,
bundleIdentifier?: string
): Promise<
DiscoverAccessibleAppStoreConnectAppsQuery['appStoreConnectApiKey']['byId']['remoteAppStoreConnectApps']
> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<
DiscoverAccessibleAppStoreConnectAppsQuery,
DiscoverAccessibleAppStoreConnectAppsQueryVariables
>(
gql`
query DiscoverAccessibleAppStoreConnectApps(
$appStoreConnectApiKeyId: ID!
$bundleIdentifier: String
) {
appStoreConnectApiKey {
byId(id: $appStoreConnectApiKeyId) {
id
remoteAppStoreConnectApps(bundleIdentifier: $bundleIdentifier) {
ascAppIdentifier
bundleIdentifier
name
appStoreIconUrl
}
}
}
}
`,
{ appStoreConnectApiKeyId, bundleIdentifier },
{ additionalTypenames: ['AppStoreConnectApp'] }
)
.toPromise()
);
return data.appStoreConnectApiKey.byId?.remoteAppStoreConnectApps ?? [];
},
};
@@ -0,0 +1,37 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
export const DeviceRunSessionQuery = {
async byIdAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
): Promise<DeviceRunSessionByIdQuery['deviceRunSessions']['byId']> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables>(
gql`
query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
status
turtleJobRun {
id
status
logFileUrls
}
}
}
}
`,
{ deviceRunSessionId },
{ requestPolicy: 'network-only' }
)
.toPromise()
);
return data.deviceRunSessions.byId;
},
};
@@ -0,0 +1,99 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../utils';
const mockMetadataConnected = {
id: 'app-id',
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: 'app-id',
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe('buildJsonOutput', () => {
it('returns connected output', () => {
const output = buildJsonOutput('status', mockMetadataConnected);
expect(output.action).toBe('status');
expect(output.project).toBe('@testuser/testapp');
expect(output.status).toBe('connected');
expect(output.appStoreConnectApp).not.toBeNull();
expect(output.appStoreConnectApp!.ascAppIdentifier).toBe('1234567890');
expect(output.appStoreConnectApp!.name).toBe('Test App');
expect(output.appStoreConnectApp!.bundleIdentifier).toBe('com.test.app');
});
it('returns disconnected output', () => {
const output = buildJsonOutput('status', mockMetadataDisconnected);
expect(output.status).toBe('not-connected');
expect(output.appStoreConnectApp).toBeNull();
});
});
describe('buildInvalidJsonOutput', () => {
it('returns invalid output', () => {
const output = buildInvalidJsonOutput('status', 'project-id');
expect(output.status).toBe('invalid');
expect(output.project).toBe('project-id');
expect(output.appStoreConnectApp).toBeNull();
});
});
describe('isAscAuthenticationError', () => {
it('returns true for ASC authentication errors', () => {
const error = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
expect(isAscAuthenticationError(error)).toBe(true);
});
it('returns false for other CombinedErrors', () => {
const error = new CombinedError({
graphQLErrors: [new GraphQLError('Some other error')],
});
expect(isAscAuthenticationError(error)).toBe(false);
});
it('returns false for non-CombinedError errors', () => {
expect(isAscAuthenticationError(new Error('random error'))).toBe(false);
});
});
describe('formatAscAppLinkStatus', () => {
it('formats connected status', () => {
const status = formatAscAppLinkStatus(mockMetadataConnected);
expect(status).toContain('Connected');
expect(status).toContain('1234567890');
expect(status).toContain('Test App');
expect(status).toContain('com.test.app');
});
it('formats disconnected status', () => {
const status = formatAscAppLinkStatus(mockMetadataDisconnected);
expect(status).toContain('Not connected');
});
});
@@ -0,0 +1,50 @@
import { selectAsync } from '../../prompts';
import { CredentialsContext } from '../../credentials/context';
import {
AppStoreApiKeyPurpose,
formatAscApiKey,
provideOrGenerateAscApiKeyAsync,
sortAscApiKeysByUpdatedAtDesc,
} from '../../credentials/ios/actions/AscApiKeyUtils';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../graphql/generated';
export async function selectOrCreateAscApiKeyIdAsync({
credentialsContext,
existingKeys,
ownerAccount,
}: {
credentialsContext: CredentialsContext;
existingKeys: AppStoreConnectApiKeyFragment[];
ownerAccount: AccountFragment;
}): Promise<string> {
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys);
const createKeyOption = {
title: '[Create or upload a new API key]',
value: '__create_new_key__',
};
const selectedValue =
sortedKeys.length === 0
? createKeyOption.value
: await selectAsync<string>('Select an App Store Connect API key:', [
...sortedKeys.map(key => ({
title: formatAscApiKey(key),
value: key.id,
})),
createKeyOption,
]);
if (selectedValue !== createKeyOption.value) {
return selectedValue;
}
const newKey = await credentialsContext.ios.createAscApiKeyAsync(
credentialsContext.graphqlClient,
ownerAccount,
await provideOrGenerateAscApiKeyAsync(
credentialsContext,
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
)
);
return newKey.id;
}
@@ -0,0 +1,81 @@
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { AscAppLinkQuery } from '../../graphql/queries/AscAppLinkQuery';
type AscAppLinkMetadata = Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>>;
export type AscAppLinkStatus = 'not-connected' | 'connected' | 'invalid';
export interface AscAppLinkJsonOutput {
action: string;
project: string;
status: AscAppLinkStatus;
appStoreConnectApp: {
id: string;
ascAppIdentifier: string;
name: string | null;
bundleIdentifier: string | null;
appleUrl: string;
} | null;
}
export function buildJsonOutput(
action: string,
metadata: AscAppLinkMetadata
): AscAppLinkJsonOutput {
const link = metadata.appStoreConnectApp;
return {
action,
project: metadata.fullName,
status: link ? 'connected' : 'not-connected',
appStoreConnectApp: link
? {
id: link.id,
ascAppIdentifier: link.ascAppIdentifier,
name: link.remoteAppStoreConnectApp.name ?? null,
bundleIdentifier: link.remoteAppStoreConnectApp.bundleIdentifier ?? null,
appleUrl: getAppleAppUrl(link.ascAppIdentifier),
}
: null,
};
}
export function buildInvalidJsonOutput(action: string, projectId: string): AscAppLinkJsonOutput {
return {
action,
project: projectId,
status: 'invalid',
appStoreConnectApp: null,
};
}
export function isAscAuthenticationError(error: unknown): error is CombinedError {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.message.includes('App Store Connect rejected this API key'))
);
}
export function formatAscAppLinkStatus(metadata: AscAppLinkMetadata): string {
const link = metadata.appStoreConnectApp;
if (!link) {
return `Project ${chalk.bold(metadata.fullName)}: ${chalk.yellow('Not connected')} to App Store Connect.`;
}
const lines: string[] = [
`Project ${chalk.bold(metadata.fullName)}: ${chalk.green('Connected')} to App Store Connect.`,
` ASC App ID: ${chalk.bold(link.ascAppIdentifier)}`,
];
const remote = link.remoteAppStoreConnectApp;
lines.push(` Name: ${remote.name}`);
lines.push(` Bundle ID: ${remote.bundleIdentifier}`);
lines.push(` Apple URL: ${getAppleAppUrl(link.ascAppIdentifier)}`);
return lines.join('\n');
}
function getAppleAppUrl(ascAppIdentifier: string): string {
return `https://appstoreconnect.apple.com/apps/${encodeURIComponent(ascAppIdentifier)}/distribution`;
}
+1 -1
View File
@@ -70,7 +70,7 @@ function wrapFetchWithProgress(): (
};
}
async function downloadFileWithProgressTrackerAsync(
export async function downloadFileWithProgressTrackerAsync(
url: string,
outputPath: string,
progressTrackerMessage: string | ((ratio: number, total: number) => string),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.8.0",
"version": "18.9.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,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "18.8.0",
"@expo/build-tools": "18.9.0",
"@expo/eas-build-job": "18.8.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.8.0",
"version": "18.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -0,0 +1,51 @@
import type { JobInterpolationContext } from '@expo/eas-build-job';
import { interpolateJobContext } from '../interpolation';
describe(interpolateJobContext, () => {
it('interpolates multiple ${{ }} segments when the string starts with ${{ and ends with }}', () => {
const context = {
github: {
event: {
pull_request: {
html_url: 'https://github.com/acme/app/pull/42',
title: 'Fix crash',
},
},
},
} as unknown as JobInterpolationContext;
expect(
interpolateJobContext({
target: '${{ github.event.pull_request.html_url }}: ${{ github.event.pull_request.title }}',
context,
})
).toBe('https://github.com/acme/app/pull/42: Fix crash');
});
it('still preserves non-string types for a single whole-string interpolation', () => {
const context = {
build: { profile: 'production', id: '123' },
} as unknown as JobInterpolationContext;
expect(
interpolateJobContext({
target: '${{ build }}',
context,
})
).toEqual({ profile: 'production', id: '123' });
});
it("handles a single interpolation when the expression contains '}}' in a string literal", () => {
const context = {
build: { profile: 'production}}' },
} as unknown as JobInterpolationContext;
expect(
interpolateJobContext({
target: "${{ build.profile == 'production}}' }}",
context,
})
).toBe(true);
});
});
+5 -1
View File
@@ -2,6 +2,10 @@ import { JobInterpolationContext } from '@expo/eas-build-job';
import { jsepEval } from './utils/jsepEval';
function isSingleWholeStringInterpolation(value: string): boolean {
return value.startsWith('${{') && value.endsWith('}}') && !value.includes('${{', 3);
}
export function interpolateJobContext({
target,
context,
@@ -12,7 +16,7 @@ export function interpolateJobContext({
if (typeof target === 'string') {
// If the value is e.g. `build: ${{ inputs.build }}`, we will interpolate the value
// without changing `inputs.build` type, i.e. if it is an object it'll be like `build: {...inputs.build}`.
if (target.startsWith('${{') && target.endsWith('}}')) {
if (isSingleWholeStringInterpolation(target)) {
return jsepEval(target.slice(3, -2), context);
}
@@ -14,3 +14,4 @@ if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) {
// Always mock:
jest.mock('../src/CacheManager');
jest.mock('../src/utils/turtleFetch');
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.8.0",
"version": "18.9.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.8.0",
"@expo/build-tools": "18.9.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.5.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@hapi/boom": "10.0.1",
@@ -70,6 +70,7 @@ jest.mock('../config', () => {
default: {
...config.default,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
},
};
});
@@ -40,6 +40,7 @@ jest.mock('../config', () => {
return {
...config,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
@@ -40,6 +40,7 @@ jest.mock('../config', () => {
return {
...config,
buildId: 'f38532aa-81a8-4db7-915f-6e7afe46e22f',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
@@ -77,6 +77,7 @@ jest.mock('../config', () => {
return {
...config,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
jest.mock('../upload', () => {
+32
View File
@@ -5,9 +5,11 @@ import { getBuildEnv } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalCocoapodsCacheUrl = config.cocoapodsCacheUrl;
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
config.cocoapodsCacheUrl = originalCocoapodsCacheUrl;
});
it('passes the CLI sentry DSN to worker child processes', () => {
@@ -55,6 +57,36 @@ describe(getBuildEnv.name, () => {
});
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://storage.googleapis.com/eas-build-precompiled-modules/'
);
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('passes precompiled modules through the CocoaPods cache URL when available', () => {
config.cocoapodsCacheUrl = 'https://cache.example.com';
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {
EAS_USE_PRECOMPILED_MODULES: '1',
},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://cache.example.com/storage.googleapis.com/eas-build-precompiled-modules/'
);
});
});
+16
View File
@@ -12,6 +12,9 @@ import {
} from './external/turtle';
import { getAccessedEnvs } from './utils/env';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
// keep in sync with local-build-plugin env vars
// see packages/local-build-plugin/src/build.ts
export function getBuildEnv({
@@ -55,6 +58,7 @@ export function getBuildEnv({
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
setEnv(env, 'EXPO_PRECOMPILED_MODULES_BASE_URL', getPrecompiledModulesBaseUrl());
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
@@ -166,6 +170,18 @@ function setEnv(env: Env, key: string, value: string | null | undefined): void {
}
}
function getPrecompiledModulesBaseUrl(): string {
if (!config.cocoapodsCacheUrl) {
return PRECOMPILED_MODULES_BASE_URL;
}
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
return PRECOMPILED_MODULES_BASE_URL.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${config.cocoapodsCacheUrl.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
const ResourceClassToMaxHeapSize: Record<ResourceClass, string | null> = {
[ResourceClass.LINUX_C3D_STANDARD_4]: '4g', // 4 out of 16 GB, 12 GB left
[ResourceClass.LINUX_C3D_STANDARD_8]: '8g', // 8 out of 32 GB, 24 GB left
+36 -17
View File
@@ -332,7 +332,7 @@ export default class BuildService {
});
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
if (robotAccessToken) {
let rawErrorMessage: string = '';
if (maybeRawError?.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
@@ -340,23 +340,42 @@ export default class BuildService {
if (maybeRawError?.stdout) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
}
if (!rawErrorMessage) {
rawErrorMessage = maybeRawError?.message ?? err.message;
}
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
try {
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
tags: {
platform: job.platform,
workflow: job.type,
sdk_version: metadata?.sdkVersion ?? null,
react_native_version: metadata?.reactNativeVersion ?? null,
app_id: job.appId ?? null,
build_profile: metadata?.buildProfile ?? null,
app_name: metadata?.appName ?? null,
app_identifier: metadata?.appIdentifier ?? null,
distribution: metadata?.distribution ?? null,
cli_version: metadata?.cliVersion ?? null,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
);
} catch (fetchError: any) {
logger.warn({ err: fetchError }, 'Failed to send build error log');
}
}
await this.finishError(err, maybeArtifacts);
Generated
+8 -8
View File
@@ -2856,7 +2856,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:18.8.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:18.9.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2871,7 +2871,7 @@ __metadata:
"@expo/repack-app": "npm:~0.4.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.8.0"
"@expo/steps": "npm:18.9.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -3481,7 +3481,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:18.8.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:18.9.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
@@ -3554,7 +3554,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.8.0"
"@expo/build-tools": "npm:18.9.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:18.5.0"
@@ -3563,7 +3563,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:18.8.0"
"@expo/steps": "npm:18.9.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@hapi/boom": "npm:10.0.1"
@@ -10331,7 +10331,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:18.8.0"
"@expo/steps": "npm:18.9.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10944,7 +10944,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:18.8.0"
"@expo/build-tools": "npm:18.9.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
@@ -11018,7 +11018,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:18.8.0"
"@expo/steps": "npm:18.9.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"