Compare commits

..
16 Commits
Author SHA1 Message Date
Expo CI 3784a50399 v21.3.0 2026-07-27 01:58:10 +00:00
Brent Vatne ddf386597c [eas-cli] Add eas testflight:feedback and eas testflight:crashes (#4084) 2026-07-26 18:52:00 -07:00
Brent Vatne a2cf4c658d [reviewer] Use gpt-5.5 @expo/[email protected] (#4085) 2026-07-26 18:27:05 -07:00
Philippe Loulidi 23d1ed3af5 Bump @expo/code-review-cli default to ^0.4.0 (#4082) 2026-07-24 16:26:02 -07:00
Brent Vatne 417f0df466 [eas-cli] Add --json flag and usage examples to eas init (#4079) 2026-07-24 16:25:48 -07:00
William Grosset 591db3b142 [eas-cli] Add --account flag to eas init (#4057)
* [eas-cli] Add --account flag to eas init

* [eas-cli] Update CHANGELOG

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

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

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

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

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

* [eas-cli] Update robot user test expectation for the eas init --account error message
2026-07-24 14:39:18 -07:00
William Grosset 246d0c0794 [eas-cli] Prompt for account when linking unconfigured project (#4054)
* [eas-cli] Prompt for account when linking unconfigured project

* [eas-cli] Update CHANGELOG

* [eas-cli] Sort account choices from newest to oldest in project link prompt

* [eas-cli] Revert account sorting by creation date in project link prompt
2026-07-24 13:43:30 -07:00
Brent Vatne f299ccb493 Rename code review auth env var/secret to ANTHROPIC_OAUTH_API_KEY 2026-07-24 13:28:06 -07:00
Douglas Lowder adab98404e [eas-cli] Handle new server-side errors thrown when observe features are blocked (#4042)
# Why

New server code for observe features may throw errors when a feature is not available for an account.

# How

Handle these errors correctly, and surface the correct server-side error message.

# Test Plan

- Testing against accounts in staging
- New unit tests added
2026-07-24 12:00:03 -07:00
Brent Vatne a8676fe92d [eas-cli] Add project:icon:set command (#4068) 2026-07-24 09:27:31 -07:00
Szymon Świerk 68c87fa6fb [steps][4/4] Resolve composite function outputs (#4015)
# Why

This PR stack adds local composite functions for EAS workflows. A `function.yml` declares `outputs:` with templated values, and the caller should read them as `${{ steps.setup.outputs.version }}`, where `setup` is the id of the calling step.

After the previous PRs, data flows into a composite function (inputs, env) but nothing flows back out. This PR closes that loop.

# How

For a composite function that declares `outputs:`, `CompositeFunctionExpander` appends a synthetic function step after the inner steps, reusing the caller's step id.

- The step runs with `if: ${{ always() }}`
- Output values are interpolated against the composite-function-local step namespace

# Test Plan

Added outputs unit tests.
2026-07-24 17:31:29 +02:00
Szymon Świerk 1010e3b997 [steps][3/4] Interpolate composite function inputs (#4014)
# Why

This PR stack adds support for local composite functions in workflows. After structural expansion, inner steps still could not see `${{ inputs.* }}`, and caller `env` did not yet reach them at runtime.

This PR adds the data layer: input declaration, validation and interpolation, and runtime resolution of the caller context (env, templated working directories).

# How

- `CompositeFunctionExpander` builds declared composite function inputs as real `BuildStepInput` instances
- `BuildStepCompositeFunctionScope` exposes `${{ inputs.* }}` as lazy getters
- Caller `env` and working directory templating resolve through the composite function scope

# Test Plan

Added inputs and scoping unit tests.
2026-07-24 17:14:56 +02:00
Szymon Świerk a8e8c796dd [steps][2/4] Expand composite function references into build steps (#3929)
# Why

EAS workflows need reusable local composite functions: a step like `uses: ./.eas/functions/setup` should run the steps defined in `function.yml`, not be treated as a built-in function.

This PR adds the structural layer for that: turning a composite function call into concrete workflow steps with correct ids, scoping, and control flow. Input interpolation, outputs, and loading `function.yml` from disk are handled in separate PRs.

# How

When `StepsConfigParser` sees a local `uses:` path (`./` or `../`), `CompositeFunctionExpander` replaces the call with a flat list of inner steps:

- ID namespacing: inner step `read` under caller `setup` becomes `setup__read`
- Each expanded step carries a `BuildStepCompositeFunctionScope` so `${{ steps.* }}` and `${{ inputs.* }}` resolve against composite-function-local names
- Nested local composite functions are expanded recursively with cycle and depth checks
- Local composite functions are rejected in hooks

# Test Plan

Added expansion unit tests covering nesting, cycles, depth limits, and id collisions.
2026-07-24 16:56:35 +02:00
Szymon Świerk fcea341834 [steps][1/4] Add isCompositeFunctionPath and reject local composite functions in custom builds (#3956)
# Why

Local composite functions use `uses: ./...` paths. Custom build configs (`.eas/build/*.yml`) must reject these early with a clear error.

# How

- Adds `isCompositeFunctionPath` in `localCompositeFunctions.ts`
- Rejects local composite function paths in `validateAllFunctionsExist` for custom builds

# Test Plan

Added unit tests.
2026-07-24 16:37:24 +02:00
Szymon Świerk 197edf5ac6 [eas-build-job] Add local composite function schema (#3928)
# Why

First step toward local composite functions for EAS Workflows: reusable step groups referenced via `uses:` in `.eas/workflows/*.yml`.

This PR adds the shared contract in `@expo/eas-build-job` so all consumers agree on composite function config shape.

# How

Introduce the composite function config schema in `@expo/eas-build-job`, the single source of truth for the shape of a `function.yml` file. No runtime behavior changes yet.

Added:
- `CompositeFunctionConfigZ` / `CompositeFunctionConfig`: Zod schema and type for `function.yml` (inputs, outputs, runs.steps)
- `CompositeFunctionCatalog`: map of normalized path → config

# Test Plan

Added unit tests for `CompositeFunctionConfigZ`.
2026-07-24 14:28:21 +02:00
Expo CI a48ad7acd4 update CHANGELOG.md 2026-07-24 01:02:56 +00:00
97 changed files with 9294 additions and 2213 deletions

No files matched your search

+4 -5
View File
@@ -1,11 +1,10 @@
---
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
alwaysRun: true
# Security is the highest-stakes agent and benefits most from stronger threat-model
# reasoning, so it runs on Opus even though the other specialists use the Sonnet
# default. Scoped to this one agent to limit the extra latency/rate-limit cost;
# subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
model: anthropic/claude-opus-4-8
# No model override: security runs on the config default (gpt-5.5, on the
# subscription). To give it a stronger model, use the metered openai-api alias
# from the mixed auth.providers setup (see the @expo/code-review-cli README) —
# pro-tier models are excluded from the subscription.
---
# Security & secrets
+13 -11
View File
@@ -1,10 +1,9 @@
{
// Default model for the specialist reviewers (and the cross-file pass). Sonnet
// is the quality/speed sweet spot for finding real bugs. Per-agent frontmatter
// can override this; the coordinator runs on Haiku (see coordinator.md) since it
// only consolidates text. Override everything at runtime with REVIEWER_MODEL
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini-fast for a cheaper local run).
"model": "anthropic/claude-sonnet-5",
// Default model for every pass — served by the ChatGPT/Codex subscription (see
// auth below), so reviews have no marginal cost. Per-agent frontmatter can
// override this. Override everything at runtime with REVIEWER_MODEL
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini for a cheaper local run).
"model": "openai/gpt-5.5",
// Agents are every markdown file in agents/. shared.md (prepended to all) and
// coordinator.md are reserved. Per-agent overrides go in each file's frontmatter.
@@ -31,12 +30,15 @@
"breakGlass": { "marker": "/skip-review" },
"commentTag": "expo-ai-code-reviewer",
// Use a Claude Pro/Max OAuth token (from `claude setup-token`) rather than an
// x-api-key. The token is read from this env var and injected into an isolated
// OpenCode auth.json as a bearer credential.
// ChatGPT/Codex subscription (OAuth): tokenEnv holds the REFRESH token from an
// `opencode auth login` ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's
// auth.json); OpenCode mints short-lived access tokens from it on demand. All
// models above are on the subscription's allowlist, so reviews draw no metered
// spend. (To add pro-tier models later, see the mixed auth.providers setup in
// the @expo/code-review-cli README.)
"auth": {
"mode": "oauth",
"provider": "anthropic",
"tokenEnv": "DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY"
"provider": "openai",
"tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN"
}
}
+3 -4
View File
@@ -1,8 +1,7 @@
---
# The coordinator makes the final call — de-duping, re-judging severity, and
# deciding — so it runs on Opus: consolidation quality matters more here than the
# small serial-tail latency it adds (no repo tools, so it's a single bounded pass).
model: anthropic/claude-opus-4-8
# No model override: the coordinator runs on the config default (gpt-5.5, on the
# subscription). It only consolidates text (no repo tools), so the default is
# plenty; override here if consolidation quality ever needs a stronger model.
---
# Coordinator — consolidation & decision
@@ -19,7 +19,7 @@ permissions:
env:
# Published reviewer run via npx (override with repo variable ECR_VERSION).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
concurrency:
group: ai-code-review-cmd-${{ github.event.issue.number }}
@@ -104,7 +104,8 @@ jobs:
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY: ${{ secrets.DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY }}
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
@@ -18,7 +18,7 @@ permissions:
env:
# Published reviewer run via npx (override with repo variable ECR_VERSION).
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
concurrency:
group: ai-code-review-dismiss-${{ github.event.issue.number }}
+10 -6
View File
@@ -13,8 +13,8 @@ permissions:
env:
# The reviewer is the published @expo/code-review-cli package, run via npx — the
# repo only carries the `.expo-code-review/` config, not the engine source.
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.2.x.
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.5.x.
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
concurrency:
group: ai-code-review-${{ github.event.pull_request.number }}
@@ -98,7 +98,7 @@ jobs:
- name: Guard config.jsonc tokenEnv
if: steps.resolve.outputs.run == 'true'
env:
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY' }}
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
run: |
values=$(grep -oE '"tokenEnv"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_]+"' .expo-code-review/config.jsonc | sed -E 's/.*"([A-Za-z0-9_]+)"$/\1/')
count=$(printf '%s\n' "$values" | grep -c .)
@@ -135,9 +135,13 @@ jobs:
env:
# GitHub token scoped by the permissions block above (PR comments only).
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Claude Pro/Max OAuth token (auth.mode "oauth" in .expo-code-review/
# config.jsonc reads this env var). Not an x-api-key.
DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY: ${{ secrets.DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY }}
# ChatGPT/Codex subscription refresh token (auth.mode "oauth" in
# .expo-code-review/config.jsonc reads this env var); OpenCode mints
# access tokens from it. All reviews run on the subscription.
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
# Layer-1 runtime auth lock (mirrors the guard step): the CLI refuses to
# run when the tokenEnv the loaded config honors differs from this.
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
# Optional: override the model used by every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.resolve.outputs.agents }}
+14 -1
View File
@@ -8,12 +8,24 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas simulator:availability` to check whether EAS Simulator is enabled for the current project account. ([#4045](https://github.com/expo/eas-cli/pull/4045) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--account` flag to `eas init` to choose the account that should own the project, enabling non-interactive project creation (`eas init --account <name> --non-interactive`), and improve unconfigured-project error messages to list the accounts you can create projects in. ([#4057](https://github.com/expo/eas-cli/pull/4057) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add `--json` flag to `eas init` to print the linked project (status, project ID, owner, slug, dashboard URL) as JSON to stdout, and add usage examples to `eas init --help`. ([#4079](https://github.com/expo/eas-cli/pull/4079) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `eas testflight:feedback` and `eas testflight:crashes` to read TestFlight tester feedback (comments, device info, screenshot URLs) and crash reports (including full crash logs) from App Store Connect. Both support `--offset`/`--limit` pagination, `--json`, and looking up a single submission by ID or by the App Store Connect URL that EAS workflow triggers expose as `${{ app_store_connect.beta_feedback.url }}`. ([#4084](https://github.com/expo/eas-cli/pull/4084) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
### 🧹 Chores
## [21.2.0](https://github.com/expo/eas-cli/releases/tag/v21.2.0) - 2026-07-24
### 🎉 New features
- [eas-cli] Add `eas simulator:availability` to check whether EAS Simulator is enabled for the current project account. ([#4045](https://github.com/expo/eas-cli/pull/4045) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Calibrate the experimental AI code reviewer's agent prompts and noise config to eas-cli's conventions. ([#4065](https://github.com/expo/eas-cli/pull/4065) by [@brentvatne](https://github.com/brentvatne))
## [21.1.0](https://github.com/expo/eas-cli/releases/tag/v21.1.0) - 2026-07-22
@@ -28,6 +40,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Fix `eas update` failing with a server error when run inside an EAS Build. ([#4048](https://github.com/expo/eas-cli/pull/4048) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Avoid splitting an emoji when truncating a long git commit message, which produced an invalid commit message the build server rejected. ([#4055](https://github.com/expo/eas-cli/pull/4055) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Prompt for the account that should own a not-yet-configured EAS project when linking it from commands like `eas build`, `eas submit`, and `eas update`, instead of silently defaulting to the personal account. ([#4054](https://github.com/expo/eas-cli/pull/4054) by [@williamgrosset](https://github.com/williamgrosset))
## [21.0.3](https://github.com/expo/eas-cli/releases/tag/v21.0.3) - 2026-07-22
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.2.0",
"version": "21.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.1.0",
"version": "21.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -39,14 +39,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.1.0",
"@expo/eas-build-job": "21.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.1.0",
"@expo/steps": "21.3.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.1.0",
"version": "21.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "21.1.0"
"@expo/steps": "21.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.1.0",
"version": "21.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -0,0 +1,78 @@
import { ZodError } from 'zod';
import { CompositeFunctionConfigZ } from '../compositeFunction';
describe('CompositeFunctionConfigZ', () => {
it('accepts a valid local composite function config', () => {
const config = {
name: 'Setup',
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', run: 'set-output version "1.0.0"' }],
},
};
expect(CompositeFunctionConfigZ.parse(config)).toEqual(config);
});
it('accepts shorthand input names', () => {
const config = {
inputs: ['greeting'],
runs: {
steps: [{ run: 'echo hello' }],
},
};
expect(CompositeFunctionConfigZ.parse(config)).toEqual(config);
});
it('accepts a minimal config with only runs.steps', () => {
const config = {
runs: {
steps: [{ run: 'echo hello' }],
},
};
expect(CompositeFunctionConfigZ.parse(config)).toEqual(config);
});
it('applies default input type "string" when omitted', () => {
const config = {
inputs: [{ name: 'greeting' }],
runs: {
steps: [{ run: 'echo hello' }],
},
};
expect(CompositeFunctionConfigZ.parse(config)).toEqual({
inputs: [{ name: 'greeting', type: 'string' }],
runs: {
steps: [{ run: 'echo hello' }],
},
});
});
it('rejects unknown top-level keys', () => {
const config = {
unknown_field: true,
runs: {
steps: [{ run: 'echo hello' }],
},
};
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(/unknown_field/);
});
it('errors when runs.steps is empty', () => {
const config = {
name: 'Broken',
runs: { steps: [] },
};
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError);
try {
CompositeFunctionConfigZ.parse(config);
} catch (err) {
expect(err).toBeInstanceOf(ZodError);
expect((err as ZodError).issues[0]?.message).toMatch(
/must declare at least one step under "runs.steps"/
);
}
});
});
@@ -0,0 +1,114 @@
/**
* Schema for local composite functions, reusable step groups referenced via `uses:` in EAS
* workflows (`.eas/workflows/*.yml`) or inline job step definitions.
*
* This module defines the shape of a composite function configuration file (`function.yml`).
* Callers that load composite function files format validation errors from `CompositeFunctionConfigZ`.
* Local composite functions are not supported in `.eas/build/*.yml` custom build config files.
*/
import { z } from 'zod';
import { StepZ } from './step';
const CompositeFunctionInputValueTypeNameZ = z.enum(['string', 'boolean', 'number', 'json']);
const CompositeFunctionInputValueZ = z.union([
z.string(),
z.boolean(),
z.number(),
z.array(z.unknown()),
z.record(z.string(), z.unknown()),
]);
const CompositeFunctionInputZ = z.union([
z
.string()
.describe('Shorthand for an input name with default type "string" and no default value.'),
z
.object({
name: z.string(),
type: CompositeFunctionInputValueTypeNameZ.default('string'),
default_value: CompositeFunctionInputValueZ.optional(),
allowed_values: z.array(CompositeFunctionInputValueZ).optional(),
required: z.boolean().optional(),
})
.strict(),
]);
const CompositeFunctionOutputZ = z
.object({
description: z.string().optional(),
/**
* @example
* value: '${{ steps.read.outputs.version }}'
*/
value: z.string().describe('Expression that resolves to the output value.'),
})
.strict();
export const CompositeFunctionConfigZ = z
.object({
/**
* @example
* name: Setup
*/
name: z.string().optional().describe('Display name of the composite function.'),
description: z.string().optional(),
/**
* @example
* inputs:
* - greeting
* - name: platform
* type: string
* default_value: ios
*/
inputs: z
.array(CompositeFunctionInputZ)
.optional()
.describe('Inputs accepted by the composite function.'),
/**
* @example
* outputs:
* version:
* value: '${{ steps.read.outputs.version }}'
*/
outputs: z
.record(z.string(), CompositeFunctionOutputZ)
.optional()
.describe('Named outputs exposed by the composite function to its caller.'),
runs: z.object({
/**
* @example
* runs:
* steps:
* - id: read
* run: set-output version "1.0.0"
*/
steps: z
.array(StepZ)
.min(1, {
message: 'A composite function must declare at least one step under "runs.steps".',
})
.describe('Steps executed when the composite function is invoked.'),
}),
})
.strict();
/**
* Structure of a local composite function configuration file (`function.yml`).
*
* @example
* name: Setup
* inputs:
* - greeting
* outputs:
* version:
* value: '${{ steps.read.outputs.version }}'
* runs:
* steps:
* - id: read
* run: set-output version "1.0.0"
*/
export type CompositeFunctionConfig = z.infer<typeof CompositeFunctionConfigZ>;
export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
+1
View File
@@ -26,6 +26,7 @@ export * from './context';
export * from './generic';
export * from './hooks';
export * from './step';
export * from './compositeFunction';
export * from './submission-config';
export * from './projectPackage';
+243 -115
View File
File diff suppressed because it is too large. Load diff
+1143 -1413
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.2.0",
"version": "21.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "21.1.0",
"@expo/eas-json": "21.1.0",
"@expo/eas-build-job": "21.3.0",
"@expo/eas-json": "21.3.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "21.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.1.0",
"@expo/steps": "21.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -221,6 +221,9 @@
"secret": {
"description": "manage project and account secrets"
},
"testflight": {
"description": "read TestFlight feedback and crashes"
},
"update": {
"description": "manage individual updates"
},
@@ -7,6 +7,7 @@ import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { learnMore } from '../../../../log';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../../project/projectUtils';
import { promptAsync } from '../../../../prompts';
import SessionManager from '../../../../user/SessionManager';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from '../getProjectIdAsync';
@@ -23,11 +24,14 @@ jest.mock('../../../../ora', () => ({
}));
jest.mock('../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
describe(getProjectIdAsync, () => {
let sessionManager: SessionManager;
beforeEach(() => {
jest.clearAllMocks();
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
@@ -74,6 +78,10 @@ describe(getProjectIdAsync, () => {
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
jest
.mocked(promptAsync)
.mockResolvedValue({ account: { id: 'account_id_1', name: 'notnotbrent' } });
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
@@ -338,6 +346,161 @@ describe(getProjectIdAsync, () => {
);
});
describe('account selection when project is not configured', () => {
beforeEach(() => {
jest
.mocked(getConfig)
.mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any);
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'success',
config: {
sdkVersion: '52.0.0',
name: 'test',
slug: 'test',
extra: { eas: { projectId: '2345' } },
},
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '2345');
});
it('prompts for the account when the user has multiple accounts and no owner is set', async () => {
jest
.mocked(promptAsync)
.mockResolvedValue({ account: { id: 'account_id_2', name: 'dominik' } });
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: false }
)
).resolves.toEqual('2345');
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'dominik', projectName: 'test' },
{ nonInteractive: false },
expect.anything()
);
});
it('uses the owner from the app config without prompting', async () => {
jest.mocked(getConfig).mockReturnValue({
exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test', owner: 'dominik' },
} as any);
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test', owner: 'dominik' },
{ nonInteractive: false }
)
).resolves.toEqual('2345');
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'dominik', projectName: 'test' },
{ nonInteractive: false },
expect.anything()
);
});
it('uses the only account without prompting when the user has a single account', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
} as any);
await expect(
getProjectIdAsync(
instance(sessionManagerMock),
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: false }
)
).resolves.toEqual('2345');
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'notnotbrent', projectName: 'test' },
{ nonInteractive: false },
expect.anything()
);
});
it('throws for a robot user when no owner is set', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'Robot',
id: 'robot_id',
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
} as any);
await expect(
getProjectIdAsync(
instance(sessionManagerMock),
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: false }
)
).rejects.toThrow(
'Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: notnotbrent'
);
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
it('throws in non-interactive mode when the user has multiple accounts and no owner is set', async () => {
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).rejects.toThrow(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
});
it('throws if writing the ID back to the config fails', async () => {
jest
.mocked(getConfig)
@@ -8,13 +8,18 @@ import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { learnMore } from '../../../log';
import { ora } from '../../../ora';
import {
getAccountChoices,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp,
} from '../../../project/accountSelection';
import {
createOrModifyExpoConfigAsync,
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor, getActorUsername } from '../../../user/User';
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
/**
* Save an EAS project ID to the appropriate field in the app config.
@@ -180,28 +185,12 @@ export async function validateOrSetProjectIdAsync({
Log.warn('EAS project not configured.');
const getDefaultAccountNameForEASProject = (exp: ExpoConfig, user: Actor): string => {
if (exp.owner) {
return exp.owner;
}
switch (user.__typename) {
case 'User':
return user.username;
case 'SSOUser':
return user.username;
case 'PartnerActor':
return user.username;
case 'Robot':
throw new Error(
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
);
}
};
const accountName = await chooseAccountNameForEASProjectAsync(exp, actor, options);
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
graphqlClient,
{
accountName: getDefaultAccountNameForEASProject(exp, actor),
accountName,
projectName: exp.slug,
},
{
@@ -221,3 +210,51 @@ export async function validateOrSetProjectIdAsync({
return projectId;
}
/**
* Choose the account that should own a not-yet-configured EAS project. Uses the "owner" field
* from the app config when set. Otherwise, when the actor has access to multiple accounts,
* prompts for the account to use.
*/
async function chooseAccountNameForEASProjectAsync(
exp: ExpoConfig,
actor: Actor,
options: { nonInteractive: boolean }
): Promise<string> {
if (exp.owner) {
return exp.owner;
}
if (actor.__typename === 'Robot') {
throw new Error(
`Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: ${getCreatableAccountNames(
actor
).join(', ')}`
);
}
const allAccounts = actor.accounts;
if (allAccounts.length === 1) {
return allAccounts[0].name;
}
if (options.nonInteractive) {
throw new Error(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
}
const choices = getAccountChoices(
actor,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(actor)
);
return (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
@@ -1,8 +1,12 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppObservePlatform } from '../../../graphql/generated';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import {
buildObserveCustomEventNamesJson,
buildObserveCustomEventsEmptyWithSuggestionsJson,
@@ -73,6 +77,31 @@ describe(ObserveEvents, () => {
return command;
}
function planGateError(): CombinedError {
const serverMessage =
'Subscription to EAS is required for this feature. ' +
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
return new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}),
],
});
}
it('surfaces the plan-gate message when listing event names is not available on the plan', async () => {
mockCustomEventNamesAsync.mockRejectedValueOnce(planGateError());
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(/Subscription to EAS is required/);
});
it('surfaces the plan-gate message when querying events is not available on the plan', async () => {
mockFetchObserveCustomEventsAsync.mockRejectedValueOnce(planGateError());
const command = createCommand(['my_event']);
await expect(command.runAsync()).rejects.toThrow(/Subscription to EAS is required/);
});
it('passes eventName arg to fetchObserveCustomEventsAsync', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [{ id: 'evt-1' } as any],
@@ -1,7 +1,11 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetricsSummary from '../metrics-summary';
@@ -52,6 +56,24 @@ describe(ObserveMetricsSummary, () => {
return command;
}
it('surfaces the server plan-gate message when a metric is not available on the plan', async () => {
const serverMessage =
'Subscription to EAS is required for this feature. ' +
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
mockFetchObserveMetricsSummaryAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}),
],
})
);
const command = createCommand(['--metric', 'nav_tti']);
await expect(command.runAsync()).rejects.toThrow(serverMessage);
});
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
@@ -1,3 +1,6 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
@@ -6,6 +9,7 @@ import {
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
@@ -52,6 +56,24 @@ describe(ObserveMetrics, () => {
return command;
}
it('surfaces the server plan-gate message when the metric is not available on the plan', async () => {
const serverMessage =
'Subscription to EAS is required for this feature. ' +
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
mockFetchObserveEventsAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}),
],
})
);
const command = createCommand(['nav_tti']);
await expect(command.runAsync()).rejects.toThrow(serverMessage);
});
it('accepts a navigation metric alias as the positional arg', async () => {
const command = createCommand(['nav_tti']);
await command.runAsync();
@@ -1,7 +1,11 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import {
buildObserveNavigationRoutesJson,
buildObserveNavigationRoutesTable,
@@ -50,6 +54,24 @@ describe(ObserveRoutes, () => {
return command;
}
it('surfaces the server plan-gate message when navigation is not available on the plan', async () => {
const serverMessage =
'Subscription to EAS is required for this feature. ' +
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
mockFetchObserveNavigationRoutesAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}),
],
})
);
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(serverMessage);
});
it('queries both platforms by default with all three navigation metric full names', async () => {
const command = createCommand([]);
await command.runAsync();
@@ -1,10 +1,15 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import {
fetchObserveSessionEventsAsync,
fetchSessionLogCandidatesAsync,
fetchSessionMetricCandidatesAsync,
verifyObserveSessionAccessAsync,
} from '../../../observe/fetchSessions';
import {
buildObserveSessionEventsJson,
@@ -39,6 +44,7 @@ jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
const mockVerifyObserveSessionAccessAsync = jest.mocked(verifyObserveSessionAccessAsync);
const mockFetchSessionMetricCandidatesAsync = jest.mocked(fetchSessionMetricCandidatesAsync);
const mockFetchSessionLogCandidatesAsync = jest.mocked(fetchSessionLogCandidatesAsync);
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
@@ -162,6 +168,23 @@ describe(ObserveSession, () => {
await expect(command.runAsync()).rejects.toThrow();
});
it('surfaces the server plan-gate message when the timeline is plan-gated', async () => {
const serverMessage =
'Access to this feature is not included in your current plan. ' +
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
const gateError = new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
}),
],
});
mockFetchObserveSessionEventsAsync.mockRejectedValueOnce(gateError);
const command = createCommand(['session-abc']);
await expect(command.runAsync()).rejects.toThrow(serverMessage);
});
// ---- new picker-mode behavior ----
it('errors when no session ID is provided in non-interactive mode', async () => {
@@ -176,6 +199,36 @@ describe(ObserveSession, () => {
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
it('surfaces the plan-gate upgrade prompt before the picker when session access is blocked', async () => {
mockVerifyObserveSessionAccessAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(
'Upgrade your plan to view session timelines.',
null,
null,
null,
null,
null,
{
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
}
),
],
})
);
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(/Upgrade your plan to view session timelines/);
// The picker is never entered: no prompts, candidate fetches, or drill-down.
expect(mockSelectAsync).not.toHaveBeenCalled();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockFetchSessionMetricCandidatesAsync).not.toHaveBeenCalled();
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
it('rejects picker flags when a session ID is also provided', async () => {
const command = createCommand(['session-abc', '--event-name', 'tti']);
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
+22 -17
View File
@@ -25,6 +25,7 @@ import {
buildObserveCustomEventsJson,
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
@@ -101,12 +102,14 @@ export default class ObserveEvents extends EasCommand {
const platform = appObservePlatformFromFlag(flags.platform);
if (!args.eventName && !flags['all-events']) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
platform,
});
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
platform,
})
);
if (json) {
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
@@ -124,17 +127,19 @@ export default class ObserveEvents extends EasCommand {
return;
}
const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, {
eventName: args.eventName,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
sessionId: flags['session-id'],
});
const { events, pageInfo } = await withObservePlanGateHandlingAsync(() =>
fetchObserveCustomEventsAsync(graphqlClient, projectId, {
eventName: args.eventName,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
sessionId: flags['session-id'],
})
);
if (args.eventName && events.length === 0) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
@@ -19,6 +19,7 @@ import {
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
@@ -99,13 +100,15 @@ export default class ObserveMetricsSummary extends EasCommand {
const platforms = appPlatformsFromFlag(flags.platform);
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
await fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricNames,
platforms,
startTime,
endTime
await withObservePlanGateHandlingAsync(() =>
fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricNames,
platforms,
startTime,
endTime
)
);
const argumentsStat = flags.stat?.length
@@ -23,6 +23,7 @@ import {
ObserveUpdateIdFlag,
} from '../../observe/flags';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
@@ -110,27 +111,29 @@ export default class ObserveMetrics extends EasCommand {
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = appPlatformsFromFlag(flags.platform);
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
}),
fetchTotalEventCountAsync(
graphqlClient,
projectId,
metricName,
platforms,
startTime,
endTime
),
]);
const [{ events, pageInfo }, totalEventCount] = await withObservePlanGateHandlingAsync(() =>
Promise.all([
fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
}),
fetchTotalEventCountAsync(
graphqlClient,
projectId,
metricName,
platforms,
startTime,
endTime
),
])
);
if (json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
@@ -24,6 +24,7 @@ import {
resolveNavigationStatKey,
} from '../../observe/formatNavigationRoutes';
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
@@ -113,10 +114,8 @@ export default class ObserveRoutes extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
graphqlClient,
projectId,
{
const { routes, pageInfoByPlatform } = await withObservePlanGateHandlingAsync(() =>
fetchObserveNavigationRoutesAsync(graphqlClient, projectId, {
startTime,
endTime,
platforms,
@@ -126,7 +125,7 @@ export default class ObserveRoutes extends EasCommand {
updateId: flags['update-id'],
buildNumber: flags['build-number'],
routeNames,
}
})
);
if (json) {
@@ -14,8 +14,10 @@ import {
fetchObserveSessionEventsAsync,
fetchSessionLogCandidatesAsync,
fetchSessionMetricCandidatesAsync,
verifyObserveSessionAccessAsync,
} from '../../observe/fetchSessions';
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import {
buildObserveSessionEventsJson,
buildObserveSessionEventsTable,
@@ -112,6 +114,13 @@ export default class ObserveSession extends EasCommand {
'A session ID argument is required in non-interactive mode. In interactive mode, you can omit the session ID to pick one from a list of events.'
);
} else {
// Session timelines are a paid feature. Verify access before walking the
// user through the interactive picker so a blocked plan gets the upgrade
// prompt immediately, rather than after selecting an event (or a
// misleading "No events found" when there are no candidates).
await withObservePlanGateHandlingAsync(() =>
verifyObserveSessionAccessAsync(graphqlClient, projectId)
);
sessionId = await pickSessionIdInteractivelyAsync({
graphqlClient,
projectId,
@@ -122,10 +131,12 @@ export default class ObserveSession extends EasCommand {
}
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
sessionId,
limit: SESSION_PAGE_SIZE,
});
await withObservePlanGateHandlingAsync(() =>
fetchObserveSessionEventsAsync(graphqlClient, projectId, {
sessionId,
limit: SESSION_PAGE_SIZE,
})
);
if (json) {
printJsonOnlyOutput(
@@ -17,6 +17,7 @@ import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ProjectInit from '../init';
jest.mock('fs');
@@ -37,6 +38,7 @@ jest.mock('../../../ora', () => ({
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../project/projectUtils');
jest.mock('../../../utils/json');
let originalProcessArgv: string[];
@@ -535,4 +537,354 @@ describe(ProjectInit.name, () => {
});
});
});
describe('when account flag is provided', () => {
it('uses the specified account without prompting', async () => {
mockTestProject({});
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '0129',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester'], commandOptions).run();
expect(promptAsync).not.toHaveBeenCalled();
expect(jest.mocked(AppMutation.createAppAsync).mock.calls[0][1]).toEqual({
accountId: jester.accounts[0].id,
projectName: 'testing-123',
});
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
});
it('creates the project non-interactively without requiring --force', async () => {
mockTestProject({});
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '0129',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--non-interactive'], commandOptions).run();
expect(promptAsync).not.toHaveBeenCalled();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
});
it('links an existing project non-interactively without requiring --force', async () => {
mockTestProject({});
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--non-interactive'], commandOptions).run();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
});
it('throws non-interactively without --account when no owner is set', async () => {
mockTestProject({});
await expect(
new ProjectInit(['--non-interactive'], commandOptions).run()
).rejects.toThrowError(
'You have access to multiple accounts. Choose the account that should own this project with the --account flag:'
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('throws when the account does not exist or is not accessible', async () => {
mockTestProject({});
await expect(
new ProjectInit(['--account', 'nonexistent'], commandOptions).run()
).rejects.toThrowError(
`You are not able to create projects in the "nonexistent" account. Accounts you have permissions to create projects in: jester`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('throws when the actor only has a view-only role in the account', async () => {
mockTestProject({});
await expect(
new ProjectInit(['--account', 'other'], commandOptions).run()
).rejects.toThrowError(
`You are not able to create projects in the "other" account. Accounts you have permissions to create projects in: jester`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('is a no-op when the project is already linked to the specified account', async () => {
mockTestProject({ configuredProjectId: '1234' });
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('throws when the project is already linked to a different account without --force', async () => {
mockTestProject({ configuredProjectId: '1234' });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester2/testing-123',
ownerAccount: jester2.accounts[0],
});
await expect(
new ProjectInit(['--account', 'jester'], commandOptions).run()
).rejects.toThrowError(
`This project is already linked to @jester2 (ID: 1234). Pass --force to re-link it to a project owned by jester, or remove the "extra.eas.projectId" field from your app config.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('re-links to the specified account when the project is already linked to a different account with --force', async () => {
mockTestProject({ configuredProjectId: '1234' });
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest
.mocked(AppQuery.byIdAsync)
// ownership check for the currently linked project
.mockResolvedValueOnce({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester2/testing-123',
ownerAccount: jester2.accounts[0],
})
// owner/slug consistency check for the newly linked project
.mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--force'], commandOptions).run();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
});
it('re-links and overwrites a conflicting "owner" field with --force', async () => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: 'jester2' });
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest
.mocked(AppQuery.byIdAsync)
// ownership check for the currently linked project
.mockResolvedValueOnce({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester2/testing-123',
ownerAccount: jester2.accounts[0],
})
// owner/slug consistency check for the newly linked project
.mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--account', 'jester', '--force'], commandOptions).run();
expect(confirmAsync).not.toHaveBeenCalled();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
owner: 'jester',
});
});
it('throws when the account conflicts with the owner field in the app config', async () => {
mockTestProject({ configuredOwner: jester.accounts[0].name });
await expect(
new ProjectInit(['--account', 'other'], commandOptions).run()
).rejects.toThrowError(
`The account specified with --account (other) does not match the "owner" field in your app config (jester). Provide a matching --account or update the "owner" field.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
});
describe('when json flag is provided', () => {
it('prints the created project as JSON', async () => {
mockTestProject({});
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '0129',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(
['--account', 'jester', '--json', '--non-interactive'],
commandOptions
).run();
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'created',
projectId: '0129',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('prints the linked project as JSON when an existing project is found on the server', async () => {
mockTestProject({});
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '123456',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(
['--account', 'jester', '--json', '--non-interactive'],
commandOptions
).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'linked',
projectId: '123456',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('prints already-linked as JSON when the project is already configured', async () => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('prints the linked project as JSON when linking with an explicit id', async () => {
mockTestProject({});
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: {} as any });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit(['--id', '1234', '--json', '--non-interactive'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '1234');
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
it('implies non-interactive mode', async () => {
mockTestProject({});
await expect(new ProjectInit(['--json'], commandOptions).run()).rejects.toThrowError(
'You have access to multiple accounts. Choose the account that should own this project with the --account flag'
);
expect(promptAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
it('does not print JSON without the json flag', async () => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
await new ProjectInit([], commandOptions).run();
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,144 @@
import { Config } from '@oclif/core';
import { vol } from 'memfs';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppUploadSessionType } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../../uploads';
import { sleepAsync } from '../../../../utils/promise';
import ProjectIconSet from '../set';
jest.mock('fs');
jest.mock('../../../../graphql/queries/AppQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../../uploads');
jest.mock('../../../../utils/promise');
const mockByIdAsync = jest.mocked(AppQuery.byIdAsync);
const mockByIdProfileImageUrlAsync = jest.mocked(AppQuery.byIdProfileImageUrlAsync);
const mockUploadAsync = jest.mocked(uploadAppScopedFileAtPathToGCSAsync);
const mockSleepAsync = jest.mocked(sleepAsync);
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(ProjectIconSet, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'project-123';
let now: number;
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
mockSleepAsync.mockImplementation(async ms => {
now += ms;
});
mockByIdAsync.mockResolvedValue({
id: projectId,
fullName: '@test-account/test-project',
slug: 'test-project',
ownerAccount: { name: 'test-account' },
} as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
function createCommand(argv: string[]): ProjectIconSet {
const command = new ProjectIconSet(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('uploads the icon and polls until the icon URL changes', async () => {
vol.fromJSON({ '/app/icon.png': 'fake-png-bytes' });
mockByIdProfileImageUrlAsync
.mockResolvedValueOnce('https://example.com/old.png')
.mockResolvedValueOnce('https://example.com/old.png')
.mockResolvedValueOnce('https://example.com/new.png');
const command = createCommand(['/app/icon.png']);
await command.runAsync();
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: '/app/icon.png',
});
expect(mockByIdProfileImageUrlAsync).toHaveBeenCalledTimes(3);
});
it('succeeds when the project had no icon before', async () => {
vol.fromJSON({ '/app/icon.jpg': 'fake-jpg-bytes' });
mockByIdProfileImageUrlAsync
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('https://example.com/new.png');
const command = createCommand(['/app/icon.jpg']);
await command.runAsync();
expect(mockUploadAsync).toHaveBeenCalledTimes(1);
});
it('throws when the file does not exist', async () => {
const command = createCommand(['/app/missing.png']);
await expect(command.runAsync()).rejects.toThrow('No file found at /app/missing.png');
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws for an unsupported image format', async () => {
vol.fromJSON({ '/app/icon.gif': 'fake-gif-bytes' });
const command = createCommand(['/app/icon.gif']);
await expect(command.runAsync()).rejects.toThrow('Unsupported image format ".gif"');
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws for a file over the size limit', async () => {
vol.fromJSON({ '/app/icon.png': 'x'.repeat(10 * 1024 * 1024 + 1) });
const command = createCommand(['/app/icon.png']);
await expect(command.runAsync()).rejects.toThrow('maximum allowed size is 10 MB');
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws when the icon is not processed before the timeout', async () => {
vol.fromJSON({ '/app/icon.png': 'fake-png-bytes' });
mockByIdProfileImageUrlAsync.mockResolvedValue('https://example.com/old.png');
const command = createCommand(['/app/icon.png']);
await expect(command.runAsync()).rejects.toThrow(
'Timed out waiting for the icon to be processed'
);
expect(mockUploadAsync).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,131 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getProjectDashboardUrl } from '../../../build/utils/url';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AppUploadSessionType } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads';
import { sleepAsync } from '../../../utils/promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
export default class ProjectIconSet extends EasCommand {
static override description = 'set the project icon displayed on the EAS dashboard';
static override args = {
path: Args.string({
required: true,
description:
'Path to the icon image (PNG or JPEG, at most 10 MB). Non-square images are center-cropped to a square.',
}),
};
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { path: imagePath },
flags,
} = await this.parse(ProjectIconSet);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ProjectIconSet, {
nonInteractive: flags['non-interactive'],
});
await validateImageAsync(imagePath);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
const previousProfileImageUrl = await AppQuery.byIdProfileImageUrlAsync(
graphqlClient,
projectId
);
const spinner = ora('Uploading project icon').start();
try {
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: imagePath,
});
// The icon is processed asynchronously (resized and assigned to the
// project by the server), so poll until the icon URL changes.
spinner.text = 'Processing project icon';
await pollForProfileImageChangeAsync(graphqlClient, {
projectId,
previousProfileImageUrl,
projectDashboardUrl,
});
spinner.succeed(`Set icon for ${chalk.bold(app.fullName)}`);
Log.withTick(`View it on the project page: ${link(projectDashboardUrl)}`);
} catch (error) {
spinner.fail('Failed to set project icon');
throw error;
}
}
}
async function validateImageAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
async function pollForProfileImageChangeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
previousProfileImageUrl,
projectDashboardUrl,
}: {
projectId: string;
previousProfileImageUrl: string | null;
projectDashboardUrl: string;
}
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
projectDashboardUrl
)}`
);
}
+49 -13
View File
@@ -1,26 +1,43 @@
import { Flags } from '@oclif/core';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
ProjectInitResult,
ensureOwnerSlugConsistencyAsync,
initializeWithExplicitIDAsync,
initializeWithoutExplicitIDAsync,
} from '../../project/projectInitialization';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ProjectInit extends EasCommand {
static override description = 'create or link an EAS project';
static override aliases = ['init'];
static override examples = [
'$ eas init \t # Create or link a project interactively',
'$ eas init --id <project-id> \t # Link to the project with the given ID',
'$ eas init --account my-account --non-interactive \t # Create or link @my-account/<slug> without prompts',
'$ eas init --account my-account --json --non-interactive \t # Same, and print the result as JSON to stdout',
];
static override flags = {
id: Flags.string({
description: 'ID of the EAS project to link',
}),
account: Flags.string({
description: 'Name of the account that will own the project',
exclusive: ['id'],
}),
force: Flags.boolean({
description:
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
}),
...EASNonInteractiveFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
@@ -29,31 +46,50 @@ export default class ProjectInit extends EasCommand {
};
async runAsync(): Promise<void> {
const {
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
} = await this.parse(ProjectInit);
const { flags } = await this.parse(ProjectInit);
const { id: idArgument, account: accountArgument, force } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
loggedIn: { actor, graphqlClient },
projectDir,
} = await this.getContextAsync(ProjectInit, { nonInteractive });
let idForConsistency: string;
let result: ProjectInitResult;
if (idArgument) {
await initializeWithExplicitIDAsync(idArgument, projectDir, {
const status = await initializeWithExplicitIDAsync(idArgument, projectDir, {
force,
nonInteractive,
});
idForConsistency = idArgument;
result = { projectId: idArgument, status };
} else {
idForConsistency = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
result = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
force,
nonInteractive,
accountName: accountArgument,
});
}
await ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
force,
nonInteractive,
});
const { owner, slug } = await ensureOwnerSlugConsistencyAsync(
graphqlClient,
result.projectId,
projectDir,
{
force,
nonInteractive,
}
);
if (jsonFlag) {
printJsonOnlyOutput({
status: result.status,
projectId: result.projectId,
owner,
slug,
dashboardUrl: getProjectDashboardUrl(owner, slug),
});
}
}
}
@@ -0,0 +1,108 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { resolveTestFlightAppAsync } from '../../testflight/app';
import {
TESTFLIGHT_PAGE_SIZE,
listAndRenderTestFlightCrashesAsync,
viewAndRenderBetaFeedbackAsync,
} from '../../testflight/queries';
import { resolveBetaFeedbackTarget } from '../../testflight/target';
import { enableJsonOutput } from '../../utils/json';
export default class TestFlightCrashes extends EasCommand {
static override description =
'display crashes reported by TestFlight testers, or the full crash log of a single crash';
static override examples = [
'$ eas testflight:crashes \t # Show the most recent crashes',
'$ eas testflight:crashes --limit 50 --offset 20 \t # Page through crashes',
'$ eas testflight:crashes AAo2eIIfGzcb1BzuUv3xrh4 \t # Show the full crash log for one crash',
'$ eas testflight:crashes --json \t # Print a page of crashes, with paging metadata, as JSON',
'$ eas testflight:crashes ${{ app_store_connect.beta_feedback.url }} --json \t # Look up whatever an EAS workflow trigger reported',
];
static override args = {
id: Args.string({
description:
'ID or App Store Connect API URL of a single submission to show. Accepts ${{ app_store_connect.beta_feedback.id }} or ${{ app_store_connect.beta_feedback.url }} from an EAS workflow trigger.',
required: false,
}),
};
static override flags = {
type: Flags.option({
description:
'Kind of feedback the ID refers to. Only needed when passing a bare ID for screenshot feedback; a URL already encodes it.',
options: ['crash', 'screenshot'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json used to resolve the bundle identifier and App Store Connect API key. Defaults to "production".',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: TESTFLIGHT_PAGE_SIZE, limit: 200 }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(TestFlightCrashes);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json, nonInteractive } = paginatedQueryOptions;
if (json) {
enableJsonOutput();
}
if (flags.type && !args.id) {
throw new Error('--type only applies when looking up a single submission by ID or URL.');
}
// Resolved before authenticating so a malformed ID or URL fails fast.
const target = args.id
? resolveBetaFeedbackTarget({ idOrUrl: args.id, type: flags.type, defaultType: 'crash' })
: null;
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(TestFlightCrashes, {
nonInteractive,
withServerSideEnvironment: null,
});
const app = await resolveTestFlightAppAsync({
actor,
analytics,
exp,
graphqlClient,
nonInteractive,
profileName: flags.profile,
projectDir,
projectId,
vcsClient,
});
if (target) {
await viewAndRenderBetaFeedbackAsync(app, target, { json });
return;
}
await listAndRenderTestFlightCrashesAsync(app, paginatedQueryOptions);
}
}
@@ -0,0 +1,108 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { resolveTestFlightAppAsync } from '../../testflight/app';
import {
TESTFLIGHT_PAGE_SIZE,
listAndRenderTestFlightFeedbackAsync,
viewAndRenderBetaFeedbackAsync,
} from '../../testflight/queries';
import { resolveBetaFeedbackTarget } from '../../testflight/target';
import { enableJsonOutput } from '../../utils/json';
export default class TestFlightFeedback extends EasCommand {
static override description =
'display screenshot feedback submitted by TestFlight testers, including their comments, device information, and screenshot URLs';
static override examples = [
'$ eas testflight:feedback \t # Show the most recent feedback submissions',
'$ eas testflight:feedback --limit 50 --offset 20 \t # Page through submissions',
'$ eas testflight:feedback --json \t # Print a page of feedback, with paging metadata, as JSON',
'$ eas testflight:feedback AD8JvKbr0BK0Cj9OnM6WO6I \t # Show a single submission by ID',
'$ eas testflight:feedback ${{ app_store_connect.beta_feedback.url }} --json \t # Look up whatever an EAS workflow trigger reported',
];
static override args = {
id: Args.string({
description:
'ID or App Store Connect API URL of a single submission to show. Accepts ${{ app_store_connect.beta_feedback.id }} or ${{ app_store_connect.beta_feedback.url }} from an EAS workflow trigger.',
required: false,
}),
};
static override flags = {
type: Flags.option({
description:
'Kind of feedback the ID refers to. Only needed when passing a bare ID for a crash; a URL already encodes it.',
options: ['crash', 'screenshot'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json used to resolve the bundle identifier and App Store Connect API key. Defaults to "production".',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: TESTFLIGHT_PAGE_SIZE, limit: 200 }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(TestFlightFeedback);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json, nonInteractive } = paginatedQueryOptions;
if (json) {
enableJsonOutput();
}
if (flags.type && !args.id) {
throw new Error('--type only applies when looking up a single submission by ID or URL.');
}
// Resolved before authenticating so a malformed ID or URL fails fast.
const target = args.id
? resolveBetaFeedbackTarget({ idOrUrl: args.id, type: flags.type, defaultType: 'screenshot' })
: null;
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(TestFlightFeedback, {
nonInteractive,
withServerSideEnvironment: null,
});
const app = await resolveTestFlightAppAsync({
actor,
analytics,
exp,
graphqlClient,
nonInteractive,
profileName: flags.profile,
projectDir,
projectId,
vcsClient,
});
if (target) {
await viewAndRenderBetaFeedbackAsync(app, target, { json });
return;
}
await listAndRenderTestFlightFeedbackAsync(app, paginatedQueryOptions);
}
}
+214 -279
View File
@@ -89,8 +89,6 @@ export type Account = {
appStoreConnectApiKeys: Array<AppStoreConnectApiKey>;
appStoreConnectApiKeysPaginated: AccountAppStoreConnectApiKeysConnection;
appleAppIdentifiers: Array<AppleAppIdentifier>;
/** @deprecated Use appleDevicesPaginated */
appleDevices: Array<AppleDevice>;
appleDevicesPaginated: AccountAppleDevicesConnection;
/** @deprecated Use appleDistributionCertificatesPaginated */
appleDistributionCertificates: Array<AppleDistributionCertificate>;
@@ -197,6 +195,8 @@ export type Account = {
users: Array<UserPermission>;
/** Vexo account connection for this account */
vexoAccountConnection?: Maybe<VexoAccountConnection>;
/** Most recently active first */
viewerDashboardChats: DashboardChatConnection;
/** Notification preferences of the viewer for this account */
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
/** Permission info for the viewer on this account */
@@ -245,17 +245,6 @@ export type AccountAppleAppIdentifiersArgs = {
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
*/
export type AccountAppleDevicesArgs = {
identifier?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
@@ -492,6 +481,18 @@ export type AccountTimelineActivityArgs = {
last?: InputMaybe<Scalars['Int']['input']>;
};
/**
* An account is a container owning projects, credentials, billing and other organization
* data and settings. Actors may own and be members of accounts.
*/
export type AccountViewerDashboardChatsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
export type AccountAppStoreConnectApiKeysConnection = {
__typename?: 'AccountAppStoreConnectApiKeysConnection';
edges: Array<AccountAppStoreConnectApiKeysEdge>;
@@ -1387,8 +1388,6 @@ export type AndroidSubmissionConfig = {
__typename?: 'AndroidSubmissionConfig';
/** @deprecated applicationIdentifier is deprecated and will be auto-detected on submit */
applicationIdentifier?: Maybe<Scalars['String']['output']>;
/** @deprecated archiveType is deprecated and will be null */
archiveType?: Maybe<SubmissionAndroidArchiveType>;
releaseStatus?: Maybe<SubmissionAndroidReleaseStatus>;
rollout?: Maybe<Scalars['Float']['output']>;
track: Scalars['String']['output'];
@@ -1416,11 +1415,6 @@ export type App = Project & {
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
/** Connection status for this project's App Store Connect-triggered workflows. */
appStoreConnectWorkflowConnectionStatus: AppStoreConnectWorkflowConnectionStatus;
/**
* ios.appStoreUrl field from most recent classic update manifest
* @deprecated Classic updates have been deprecated.
*/
appStoreUrl?: Maybe<Scalars['String']['output']>;
assetLimitPerUpdateGroup: Scalars['Int']['output'];
branchesPaginated: AppBranchesConnection;
buildProfiles: Array<Scalars['String']['output']>;
@@ -1450,11 +1444,6 @@ export type App = Project & {
githubJobRunTriggers: Array<GitHubJobRunTrigger>;
githubRepository?: Maybe<GitHubRepository>;
githubRepositorySettings?: Maybe<GitHubRepositorySettings>;
/**
* githubUrl field from most recent classic update manifest
* @deprecated Classic updates have been deprecated.
*/
githubUrl?: Maybe<Scalars['String']['output']>;
/**
* Info about the icon specified in the most recent classic update manifest
* @deprecated Classic updates have been deprecated.
@@ -1468,35 +1457,19 @@ export type App = Project & {
internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy;
/** iOS app credentials for the project */
iosAppCredentials: Array<IosAppCredentials>;
/**
* Whether the latest classic update publish is using a deprecated SDK version
* @deprecated Classic updates have been deprecated.
*/
isDeprecated: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
/** @deprecated No longer supported */
lastPublishedTime: Scalars['DateTime']['output'];
/** Time of the last user activity (update, branch, submission). */
latestActivity: Scalars['DateTime']['output'];
latestAppVersionByPlatformAndApplicationIdentifier?: Maybe<AppVersion>;
/** @deprecated Classic updates have been deprecated. */
latestReleaseForReleaseChannel?: Maybe<AppRelease>;
logRocketProject?: Maybe<LogRocketProject>;
name: Scalars['String']['output'];
observe: AppObserve;
ownerAccount: Account;
/** @deprecated No longer supported */
packageName: Scalars['String']['output'];
/**
* android.playStoreUrl field from most recent classic update manifest
* @deprecated Classic updates have been deprecated.
*/
playStoreUrl?: Maybe<Scalars['String']['output']>;
posthogProject?: Maybe<PostHogProject>;
/** @deprecated No longer supported */
privacy: Scalars['String']['output'];
/** @deprecated No longer supported */
privacySetting: AppPrivacy;
profileImageUrl?: Maybe<Scalars['String']['output']>;
/**
* Whether there have been any classic update publishes
@@ -1506,8 +1479,6 @@ export type App = Project & {
/** App query field for querying details about an app's push notifications */
pushNotifications: AppPushNotifications;
pushSecurityEnabled: Scalars['Boolean']['output'];
/** @deprecated Legacy access tokens are deprecated */
requiresAccessTokenForPushSecurity: Scalars['Boolean']['output'];
resourceClassExperiment?: Maybe<ResourceClassExperiment>;
/** Runtimes associated with this app */
runtimes: RuntimesConnection;
@@ -1712,13 +1683,6 @@ export type AppLatestAppVersionByPlatformAndApplicationIdentifierArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppLatestReleaseForReleaseChannelArgs = {
platform: AppPlatform;
releaseChannel: Scalars['String']['input'];
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppProfileImageUrlArgs = {
width?: InputMaybe<AppProfileImageWidth>;
@@ -2400,6 +2364,8 @@ export type AppObserveCustomEvent = {
deviceModel: Scalars['String']['output'];
deviceOs: Scalars['String']['output'];
deviceOsVersion: Scalars['String']['output'];
/** Human-friendly label from the expo.log.display_name attribute; null when the event was logged without one. */
displayName?: Maybe<Scalars['String']['output']>;
easClientId: Scalars['String']['output'];
environment?: Maybe<Scalars['String']['output']>;
errorFingerprint?: Maybe<Scalars['String']['output']>;
@@ -2816,7 +2782,7 @@ export type AppObserveNavigationRoutesFilter = {
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform: AppObservePlatform;
platform?: InputMaybe<AppObservePlatform>;
routeNames?: InputMaybe<Array<Scalars['String']['input']>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2860,7 +2826,7 @@ export type AppObserveReleasesInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
platform: AppObservePlatform;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
@@ -2897,7 +2863,7 @@ export type AppObserveTimeSeriesInput = {
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
metricName: Scalars['String']['input'];
platform: AppObservePlatform;
platform?: InputMaybe<AppObservePlatform>;
routeName?: InputMaybe<Scalars['String']['input']>;
startTime: Scalars['DateTime']['input'];
};
@@ -2948,7 +2914,7 @@ export type AppObserveUpdatesInput = {
environment?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveUpdatesOrderBy>;
platform: AppObservePlatform;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
@@ -3036,11 +3002,6 @@ export type AppPushNotificationsInsightsTotalNotificationsSentArgs = {
export type AppQuery = {
__typename?: 'AppQuery';
/**
* Public apps in the app directory
* @deprecated App directory no longer supported
*/
all: Array<App>;
/** Look up app by dev domain name, if one has been created */
byDevDomainName: App;
byFullName: App;
@@ -3049,14 +3010,6 @@ export type AppQuery = {
};
export type AppQueryAllArgs = {
filter: AppsFilter;
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
sort: AppSort;
};
export type AppQueryByDevDomainNameArgs = {
name: Scalars['DevDomainName']['input'];
};
@@ -3071,27 +3024,6 @@ export type AppQueryByIdArgs = {
appId: Scalars['String']['input'];
};
export type AppRelease = {
__typename?: 'AppRelease';
hash: Scalars['String']['output'];
id: Scalars['ID']['output'];
manifest: Scalars['JSON']['output'];
publishedTime: Scalars['DateTime']['output'];
publishingUsername: Scalars['String']['output'];
runtimeVersion?: Maybe<Scalars['String']['output']>;
s3Key: Scalars['String']['output'];
s3Url: Scalars['String']['output'];
sdkVersion: Scalars['String']['output'];
version: Scalars['String']['output'];
};
export enum AppSort {
/** Sort by recently published */
RecentlyPublished = 'RECENTLY_PUBLISHED',
/** Sort by highest trendScore */
Viewed = 'VIEWED'
}
export type AppStoreConnectApiKey = {
__typename?: 'AppStoreConnectApiKey';
account: Account;
@@ -3406,6 +3338,7 @@ export type AppWorkflowRunEdge = {
export type AppWorkflowRunFilterInput = {
requestedGitRef?: InputMaybe<Scalars['String']['input']>;
searchTerm?: InputMaybe<Scalars['String']['input']>;
status?: InputMaybe<WorkflowRunStatus>;
timeRange?: InputMaybe<WorkflowRunTimeRangeInput>;
workflowId?: InputMaybe<Scalars['ID']['input']>;
@@ -3861,13 +3794,6 @@ export type AppleTeamUpdateInput = {
appleTeamType?: InputMaybe<AppleTeamType>;
};
export enum AppsFilter {
/** Featured Projects */
Featured = 'FEATURED',
/** New Projects */
New = 'NEW'
}
export type ArgentRunSessionRemoteConfig = {
__typename?: 'ArgentRunSessionRemoteConfig';
toolsAuthToken?: Maybe<Scalars['String']['output']>;
@@ -4199,8 +4125,6 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
gitCommitHash?: Maybe<Scalars['String']['output']>;
gitCommitMessage?: Maybe<Scalars['String']['output']>;
gitRef?: Maybe<Scalars['String']['output']>;
/** @deprecated Use 'githubRepository' field instead */
githubRepositoryOwnerAndName?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Queue position is 1-indexed */
initialQueuePosition?: Maybe<Scalars['Int']['output']>;
@@ -4209,6 +4133,8 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
isForIosSimulator: Scalars['Boolean']['output'];
isGitWorkingTreeDirty?: Maybe<Scalars['Boolean']['output']>;
isWaived: Scalars['Boolean']['output'];
logFileUrls: Array<Scalars['String']['output']>;
/** @deprecated Use logFileUrls instead */
logFiles: Array<Scalars['String']['output']>;
maxBuildTimeSeconds: Scalars['Int']['output'];
/** Retry time starts after completedAt */
@@ -4350,8 +4276,6 @@ export type BuildArtifacts = {
applicationArchiveUrl?: Maybe<Scalars['String']['output']>;
buildArtifactsUrl?: Maybe<Scalars['String']['output']>;
buildUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use 'runtime.fingerprint.debugInfoUrl' instead. */
fingerprintUrl?: Maybe<Scalars['String']['output']>;
xcodeBuildLogsUrl?: Maybe<Scalars['String']['output']>;
};
@@ -4610,7 +4534,8 @@ export enum BuildPhase {
UploadApplicationArchive = 'UPLOAD_APPLICATION_ARCHIVE',
/** @deprecated No longer supported */
UploadArtifacts = 'UPLOAD_ARTIFACTS',
UploadBuildArtifacts = 'UPLOAD_BUILD_ARTIFACTS'
UploadBuildArtifacts = 'UPLOAD_BUILD_ARTIFACTS',
UploadEmbeddedBundle = 'UPLOAD_EMBEDDED_BUNDLE'
}
export type BuildPlanCreditThresholdExceededMetadata = {
@@ -4995,6 +4920,11 @@ export type CreateDeviceRunSessionArtifactUploadSessionResult = {
uploadSession: DeviceRunSessionArtifactUploadSession;
};
export type CreateDeviceRunSessionEventLogUploadSessionResult = {
__typename?: 'CreateDeviceRunSessionEventLogUploadSessionResult';
uploadSession: DeviceRunSessionEventLogUploadSession;
};
export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
@@ -5316,6 +5246,135 @@ export enum CustomDomainStatus {
TimedOut = 'TIMED_OUT'
}
/** A dashboard AI chat conversation, private to the user who created it. */
export type DashboardChat = {
__typename?: 'DashboardChat';
account: Account;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
lastMessageAt?: Maybe<Scalars['DateTime']['output']>;
/** Most recent first */
messages: DashboardChatMessageConnection;
title?: Maybe<Scalars['String']['output']>;
};
/** A dashboard AI chat conversation, private to the user who created it. */
export type DashboardChatMessagesArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
export type DashboardChatConnection = {
__typename?: 'DashboardChatConnection';
edges: Array<DashboardChatEdge>;
pageInfo: PageInfo;
};
export type DashboardChatEdge = {
__typename?: 'DashboardChatEdge';
cursor: Scalars['String']['output'];
node: DashboardChat;
};
export type DashboardChatMessage = {
__typename?: 'DashboardChatMessage';
/** Client-supplied; unique per chat, not globally */
clientMessageId: Scalars['String']['output'];
id: Scalars['ID']['output'];
/** UIMessage parts, stored verbatim, ordered by index */
parts: Array<Scalars['JSONObject']['output']>;
role: DashboardChatMessageRole;
status?: Maybe<DashboardChatMessageStatus>;
};
export type DashboardChatMessageConnection = {
__typename?: 'DashboardChatMessageConnection';
edges: Array<DashboardChatMessageEdge>;
pageInfo: PageInfo;
};
export type DashboardChatMessageEdge = {
__typename?: 'DashboardChatMessageEdge';
cursor: Scalars['String']['output'];
node: DashboardChatMessage;
};
export enum DashboardChatMessageRole {
Assistant = 'ASSISTANT',
User = 'USER'
}
/** Terminal state of an assistant message */
export enum DashboardChatMessageStatus {
Canceled = 'CANCELED',
Completed = 'COMPLETED',
Error = 'ERROR'
}
export type DashboardChatMutation = {
__typename?: 'DashboardChatMutation';
/** Persist a user message. Idempotent on clientMessageId. */
addUserMessage: DashboardChatMessage;
/** Create a new chat owned by the viewer */
createChat: DashboardChat;
/** Delete a chat and all of its messages */
deleteChat: DashboardChat;
/** Rename a chat */
renameChat: DashboardChat;
/**
* Persist an assistant message, replacing parts when a message with the
* same clientMessageId already exists.
*/
upsertAssistantMessage: DashboardChatMessage;
};
export type DashboardChatMutationAddUserMessageArgs = {
accountID: Scalars['ID']['input'];
chatID: Scalars['ID']['input'];
clientMessageId: Scalars['String']['input'];
parts: Array<Scalars['JSONObject']['input']>;
};
export type DashboardChatMutationCreateChatArgs = {
accountID: Scalars['ID']['input'];
};
export type DashboardChatMutationDeleteChatArgs = {
chatID: Scalars['ID']['input'];
};
export type DashboardChatMutationRenameChatArgs = {
chatID: Scalars['ID']['input'];
title: Scalars['String']['input'];
};
export type DashboardChatMutationUpsertAssistantMessageArgs = {
accountID: Scalars['ID']['input'];
chatID: Scalars['ID']['input'];
clientMessageId: Scalars['String']['input'];
parts: Array<Scalars['JSONObject']['input']>;
status: DashboardChatMessageStatus;
};
export type DashboardChatQuery = {
__typename?: 'DashboardChatQuery';
/** Get dashboard chat by ID - entry point to the graph */
byId?: Maybe<DashboardChat>;
};
export type DashboardChatQueryByIdArgs = {
id: Scalars['ID']['input'];
};
export enum DashboardViewPin {
Activity = 'ACTIVITY',
Overview = 'OVERVIEW'
@@ -5469,11 +5528,6 @@ export type DeleteUpdateChannelResult = {
id: Scalars['ID']['output'];
};
export type DeleteUpdateGroupResult = {
__typename?: 'DeleteUpdateGroupResult';
group: Scalars['ID']['output'];
};
export type DeleteWebhookResult = {
__typename?: 'DeleteWebhookResult';
id: Scalars['ID']['output'];
@@ -5679,6 +5733,12 @@ export type DeviceRunSessionArtifactUploadSession = {
url: Scalars['String']['output'];
};
export type DeviceRunSessionEventLogUploadSession = {
__typename?: 'DeviceRunSessionEventLogUploadSession';
headers: Scalars['JSONObject']['output'];
url: Scalars['String']['output'];
};
export type DeviceRunSessionFilterInput = {
platforms?: InputMaybe<Array<AppPlatform>>;
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
@@ -5691,6 +5751,11 @@ export type DeviceRunSessionMutation = {
createArtifactUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
/** Create a device run session */
createDeviceRunSession: DeviceRunSession;
/**
* Create a standard artifact and a two-hour upload URL for repeatedly
* overwriting its structured event log while the backing job run is running.
*/
createEventLogUploadSession: CreateDeviceRunSessionEventLogUploadSessionResult;
/**
* Ensure a device run session is stopped. Idempotent: if the session has already
* finished, the existing session is returned unchanged (an ERRORED session stays
@@ -5713,6 +5778,11 @@ export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
};
export type DeviceRunSessionMutationCreateEventLogUploadSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionMutationEnsureDeviceRunSessionStoppedArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
@@ -8249,12 +8319,6 @@ export type MeMutation = {
scheduleCurrentUserDeletion: BackgroundJobReceipt;
/** Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. */
scheduleSSOUserDeletionAsSSOAccountOwner: BackgroundJobReceipt;
/**
* Legacy user preferences are no longer stored; this mutation accepts and discards
* its input. Use userPreference.set instead.
* @deprecated No longer stored; this mutation has no effect. Use userPreference.set instead.
*/
setPreferences: UserPreferences;
/** Set the user's primary second factor device */
setPrimarySecondFactorDevice: SecondFactorBooleanResult;
/** Transfer project to a different Account */
@@ -8332,11 +8396,6 @@ export type MeMutationScheduleSsoUserDeletionAsSsoAccountOwnerArgs = {
};
export type MeMutationSetPreferencesArgs = {
preferences: UserPreferencesInput;
};
export type MeMutationSetPrimarySecondFactorDeviceArgs = {
userSecondFactorDeviceId: Scalars['ID']['input'];
};
@@ -8736,20 +8795,6 @@ export type ProjectPublicData = {
id: Scalars['ID']['output'];
};
export type ProjectQuery = {
__typename?: 'ProjectQuery';
/** @deprecated See byAccountNameAndSlug */
byUsernameAndSlug: Project;
};
export type ProjectQueryByUsernameAndSlugArgs = {
platform?: InputMaybe<Scalars['String']['input']>;
sdkVersions?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
slug: Scalars['String']['input'];
username: Scalars['String']['input'];
};
export type PublicArtifacts = {
__typename?: 'PublicArtifacts';
applicationArchiveUrl?: Maybe<Scalars['String']['output']>;
@@ -8989,6 +9034,8 @@ export type RootMutation = {
convexProject: ConvexProjectMutation;
convexTeamConnection: ConvexTeamConnectionMutation;
customDomain: CustomDomainMutation;
/** Mutations for dashboard chats */
dashboardChat: DashboardChatMutation;
deployments: DeploymentsMutation;
/** Mutations that assign or modify DevDomainNames for apps */
devDomainName: AppDevDomainNameMutation;
@@ -9119,11 +9166,6 @@ export type RootQuery = {
account: AccountQuery;
/** Top-level query object for querying AccountSSOConfigurationPublicData */
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
/**
* Public apps in the app directory
* @deprecated Use 'all' field under 'app'.
*/
allPublicApps?: Maybe<Array<Maybe<App>>>;
app: AppQuery;
/**
* Look up app by app id
@@ -9157,6 +9199,8 @@ export type RootQuery = {
channels: ChannelQuery;
/** Top-level query object for querying Convex Integration information. */
convexIntegration: ConvexIntegrationQuery;
/** Top-level query object for querying dashboard chats. */
dashboardChat: DashboardChatQuery;
/** Top-level query object for querying Deployments. */
deployments: DeploymentQuery;
deviceRunSessionArtifacts: DeviceRunSessionArtifactQuery;
@@ -9199,8 +9243,6 @@ export type RootQuery = {
meUserActor?: Maybe<UserActor>;
/** Top-level query object for querying PostHog Integration information. */
posthogIntegration: PostHogIntegrationQuery;
/** @deprecated Snacks and apps should be queried separately */
project: ProjectQuery;
/** Top-level query object for querying Runtimes. */
runtimes: RuntimeQuery;
snack: SnackQuery;
@@ -9212,19 +9254,10 @@ export type RootQuery = {
updates: UpdateQuery;
/** fetch all updates in a group */
updatesByGroup: Array<Update>;
/**
* Top-level query object for querying Users.
* @deprecated Public user queries are no longer supported
*/
user: UserQuery;
/** Top-level query object for querying UserActorPublicData publicly. */
userActorPublicData: UserActorPublicDataQuery;
/** Top-level query object for querying User Audit Logs. */
userAuditLogs: UserAuditLogQuery;
/** @deprecated Use 'byId' field under 'user'. */
userByUserId?: Maybe<User>;
/** @deprecated Use 'byUsername' field under 'user'. */
userByUsername?: Maybe<User>;
/** Top-level query object for querying UserInvitationPublicData publicly. */
userInvitationPublicData: UserInvitationPublicDataQuery;
/** Query interface for user preferences */
@@ -9248,14 +9281,6 @@ export type RootQuery = {
};
export type RootQueryAllPublicAppsArgs = {
filter: AppsFilter;
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
sort: AppSort;
};
export type RootQueryAppByAppIdArgs = {
appId: Scalars['String']['input'];
};
@@ -9266,16 +9291,6 @@ export type RootQueryUpdatesByGroupArgs = {
platform?: InputMaybe<Scalars['String']['input']>;
};
export type RootQueryUserByUserIdArgs = {
userId: Scalars['String']['input'];
};
export type RootQueryUserByUsernameArgs = {
username: Scalars['String']['input'];
};
export type Runtime = {
__typename?: 'Runtime';
app: App;
@@ -9394,11 +9409,6 @@ export type SsoUser = Actor & UserActor & {
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/**
* Apps this user has published. If this user is the viewer, this field returns the apps the user has access to.
* @deprecated Use Account.appsPaginated instead
*/
apps: Array<App>;
bestContactEmail?: Maybe<Scalars['String']['output']>;
created: Scalars['DateTime']['output'];
/** Discord account linked to a user */
@@ -9444,14 +9454,6 @@ export type SsoUserActivityTimelineProjectActivitiesArgs = {
};
/** Represents a human SSO (not robot) actor. */
export type SsoUserAppsArgs = {
includeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;
limit: Scalars['Int']['input'];
offset: Scalars['Int']['input'];
};
/** Represents a human SSO (not robot) actor. */
export type SsoUserFeatureGatesArgs = {
filter?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -9658,11 +9660,6 @@ export type SnackQuery = {
__typename?: 'SnackQuery';
/** Get snack by hashId */
byHashId: Snack;
/**
* Get snack by hashId
* @deprecated Use byHashId
*/
byId: Snack;
};
@@ -9670,11 +9667,6 @@ export type SnackQueryByHashIdArgs = {
hashId: Scalars['ID']['input'];
};
export type SnackQueryByIdArgs = {
id: Scalars['ID']['input'];
};
export enum StandardOffer {
/** $29 USD per month, 30 day trial */
Default = 'DEFAULT',
@@ -9833,11 +9825,6 @@ export type Submission = ActivityTimelineProjectActivity & {
workflowJob?: Maybe<WorkflowJob>;
};
export enum SubmissionAndroidArchiveType {
Aab = 'AAB',
Apk = 'APK'
}
export enum SubmissionAndroidReleaseStatus {
Completed = 'COMPLETED',
Draft = 'DRAFT',
@@ -10519,11 +10506,6 @@ export type UpdateInsightsTotalUniqueUsersArgs = {
export type UpdateMutation = {
__typename?: 'UpdateMutation';
/**
* Delete an EAS update group
* @deprecated Use scheduleUpdateGroupDeletion instead
*/
deleteUpdateGroup: DeleteUpdateGroupResult;
/** Delete an EAS update group in the background */
scheduleUpdateGroupDeletion: BackgroundJobReceipt;
/** Set code signing info for an update */
@@ -10533,11 +10515,6 @@ export type UpdateMutation = {
};
export type UpdateMutationDeleteUpdateGroupArgs = {
group: Scalars['ID']['input'];
};
export type UpdateMutationScheduleUpdateGroupDeletionArgs = {
group: Scalars['ID']['input'];
};
@@ -10624,8 +10601,9 @@ export type UpdatesTimelineFilter = {
platform?: InputMaybe<AppPlatform>;
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Case-insensitive substring match on update group message and branch name.
* Embedded updates are matched on channel and runtime version.
* Case-insensitive substring match on update group message, branch name, and runtime version,
* plus prefix match on update ID, update group ID, and git commit hash.
* Embedded updates are matched on channel, runtime version, and ID prefix.
*/
searchTerm?: InputMaybe<Scalars['String']['input']>;
types?: InputMaybe<Array<UpdatesTimelineItemType>>;
@@ -10739,11 +10717,6 @@ export type User = Actor & UserActor & {
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/**
* Apps this user has published
* @deprecated Use Account.appsPaginated instead
*/
apps: Array<App>;
bestContactEmail?: Maybe<Scalars['String']['output']>;
created: Scalars['DateTime']['output'];
/** Discord account linked to a user */
@@ -10767,8 +10740,6 @@ export type User = Actor & UserActor & {
hasPendingUserInvitations: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
isExpoAdmin: Scalars['Boolean']['output'];
/** @deprecated No longer supported */
isLegacy: Scalars['Boolean']['output'];
isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
@@ -10805,14 +10776,6 @@ export type UserActivityTimelineProjectActivitiesArgs = {
};
/** Represents a human (not robot) actor. */
export type UserAppsArgs = {
includeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;
limit: Scalars['Int']['input'];
offset: Scalars['Int']['input'];
};
/** Represents a human (not robot) actor. */
export type UserFeatureGatesArgs = {
filter?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -10845,11 +10808,6 @@ export type UserActor = {
*/
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/**
* Apps this user has published
* @deprecated Use Account.appsPaginated instead
*/
apps: Array<App>;
bestContactEmail?: Maybe<Scalars['String']['output']>;
created: Scalars['DateTime']['output'];
/** Discord account linked to a user */
@@ -10898,14 +10856,6 @@ export type UserActorActivityTimelineProjectActivitiesArgs = {
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorAppsArgs = {
includeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;
limit: Scalars['Int']['input'];
offset: Scalars['Int']['input'];
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorFeatureGatesArgs = {
filter?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -10934,8 +10884,6 @@ export type UserActorPublicData = {
id: Scalars['ID']['output'];
lastName?: Maybe<Scalars['String']['output']>;
primaryAccountProfileImageUrl: Scalars['String']['output'];
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
username: Scalars['String']['output'];
@@ -11268,8 +11216,6 @@ export type UserPermission = {
id: Scalars['ID']['output'];
permissions: Array<Permission>;
role: Role;
/** @deprecated User type is deprecated */
user?: Maybe<User>;
userActor?: Maybe<UserActor>;
};
@@ -11324,10 +11270,6 @@ export type UserPreferences = {
onboarding?: Maybe<UserPreferencesOnboarding>;
};
export type UserPreferencesInput = {
onboarding?: InputMaybe<UserPreferencesOnboardingInput>;
};
/**
* Set by website. Used by CLI to continue onboarding process on user's machine - clone repository,
* install dependencies etc.
@@ -11342,29 +11284,6 @@ export type UserPreferencesOnboarding = {
platform?: Maybe<AppPlatform>;
};
export type UserPreferencesOnboardingInput = {
appId: Scalars['ID']['input'];
deviceType?: InputMaybe<OnboardingDeviceType>;
environment?: InputMaybe<OnboardingEnvironment>;
isCLIDone?: InputMaybe<Scalars['Boolean']['input']>;
lastUsed: Scalars['String']['input'];
platform?: InputMaybe<AppPlatform>;
};
export type UserQuery = {
__typename?: 'UserQuery';
/**
* Query a User by username
* @deprecated Public user queries are no longer supported
*/
byUsername: User;
};
export type UserQueryByUsernameArgs = {
username: Scalars['String']['input'];
};
/** A second factor device belonging to a User */
export type UserSecondFactorDevice = {
__typename?: 'UserSecondFactorDevice';
@@ -12445,13 +12364,10 @@ export type WorkflowJob = {
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
/**
* All test case attempt rows produced by this job's own execution (turtle job
* run), ordered by path then retryCount. Unlike deviceTestCaseResults and
* allDeviceTestCaseResults, this never includes rows from other jobs in the
* workflow retry chain.
* run), ordered by path then retryCount. Unlike allDeviceTestCaseResults,
* this never includes rows from other jobs in the workflow retry chain.
*/
deviceTestCaseResultAttempts: Array<WorkflowDeviceTestCaseResult>;
/** @deprecated Use deviceTestCaseResultAttempts instead */
deviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
environment?: Maybe<Scalars['String']['output']>;
errors: Array<WorkflowJobError>;
id: Scalars['ID']['output'];
@@ -12459,6 +12375,11 @@ export type WorkflowJob = {
name: Scalars['String']['output'];
outputs: Scalars['JSONObject']['output'];
requiredJobKeys: Array<Scalars['String']['output']>;
/**
* Updates in the group being rolled out by an UPDATE_ROLLOUT job, resolved from the job's
* update_group_id output. Empty for other job types or when the group has no updates.
*/
rolloutUpdateGroup: Array<Update>;
status: WorkflowJobStatus;
turtleBuild?: Maybe<Build>;
turtleJobRun?: Maybe<JobRun>;
@@ -12495,8 +12416,6 @@ export type WorkflowJobApproval = {
id: Scalars['ID']['output'];
reviewingActor?: Maybe<Actor>;
updatedAt: Scalars['DateTime']['output'];
/** @deprecated Use reviewingActor instead */
userActor?: Maybe<UserActor>;
workflowJob: WorkflowJob;
};
@@ -12560,7 +12479,8 @@ export enum WorkflowJobType {
Slack = 'SLACK',
Submission = 'SUBMISSION',
Testflight = 'TESTFLIGHT',
Update = 'UPDATE'
Update = 'UPDATE',
UpdateRollout = 'UPDATE_ROLLOUT'
}
export type WorkflowProjectSourceInput = {
@@ -14050,6 +13970,14 @@ export type CreateAccountScopedUploadSessionMutationVariables = Exact<{
export type CreateAccountScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAccountScopedUploadSession: any } };
export type CreateAppScopedUploadSessionMutationVariables = Exact<{
appID: Scalars['ID']['input'];
type: AppUploadSessionType;
}>;
export type CreateAppScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAppScopedUploadSession: any } };
export type CreateWebhookMutationVariables = Exact<{
appId: Scalars['String']['input'];
webhookInput: WebhookInput;
@@ -14157,14 +14085,6 @@ export type AccountBillingPeriodQueryVariables = Exact<{
export type AccountBillingPeriodQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any } } } };
export type SimulatorAvailabilityQueryVariables = Exact<{
appId: Scalars['String']['input'];
filter?: InputMaybe<Array<Scalars['String']['input']> | Scalars['String']['input']>;
}>;
export type SimulatorAvailabilityQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, name: string, accountFeatureGates: any } } } };
export type AppByIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -14195,6 +14115,13 @@ export type AppByFullNameQuery = { __typename?: 'RootQuery', app: { __typename?:
| { __typename?: 'User', id: string }
}> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', id: string, githubRepoOwnerName: string, githubRepoName: string } } | null } } };
export type AppByIdProfileImageUrlQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type AppByIdProfileImageUrlQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, profileImageUrl?: string | null } } };
export type AppByIdWorkflowsQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -14480,6 +14407,14 @@ export type ConvexProjectByAppIdQueryVariables = Exact<{
export type ConvexProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, convexProject?: { __typename?: 'ConvexProject', id: string, convexProjectIdentifier: string, convexProjectName: string, convexProjectSlug: string, createdAt: any, updatedAt: any, convexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, hasBeenClaimed: boolean, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } | null } } };
export type SimulatorAvailabilityQueryVariables = Exact<{
appId: Scalars['String']['input'];
filter?: InputMaybe<Array<Scalars['String']['input']> | Scalars['String']['input']>;
}>;
export type SimulatorAvailabilityQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, name: string, accountFeatureGates: any } } } };
export type DeviceRunSessionByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
@@ -4,8 +4,11 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import {
AccountUploadSessionType,
AppUploadSessionType,
CreateAccountScopedUploadSessionMutation,
CreateAccountScopedUploadSessionMutationVariables,
CreateAppScopedUploadSessionMutation,
CreateAppScopedUploadSessionMutationVariables,
CreateUploadSessionMutation,
CreateUploadSessionMutationVariables,
UploadSessionType,
@@ -77,4 +80,36 @@ export const UploadSessionMutation = {
);
return data.uploadSession.createAccountScopedUploadSession;
},
async createAppScopedUploadSessionAsync(
graphqlClient: ExpoGraphqlClient,
{
type,
appID,
}: {
type: AppUploadSessionType;
appID: string;
}
): Promise<SignedUrl> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
CreateAppScopedUploadSessionMutation,
CreateAppScopedUploadSessionMutationVariables
>(
gql`
mutation CreateAppScopedUploadSessionMutation($appID: ID!, $type: AppUploadSessionType!) {
uploadSession {
createAppScopedUploadSession(appID: $appID, type: $type)
}
}
`,
{
type,
appID,
}
)
.toPromise()
);
return data.uploadSession.createAppScopedUploadSession;
},
};
@@ -7,6 +7,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import {
AppByFullNameQuery,
AppByIdProfileImageUrlQuery,
AppByIdQuery,
AppByIdWorkflowRunsFilteredByStatusQuery,
AppByIdWorkflowsQuery,
@@ -72,6 +73,35 @@ export const AppQuery = {
assert(data.app, 'GraphQL: `app` not defined in server response');
return data.app.byFullName;
},
async byIdProfileImageUrlAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<string | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppByIdProfileImageUrlQuery>(
gql`
query AppByIdProfileImageUrlQuery($appId: String!) {
app {
byId(appId: $appId) {
id
profileImageUrl
}
}
}
`,
{ appId: projectId },
{
requestPolicy: 'network-only',
additionalTypenames: ['App'],
}
)
.toPromise()
);
assert(data.app, 'GraphQL: `app` not defined in server response');
return data.app.byId.profileImageUrl ?? null;
},
async byIdWorkflowsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
@@ -1,10 +1,20 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
AppPlatform,
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { EventsOrderPreset, fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
import {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../fetchEvents';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
jest.mock('../../graphql/queries/ObserveQuery');
@@ -274,3 +284,57 @@ describe(fetchObserveEventsAsync, () => {
expect(calledVars).not.toHaveProperty('after');
});
});
describe(fetchTotalEventCountAsync, () => {
const mockAppVersionsAsync = jest.mocked(ObserveQuery.appVersionsAsync);
const mockGraphqlClient = {} as any;
beforeEach(() => {
mockAppVersionsAsync.mockReset();
});
it('rethrows plan-gate errors instead of swallowing them as zero', async () => {
const gateError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'Subscription to EAS is required for this feature.',
null,
null,
null,
null,
null,
{
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}
),
],
});
mockAppVersionsAsync.mockRejectedValue(gateError);
await expect(
fetchTotalEventCountAsync(
mockGraphqlClient,
'project-123',
'expo.navigation.tti',
[AppPlatform.Ios, AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
)
).rejects.toBe(gateError);
});
it('swallows non-gate errors and counts them as zero', async () => {
mockAppVersionsAsync.mockRejectedValue(new Error('Network error'));
const total = await fetchTotalEventCountAsync(
mockGraphqlClient,
'project-123',
'expo.app_startup.tti',
[AppPlatform.Ios],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
expect(total).toBe(0);
});
});
@@ -1,7 +1,11 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { makeMetricsKey } from '../formatMetrics';
import { fetchObserveMetricsAsync } from '../fetchMetrics';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
jest.mock('../../graphql/queries/ObserveQuery');
jest.mock('../../log');
@@ -207,6 +211,36 @@ describe('fetchObserveMetricsAsync', () => {
expect(metricsMap.size).toBe(0);
});
it('rethrows plan-gate errors instead of swallowing them as a partial failure', async () => {
const gateError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'Subscription to EAS is required for this feature.',
null,
null,
null,
null,
null,
{
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}
),
],
});
mockAppVersionsAsync.mockRejectedValue(gateError);
await expect(
fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.navigation.tti'],
[AppPlatform.Ios, AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
)
).rejects.toBe(gateError);
});
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
mockAppVersionsAsync.mockResolvedValue([
makeAppVersion('3.0.0', [
@@ -1,3 +1,6 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import {
AppObserveEventsOrderByDirection,
AppObserveNavigationRoute,
@@ -7,6 +10,7 @@ import {
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
jest.mock('../../graphql/queries/ObserveQuery');
jest.mock('../../log');
@@ -187,4 +191,32 @@ describe('fetchObserveNavigationRoutesAsync', () => {
expect(routes[0].platform).toBe(AppPlatform.Ios);
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
});
it('rethrows plan-gate errors instead of swallowing them as a partial failure', async () => {
const gateError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'Subscription to EAS is required for this feature.',
null,
null,
null,
null,
null,
{
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}
),
],
});
mockNavigationRoutesAsync.mockRejectedValue(gateError);
await expect(
fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios, AppPlatform.Android],
limit: 50,
})
).rejects.toBe(gateError);
});
});
@@ -0,0 +1,69 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { EasCommandError } from '../../commandUtils/errors';
import {
EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
isObservePlanGateError,
withObservePlanGateHandlingAsync,
} from '../planGating';
function graphqlErrorWithCode(errorCode: string, message = 'server message'): CombinedError {
const graphQLError = new GraphQLError(message, null, null, null, null, null, { errorCode });
return new CombinedError({ graphQLErrors: [graphQLError] });
}
describe(withObservePlanGateHandlingAsync, () => {
it('returns the value when the operation succeeds', async () => {
await expect(withObservePlanGateHandlingAsync(async () => 42)).resolves.toBe(42);
});
it.each([
['plan-upgrade-required', EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE],
['free-tier', EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE],
])('surfaces the server %s message as an EasCommandError', async (_label, errorCode) => {
const serverMessage = `gate message for ${errorCode}`;
const promise = withObservePlanGateHandlingAsync(() => {
throw graphqlErrorWithCode(errorCode, serverMessage);
});
await expect(promise).rejects.toBeInstanceOf(EasCommandError);
await expect(promise).rejects.toThrow(serverMessage);
});
it('passes through GraphQL errors with a different code unchanged', async () => {
const original = graphqlErrorWithCode('SOME_OTHER_ERROR');
await expect(
withObservePlanGateHandlingAsync(() => {
throw original;
})
).rejects.toBe(original);
});
it('passes through non-GraphQL errors unchanged', async () => {
const original = new Error('network down');
await expect(
withObservePlanGateHandlingAsync(() => {
throw original;
})
).rejects.toBe(original);
});
});
describe(isObservePlanGateError, () => {
it.each([
EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
])('is true for %s', errorCode => {
expect(isObservePlanGateError(graphqlErrorWithCode(errorCode))).toBe(true);
});
it('is false for a different GraphQL error code', () => {
expect(isObservePlanGateError(graphqlErrorWithCode('SOME_OTHER_ERROR'))).toBe(false);
});
it('is false for a non-GraphQL error', () => {
expect(isObservePlanGateError(new Error('network down'))).toBe(false);
});
});
+7 -1
View File
@@ -10,6 +10,7 @@ import {
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import { isObservePlanGateError } from './planGating';
export enum EventsOrderPreset {
Slowest = 'SLOWEST',
@@ -112,7 +113,12 @@ export async function fetchTotalEventCountAsync(
const metric = v.metrics.find(m => m.metricName === metricName);
return sum + (metric?.eventCount ?? 0);
}, 0);
} catch {
} catch (error) {
// A plan gate is an account-wide rejection, not a per-platform failure —
// let it propagate so the command surfaces the upgrade prompt.
if (isObservePlanGateError(error)) {
throw error;
}
return 0;
}
});
@@ -10,6 +10,7 @@ import {
UpdateIdsMap,
makeMetricsKey,
} from './formatMetrics';
import { isObservePlanGateError } from './planGating';
import { appPlatformToObservePlatform } from './platforms';
export function validateDateFlag(value: string, flagName: string): void {
@@ -48,6 +49,11 @@ export async function fetchObserveMetricsAsync(
});
return { appPlatform, appVersions };
} catch (error: any) {
// A plan gate is an account-wide rejection, not a per-platform failure —
// let it propagate so the command surfaces the upgrade prompt.
if (isObservePlanGateError(error)) {
throw error;
}
Log.warn(`Failed to fetch observe data on ${observePlatform}: ${error.message}`);
return null;
}
@@ -9,6 +9,7 @@ import {
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import { isObservePlanGateError } from './planGating';
import { appPlatformToObservePlatform } from './platforms';
export interface NavigationRouteWithPlatform {
@@ -64,6 +65,11 @@ export async function fetchObserveNavigationRoutesAsync(
});
return { appPlatform, ...result };
} catch (error: any) {
// A plan gate is an account-wide rejection, not a per-platform failure —
// let it propagate so the command surfaces the upgrade prompt.
if (isObservePlanGateError(error)) {
throw error;
}
Log.warn(`Failed to fetch navigation routes on ${observePlatform}: ${error.message}`);
return null;
}
@@ -99,6 +99,26 @@ export interface FetchSessionEventsResult {
hasMoreLogEvents: boolean;
}
// A syntactically valid UUID that matches no real session, used only to trip
// the server-side session-timeline plan gate without fetching real data.
const SESSION_ACCESS_PROBE_ID = '00000000-0000-0000-0000-000000000000';
/**
* Issues a throwaway session-scoped query so the server-side session-timeline
* plan gate fires (or doesn't) up front. Blocked plans reject with the coded
* plan-gate error; allowed plans get an empty result that is discarded. Used to
* check access before the interactive session picker runs.
*/
export async function verifyObserveSessionAccessAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<void> {
await fetchObserveSessionEventsAsync(graphqlClient, appId, {
sessionId: SESSION_ACCESS_PROBE_ID,
limit: 1,
});
}
export async function fetchObserveSessionEventsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
@@ -0,0 +1,50 @@
import { EasCommandError } from '../commandUtils/errors';
import { GraphqlError } from '../graphql/client';
// Must match the server's Observe plan-gate error codes (`ExpoErrorCode`). Both
// signal "this Observe feature is not available on the account's current plan";
// they differ only in which plans are blocked, which is decided server-side.
export const EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE = 'EAS_OBSERVE_PLAN_UPGRADE_REQUIRED';
export const EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE =
'EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER';
const OBSERVE_PLAN_GATE_ERROR_CODES: ReadonlySet<string> = new Set([
EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
]);
function findObservePlanGateMessage(error: unknown): string | undefined {
if (!(error instanceof GraphqlError)) {
return undefined;
}
return error.graphQLErrors.find(e =>
OBSERVE_PLAN_GATE_ERROR_CODES.has(e?.extensions?.errorCode as string)
)?.message;
}
/**
* Whether an error is a server Observe plan-gate rejection. Useful where a
* plan-gate error must escape error handling that would otherwise swallow it
* (e.g. per-platform fetch loops that treat failures as empty results).
*/
export function isObservePlanGateError(error: unknown): boolean {
return findObservePlanGateMessage(error) !== undefined;
}
/**
* Runs an Observe GraphQL operation and, when the server rejects it because the
* feature is not included in the account's plan, rethrows the server's upgrade
* message (which links to the account's billing page) as an `EasCommandError`.
* Reusable across Observe commands.
*/
export async function withObservePlanGateHandlingAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
const planGateMessage = findObservePlanGateMessage(error);
if (planGateMessage !== undefined) {
throw new EasCommandError(planGateMessage);
}
throw error;
}
}
@@ -0,0 +1,114 @@
import { Role } from '../../graphql/generated';
import { Actor } from '../../user/User';
import {
getAccountChoices,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp,
} from '../accountSelection';
const userActor: Actor = {
__typename: 'User',
id: 'user-id',
email: '[email protected]',
username: 'jane',
primaryAccount: {
id: 'account-id-personal',
name: 'jane',
users: [{ role: Role.Owner, actor: { id: 'user-id' } }],
},
accounts: [
{
id: 'account-id-org',
name: 'acme',
ownerUserActor: null,
users: [{ role: Role.ViewOnly, actor: { id: 'user-id' } }],
},
{
id: 'account-id-personal',
name: 'jane',
ownerUserActor: { id: 'user-id' },
users: [{ role: Role.Owner, actor: { id: 'user-id' } }],
},
{
id: 'account-id-team',
name: 'bob',
ownerUserActor: { id: 'other-user-id' },
users: [{ role: Role.Admin, actor: { id: 'user-id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
} as any;
const robotActor: Actor = {
__typename: 'Robot',
id: 'robot-id',
accounts: [
{
id: 'account-id-1',
name: 'acme',
users: [{ role: Role.Admin, actor: { id: 'robot-id' } }],
},
{
id: 'account-id-2',
name: 'other-org',
users: [{ role: Role.ViewOnly, actor: { id: 'robot-id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
} as any;
describe(getAccountNamesWhereUserHasSufficientPermissionsToCreateApp, () => {
it('excludes accounts where the actor has the view-only role', () => {
expect(getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(userActor)).toEqual(
new Set(['jane', 'bob'])
);
});
});
describe(getAccountChoices, () => {
it('lists the personal account first and annotates account types for users', () => {
const choices = getAccountChoices(
userActor,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(userActor)
);
expect(choices.map(choice => choice.title)).toEqual(['jane', 'bob', 'acme']);
expect(choices[0].description).toEqual('(Personal)');
expect(choices[1].description).toEqual('(Team)');
expect(choices[2].description).toEqual('(Organization) (Viewer Role)');
});
it('annotates viewer role on the personal account when the user cannot create apps on it', () => {
const viewerOnlyActor = {
...userActor,
accounts: [
{
id: 'account-id-personal',
name: 'jane',
ownerUserActor: { id: 'user-id' },
users: [{ role: Role.ViewOnly, actor: { id: 'user-id' } }],
},
],
} as any as Actor;
const choices = getAccountChoices(
viewerOnlyActor,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(viewerOnlyActor)
);
expect(choices).toHaveLength(1);
expect(choices[0].description).toEqual('(Personal) (Viewer Role)');
});
it('lists accounts without personal/team/organization annotations for robots', () => {
const choices = getAccountChoices(
robotActor,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(robotActor)
);
expect(choices.map(choice => choice.title)).toEqual(['acme', 'other-org']);
expect(choices[0].description).toBeUndefined();
expect(choices[1].description).toEqual('(Viewer Role)');
});
});
@@ -15,7 +15,7 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
jester
)
).rejects.toThrow(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: jester\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
);
});
});
@@ -0,0 +1,76 @@
import { Role } from '../graphql/generated';
import { Choice } from '../prompts';
import { Actor } from '../user/User';
export function getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(
actor: Actor
): Set<string> {
return new Set(
actor.accounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
}
export function getAccountChoices(
actor: Actor,
namesWithSufficientPermissions: Set<string>
): Choice[] {
const allAccounts = actor.accounts;
const sortedAccounts =
actor.__typename === 'Robot'
? allAccounts
: [...allAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
if (actor.__typename !== 'Robot') {
const personalAccount = allAccounts?.find(account => account?.ownerUserActor?.id === actor.id);
const personalAccountChoice = personalAccount
? {
title: personalAccount.name,
value: personalAccount,
description: !namesWithSufficientPermissions.has(personalAccount.name)
? '(Personal) (Viewer Role)'
: '(Personal)',
}
: undefined;
const userAccounts = allAccounts
?.filter(account => account.ownerUserActor && account.name !== actor.username)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Team) (Viewer Role)'
: '(Team)',
}));
const organizationAccounts = allAccounts
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Organization) (Viewer Role)'
: '(Organization)',
}));
let choices: Choice[] = [];
if (personalAccountChoice) {
choices = [personalAccountChoice];
}
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
);
}
return sortedAccounts.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
}));
}
@@ -8,7 +8,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import { link } from '../log';
import { ora } from '../ora';
import { confirmAsync } from '../prompts';
import { Actor } from '../user/User';
import { Actor, getCreatableAccountNames } from '../user/User';
/**
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
@@ -30,18 +30,22 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
const { accountName, projectName } = projectInfo;
const projectFullName = `@${accountName}/${projectName}`;
if (options.nonInteractive) {
throw new Error(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPublishPermissions = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
if (options.nonInteractive) {
throw new Error(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(
', '
)}\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
);
}
const account = allAccounts.find(a => a.name === accountName);
if (!account) {
throw new Error(
@@ -2,25 +2,35 @@ import { ConfigError, ExpoConfig, getProjectConfigDescription } from '@expo/conf
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import {
getAccountChoices,
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp,
} from './accountSelection';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from './expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from './fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { getProjectDashboardUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { validSlugName, validateFullNameAndSlug } from '../commandUtils/projectNameValidation';
import { Role } from '../graphql/generated';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { link } from '../log';
import { ora } from '../ora';
import { Choice, confirmAsync, promptAsync } from '../prompts';
import { Actor } from '../user/User';
import { confirmAsync, promptAsync } from '../prompts';
import { Actor, getCreatableAccountNames } from '../user/User';
export type InitializeMethodOptions = {
force: boolean;
nonInteractive: boolean;
};
export type ProjectInitStatus = 'created' | 'linked' | 'already-linked';
export type ProjectInitResult = {
projectId: string;
status: ProjectInitStatus;
};
async function saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
@@ -82,7 +92,7 @@ export async function ensureOwnerSlugConsistencyAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
): Promise<{ owner: string; slug: string }> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
const correctOwner = appForProjectId.ownerAccount.name;
@@ -131,59 +141,60 @@ export async function ensureOwnerSlugConsistencyAsync(
} else if (!exp.slug) {
await modifyExpoConfigAsync(projectDir, { slug: correctSlug });
}
return { owner: correctOwner, slug: correctSlug };
}
async function setExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
): Promise<ProjectInitStatus> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (projectId === existingProjectId) {
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
return;
return 'already-linked';
}
if (!existingProjectId) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
return 'linked';
}
if (projectId !== existingProjectId) {
if (force) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
if (force) {
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
throw new Error('Aborting');
}
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return 'linked';
}
export async function initializeWithExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
await setExplicitIDAsync(projectId, projectDir, {
): Promise<ProjectInitStatus> {
return await setExplicitIDAsync(projectId, projectDir, {
force,
nonInteractive,
});
@@ -193,42 +204,70 @@ export async function initializeWithoutExplicitIDAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<string> {
{
force,
nonInteractive,
accountName: accountNameArgument,
}: InitializeMethodOptions & { accountName?: string }
): Promise<ProjectInitResult> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (existingProjectId) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return existingProjectId;
const ownerAccountName = accountNameArgument
? (await AppQuery.byIdAsync(graphqlClient, existingProjectId)).ownerAccount.name
: undefined;
if (!accountNameArgument || ownerAccountName === accountNameArgument) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
);
return { projectId: existingProjectId, status: 'already-linked' };
}
if (!force) {
throw new Error(
`This project is already linked to @${ownerAccountName} (ID: ${existingProjectId}). Pass --force to re-link it to a project owned by ${accountNameArgument}, or remove the "extra.eas.projectId" field from your app config.`
);
}
// --force with a different --account: fall through to re-link under the new account
}
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
const accountNamesWhereUserHasSufficientPermissionsToCreateApp =
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(actor);
// if no owner field, ask the user which account they want to use to create/link the project
let accountName = exp.owner;
if (accountNameArgument) {
// with --force, a conflicting "owner" field is rewritten after linking
if (exp.owner && exp.owner !== accountNameArgument && !force) {
throw new Error(
`The account specified with --account (${accountNameArgument}) does not match the "owner" field in your app config (${exp.owner}). Provide a matching --account or update the "owner" field.`
);
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountNameArgument)) {
throw new Error(
`You are not able to create projects in the "${accountNameArgument}" account. Accounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(', ')}`
);
}
}
// if no --account flag or owner field, ask the user which account they want to use to create/link the project
let accountName = accountNameArgument ?? exp.owner;
if (!accountName) {
if (allAccounts.length === 1) {
accountName = allAccounts[0].name;
} else if (nonInteractive) {
if (!force) {
throw new Error(
`There are multiple accounts that you have access to: ${allAccounts
.map(a => a.name)
.join(
', '
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
`You have access to multiple accounts. Choose the account that should own this project with the --account flag:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(
', '
)}\n\nAlternatively, set the "owner" field in your app config. (Deprecated: --force without --account will proceed with the default account ${
allAccounts[0].name
}.`
}.)`
);
}
accountName = allAccounts[0].name;
@@ -257,13 +296,18 @@ export async function initializeWithoutExplicitIDAsync(
const projectName = validSlugName(exp.slug); // This filters out invalid characters
const projectFullName = `@${accountName}/${projectName}`;
validateFullNameAndSlug(projectFullName, projectName);
// An explicit --account fully specifies the intended project (@account/slug), so linking or
// creating it requires no confirmation in non-interactive mode.
const skipConfirmation = force || (nonInteractive && accountNameArgument !== undefined);
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
if (existingProjectIdOnServer) {
if (!force) {
if (!skipConfirmation) {
if (nonInteractive) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
@@ -281,7 +325,7 @@ export async function initializeWithoutExplicitIDAsync(
}
await saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
return existingProjectIdOnServer;
return { projectId: existingProjectIdOnServer, status: 'linked' };
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
@@ -290,7 +334,7 @@ export async function initializeWithoutExplicitIDAsync(
);
}
if (!force) {
if (!skipConfirmation) {
if (nonInteractive) {
throw new Error(
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
@@ -323,65 +367,5 @@ export async function initializeWithoutExplicitIDAsync(
}
await saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
return createdProjectId;
}
function getAccountChoices(actor: Actor, namesWithSufficientPermissions: Set<string>): Choice[] {
const allAccounts = actor.accounts;
const sortedAccounts =
actor.__typename === 'Robot'
? allAccounts
: [...allAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
if (actor.__typename !== 'Robot') {
const personalAccount = allAccounts?.find(account => account?.ownerUserActor?.id === actor.id);
const personalAccountChoice = personalAccount
? {
title: personalAccount.name,
value: personalAccount,
description: !namesWithSufficientPermissions.has(personalAccount.name)
? '(Personal) (Viewer Role)'
: '(Personal)',
}
: undefined;
const userAccounts = allAccounts
?.filter(account => account.ownerUserActor && account.name !== actor.username)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Team) (Viewer Role)'
: '(Team)',
}));
const organizationAccounts = allAccounts
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name)
? '(Organization) (Viewer Role)'
: '(Organization)',
}));
let choices: Choice[] = [];
if (personalAccountChoice) {
choices = [personalAccountChoice];
}
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
);
}
return sortedAccounts.map(account => ({
title: account.name,
value: account,
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
}));
return { projectId: createdProjectId, status: 'created' };
}
@@ -0,0 +1,78 @@
import { App } from '@expo/apple-utils';
import { EasJsonUtils } from '@expo/eas-json';
import { resolveTestFlightAppAsync } from '../app';
import { CredentialsContext } from '../../credentials/context';
import { AppStoreConnectApiKeyQuery as AccountAppStoreConnectApiKeyQuery } from '../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { getRequestContext } from '../../credentials/ios/appstore/authenticate';
import { getAppStoreAuthAsync } from '../../metadata/auth';
jest.mock('@expo/apple-utils', () => ({
App: { findAsync: jest.fn() },
Token: jest.fn(),
}));
jest.mock('@expo/eas-json', () => ({
EasJsonAccessor: { fromProjectPath: jest.fn() },
EasJsonUtils: { getSubmitProfileAsync: jest.fn() },
}));
jest.mock('../../credentials/context', () => ({
CredentialsContext: jest.fn(),
}));
jest.mock('../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
getRequestContext: jest.fn(),
}));
jest.mock('../../metadata/auth');
jest.mock('../../project/ios/bundleIdentifier', () => ({
getBundleIdentifierAsync: jest.fn().mockResolvedValue('com.example.app'),
}));
jest.mock('../../project/projectUtils', () => ({
getOwnerAccountForProjectIdAsync: jest
.fn()
.mockResolvedValue({ id: 'account-id', name: 'account-name' }),
}));
describe(resolveTestFlightAppAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(EasJsonUtils.getSubmitProfileAsync).mockResolvedValue({} as any);
jest
.mocked(getAppStoreAuthAsync)
.mockRejectedValue(new Error('Configured API key cannot access the app'));
jest.mocked(AccountAppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValue([]);
});
it('forces Apple ID authentication for the final interactive fallback', async () => {
const userAuthCtx = { authState: { context: { token: 'user-token' } } };
const requestContext = { session: 'user' };
const app = { attributes: { bundleId: 'com.example.app' } };
const ensureUserAuthenticatedAsync = jest.fn().mockResolvedValue(userAuthCtx);
jest.mocked(CredentialsContext).mockImplementation(
() =>
({
appStore: { ensureUserAuthenticatedAsync },
}) as any
);
jest.mocked(getRequestContext).mockReturnValue(requestContext as any);
jest.mocked(App.findAsync).mockResolvedValue(app as any);
const result = await resolveTestFlightAppAsync({
actor: {} as any,
analytics: {} as any,
exp: { slug: 'example' } as any,
graphqlClient: {} as any,
nonInteractive: false,
projectDir: '/app',
projectId: 'project-id',
vcsClient: {} as any,
});
expect(getAppStoreAuthAsync).toHaveBeenCalledTimes(1);
expect(ensureUserAuthenticatedAsync).toHaveBeenCalledTimes(1);
expect(getRequestContext).toHaveBeenCalledWith(userAuthCtx);
expect(App.findAsync).toHaveBeenCalledWith(requestContext, {
bundleId: 'com.example.app',
});
expect(result).toBe(app);
});
});
@@ -0,0 +1,93 @@
import { App, BetaCrashLog, BetaFeedbackCrashSubmission } from '@expo/apple-utils';
import { fetchTestFlightCrashAsync } from '../fetch';
import { formatTestFlightCrashDetails } from '../format';
jest.mock('@expo/apple-utils', () => ({
BetaCrashLog: { getCrashLogAsync: jest.fn() },
BetaFeedbackCrashSubmission: { infoAsync: jest.fn() },
}));
const app = { context: {} } as unknown as App;
const submission = {
id: 'crash-id',
attributes: {
createdDate: '2026-07-20T10:00:00.000Z',
comment: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
},
};
describe(fetchTestFlightCrashAsync, () => {
beforeEach(() => {
jest.mocked(BetaFeedbackCrashSubmission.infoAsync).mockResolvedValue(submission as any);
jest.mocked(BetaCrashLog.getCrashLogAsync).mockReset();
});
it('returns the crash log when App Store Connect has one', async () => {
jest
.mocked(BetaCrashLog.getCrashLogAsync)
.mockResolvedValue({ attributes: { logText: 'Thread 0 Crashed:' } } as any);
const { logText } = await fetchTestFlightCrashAsync(app, 'crash-id');
expect(logText).toBe('Thread 0 Crashed:');
});
it('reports no log when App Store Connect answers without one', async () => {
jest
.mocked(BetaCrashLog.getCrashLogAsync)
.mockResolvedValue({ attributes: { logText: undefined } } as any);
const { logText } = await fetchTestFlightCrashAsync(app, 'crash-id');
expect(logText).toBeNull();
});
it('propagates a failed crash log request', async () => {
const error = new Error('Request failed with status code 403');
jest.mocked(BetaCrashLog.getCrashLogAsync).mockRejectedValue(error);
await expect(fetchTestFlightCrashAsync(app, 'crash-id')).rejects.toThrow(error);
});
it('preserves a non-Error rejection', async () => {
jest.mocked(BetaCrashLog.getCrashLogAsync).mockRejectedValue('socket hang up');
await expect(fetchTestFlightCrashAsync(app, 'crash-id')).rejects.toBe('socket hang up');
});
});
describe(`${formatTestFlightCrashDetails.name} log states`, () => {
const crash = {
id: 'crash-id',
createdDate: '2026-07-20T10:00:00.000Z',
comment: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
locale: null,
timeZone: null,
architecture: null,
connectionType: null,
deviceFamily: null,
appUptimeInMilliseconds: null,
batteryPercentage: null,
diskBytesAvailable: null,
diskBytesTotal: null,
buildVersion: null,
testerName: null,
testerEmail: null,
};
it('prints the log when present', () => {
expect(formatTestFlightCrashDetails(crash, 'Thread 0 Crashed:')).toContain('Thread 0 Crashed:');
});
it('says no log is available when there is none', () => {
expect(formatTestFlightCrashDetails(crash, null)).toContain(
'No crash log is available for this submission yet.'
);
});
});
@@ -0,0 +1,189 @@
import { App } from '@expo/apple-utils';
import { getTestFlightCrashesQuery, getTestFlightFeedbackQuery } from '../fetch';
function createSubmission(overrides: Record<string, any> = {}): any {
return {
id: 'submission-id',
attributes: {
createdDate: '2026-07-20T10:00:00.000Z',
comment: 'The login button does nothing',
email: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
locale: 'en-GB',
timeZone: 'Europe/London',
architecture: 'arm64e',
connectionType: 'WIFI',
deviceFamily: 'IPHONE',
appUptimeInMilliseconds: 12_000,
batteryPercentage: 42,
diskBytesAvailable: 1024 ** 3,
diskBytesTotal: 4 * 1024 ** 3,
build: { id: 'build-id', attributes: { version: '42' } },
tester: {
id: 'tester-id',
attributes: { firstName: 'Jane', lastName: 'Doe', email: '[email protected]' },
},
...overrides,
},
};
}
function createApp(submissions: any[]): App {
return {
getBetaFeedbackScreenshotSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
getBetaFeedbackCrashSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
} as unknown as App;
}
describe(getTestFlightFeedbackQuery, () => {
it('normalizes submissions, including the build and tester relationships', async () => {
const query = getTestFlightFeedbackQuery(createApp([createSubmission({ screenshots: [] })]));
const items = await query.queryAsync(20, 0);
expect(await query.getTotalAsync()).toBe(1);
expect(items[0]).toMatchObject({
id: 'submission-id',
comment: 'The login button does nothing',
deviceModel: 'iPhone15,2',
osVersion: '18.2',
buildVersion: '42',
testerName: 'Jane Doe',
testerEmail: '[email protected]',
screenshots: [],
});
});
it('maps screenshots', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: [
{
url: 'https://example.com/screenshot.png',
width: 1170,
height: 2532,
expirationDate: '2026-07-27T10:00:00.000Z',
},
],
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].screenshots).toEqual([
{
url: 'https://example.com/screenshot.png',
width: 1170,
height: 2532,
expirationDate: '2026-07-27T10:00:00.000Z',
},
]);
});
it('falls back to the submission email when the tester relationship has none', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: null,
email: '[email protected]',
tester: { id: 'tester-id', attributes: { firstName: 'Anonymous', lastName: null } },
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].testerName).toBe('Anonymous');
expect(items[0].testerEmail).toBe('[email protected]');
expect(items[0].screenshots).toEqual([]);
});
it('serves pages by limit and offset from a single underlying fetch', async () => {
const app = createApp([
createSubmission({ screenshots: [], comment: 'first' }),
createSubmission({ screenshots: [], comment: 'second' }),
createSubmission({ screenshots: [], comment: 'third' }),
]);
const query = getTestFlightFeedbackQuery(app);
expect((await query.queryAsync(2, 0)).map(item => item.comment)).toEqual(['first', 'second']);
expect((await query.queryAsync(2, 2)).map(item => item.comment)).toEqual(['third']);
expect(await query.queryAsync(2, 5)).toEqual([]);
expect(await query.getTotalAsync()).toBe(3);
// Paging must not re-hit App Store Connect for every page.
expect(app.getBetaFeedbackScreenshotSubmissionsAsync).toHaveBeenCalledTimes(1);
});
});
describe('control character sanitization', () => {
it('strips terminal escape sequences from tester-supplied text', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: [],
// A tester could submit a comment that clears the screen and spoofs a prompt.
comment: 'looks fine\u001b[2J\u001b[HFATAL: run `rm -rf /` to continue',
tester: {
id: 'tester-id',
attributes: { firstName: 'Jane\u001b[31m', lastName: 'Doe', email: '[email protected]' },
},
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].comment).toBe('looks fine[2J[HFATAL: run `rm -rf /` to continue');
expect(items[0].comment).not.toContain('\u001b');
expect(items[0].testerName).toBe('Jane[31m Doe');
expect(items[0].testerName).not.toContain('\u001b');
});
it('keeps newlines and tabs, and normalizes CRLF', async () => {
const query = getTestFlightFeedbackQuery(
createApp([
createSubmission({
screenshots: [],
comment: 'line one\r\nline two\tindented\rline three',
}),
])
);
const items = await query.queryAsync(20, 0);
expect(items[0].comment).toBe('line one\nline two\tindented\nline three');
});
});
describe(getTestFlightCrashesQuery, () => {
it('normalizes crash submissions', async () => {
const query = getTestFlightCrashesQuery(createApp([createSubmission({ comment: null })]));
const items = await query.queryAsync(20, 0);
expect(await query.getTotalAsync()).toBe(1);
expect(items[0]).toMatchObject({
id: 'submission-id',
comment: null,
buildVersion: '42',
testerName: 'Jane Doe',
});
});
it('tolerates submissions without build or tester relationships', async () => {
const query = getTestFlightCrashesQuery(
createApp([createSubmission({ build: undefined, tester: undefined, email: null })])
);
const items = await query.queryAsync(20, 0);
expect(items[0].buildVersion).toBeNull();
expect(items[0].testerName).toBeNull();
expect(items[0].testerEmail).toBeNull();
});
});
@@ -0,0 +1,100 @@
import { TestFlightCrash, TestFlightFeedback } from '../fetch';
import {
formatTestFlightCrash,
formatTestFlightCrashDetails,
formatTestFlightFeedback,
} from '../format';
const submission: TestFlightCrash = {
id: 'f7a1c2d3-1111-2222-3333-444455556666',
createdDate: '2026-07-24T10:00:00.000Z',
comment: 'Tapping Continue does nothing.\nHappens every time on cellular.',
deviceModel: 'iPhone15,2',
osVersion: '18.2',
locale: 'en-GB',
timeZone: 'Europe/London',
architecture: 'arm64e',
connectionType: 'MOBILE_DATA',
deviceFamily: 'IPHONE',
appUptimeInMilliseconds: 42_300,
batteryPercentage: 61,
diskBytesAvailable: 12 * 1024 ** 3,
diskBytesTotal: 128 * 1024 ** 3,
buildVersion: '128',
testerName: 'Jane Doe',
testerEmail: '[email protected]',
};
const feedback: TestFlightFeedback = {
...submission,
screenshots: [
{
url: 'https://example.com/screenshot.png',
width: 1179,
height: 2556,
expirationDate: '2026-07-31T10:00:00.000Z',
},
],
};
describe(formatTestFlightFeedback, () => {
it('includes the tester, device, build, comment, and screenshots', () => {
const output = formatTestFlightFeedback(feedback, 1);
expect(output).toContain('1. Feedback f7a1c2d3-1111-2222-3333-444455556666');
expect(output).toContain('Jane Doe <[email protected]>');
expect(output).toContain('iPhone15,2, iOS 18.2');
expect(output).toContain('Build');
expect(output).toContain('128');
expect(output).toContain('battery 61%, disk 12.0 GB free of 128.0 GB, mobile data');
expect(output).toContain('https://example.com/screenshot.png (1179x2556)');
});
it('indents multi-line comments under their label', () => {
const output = formatTestFlightFeedback(feedback, 1);
expect(output).toContain('Tapping Continue does nothing.\n Happens every time');
});
it('marks feedback without a comment', () => {
const output = formatTestFlightFeedback({ ...feedback, comment: null }, 1);
expect(output).toContain('(no comment)');
});
it('omits the position when rendering a single submission', () => {
const output = formatTestFlightFeedback(feedback);
expect(output).toContain('Feedback f7a1c2d3-1111-2222-3333-444455556666');
expect(output).not.toContain('1. Feedback');
});
});
describe(formatTestFlightCrash, () => {
it('numbers the crash by its absolute position and omits absent optional fields', () => {
const output = formatTestFlightCrash({ ...submission, comment: null, locale: null }, 5);
expect(output).toContain('5. Crash f7a1c2d3-1111-2222-3333-444455556666');
expect(output).not.toContain('Comment');
expect(output).not.toContain('Locale');
});
});
describe(formatTestFlightCrashDetails, () => {
it('appends the crash log', () => {
const output = formatTestFlightCrashDetails(submission, 'Thread 0 Crashed:\n0 MyApp main + 32');
expect(output).toContain('Arch');
expect(output).toContain('arm64e');
expect(output).toContain('Uptime');
expect(output).toContain('42.3s');
expect(output).toContain('Crash log');
expect(output).toContain('Thread 0 Crashed:\n0 MyApp main + 32');
});
it('explains when no crash log is available', () => {
const output = formatTestFlightCrashDetails(submission, null);
expect(output).toContain('No crash log is available for this submission yet.');
});
});
@@ -0,0 +1,112 @@
import { App } from '@expo/apple-utils';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { listAndRenderTestFlightCrashesAsync } from '../queries';
jest.mock('../../log');
jest.mock('../../prompts', () => ({
selectAsync: jest.fn(),
confirmAsync: jest.fn(),
}));
function createApp(count: number): App {
const submissions = Array.from({ length: count }).map((_, index) => ({
id: `crash-${index + 1}`,
attributes: {
createdDate: '2026-07-20T10:00:00.000Z',
comment: null,
deviceModel: 'iPhone15,2',
osVersion: '18.2',
},
}));
return {
attributes: { bundleId: 'com.example.app' },
getBetaFeedbackCrashSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
} as unknown as App;
}
function loggedOutput(): string {
return jest
.mocked(Log.log)
.mock.calls.map(call => String(call[0]))
.join('\n');
}
describe(listAndRenderTestFlightCrashesAsync, () => {
beforeEach(() => {
jest.mocked(Log.log).mockClear();
jest.mocked(confirmAsync).mockReset();
});
it('numbers items by their absolute position across interactive pages', async () => {
// Accept the first "Load more?" prompt, decline the second.
jest.mocked(confirmAsync).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
await listAndRenderTestFlightCrashesAsync(createApp(6), {
limit: 2,
offset: 0,
json: false,
nonInteractive: false,
});
const output = loggedOutput();
expect(output).toContain('1. Crash crash-1');
expect(output).toContain('2. Crash crash-2');
expect(output).toContain('3. Crash crash-3');
expect(output).toContain('4. Crash crash-4');
expect(output).not.toContain('5. Crash');
expect(jest.mocked(confirmAsync)).toHaveBeenCalledTimes(2);
});
it('stops prompting once the last page is rendered', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(2), {
limit: 2,
offset: 0,
json: false,
nonInteractive: false,
});
expect(loggedOutput()).toContain('2. Crash crash-2');
expect(jest.mocked(confirmAsync)).not.toHaveBeenCalled();
});
it('honors offset when numbering a single non-interactive page', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(6), {
limit: 2,
offset: 4,
json: false,
nonInteractive: true,
});
const output = loggedOutput();
expect(output).toContain('5. Crash crash-5');
expect(output).toContain('6. Crash crash-6');
expect(output).not.toContain('1. Crash');
expect(jest.mocked(confirmAsync)).not.toHaveBeenCalled();
});
it('hints at remaining items when non-interactive', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(6), {
limit: 2,
offset: 0,
json: false,
nonInteractive: true,
});
expect(loggedOutput()).toContain(
'Showing 2 of 6 crashes. Use --offset and --limit to see more.'
);
});
it('reports an empty collection', async () => {
await listAndRenderTestFlightCrashesAsync(createApp(0), {
limit: 2,
offset: 0,
json: false,
nonInteractive: true,
});
expect(loggedOutput()).toContain('No TestFlight crashes have been reported for');
});
});
@@ -0,0 +1,65 @@
import { resolveBetaFeedbackTarget } from '../target';
describe(resolveBetaFeedbackTarget, () => {
it('reads the id and type out of a crash submission URL', () => {
expect(
resolveBetaFeedbackTarget({
idOrUrl:
'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/AAo2eIIfGzcb1BzuUv3xrh4',
defaultType: 'screenshot',
})
).toEqual({ id: 'AAo2eIIfGzcb1BzuUv3xrh4', type: 'crash' });
});
it('reads the id and type out of a screenshot submission URL', () => {
expect(
resolveBetaFeedbackTarget({
idOrUrl:
'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
defaultType: 'crash',
})
).toEqual({ id: 'AD8JvKbr0BK0Cj9OnM6WO6I', type: 'screenshot' });
});
it('falls back to the command default for a bare id', () => {
expect(resolveBetaFeedbackTarget({ idOrUrl: 'some-id', defaultType: 'crash' })).toEqual({
id: 'some-id',
type: 'crash',
});
});
it('lets --type override the default for a bare id', () => {
expect(
resolveBetaFeedbackTarget({ idOrUrl: 'some-id', type: 'screenshot', defaultType: 'crash' })
).toEqual({ id: 'some-id', type: 'screenshot' });
});
it('rejects a --type that contradicts the URL', () => {
expect(() =>
resolveBetaFeedbackTarget({
idOrUrl: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/abc',
type: 'screenshot',
defaultType: 'screenshot',
})
).toThrow('--type screenshot does not match the URL, which points at crash feedback');
});
it('rejects a URL that is not a beta feedback submission', () => {
expect(() =>
resolveBetaFeedbackTarget({
idOrUrl: 'https://appstoreconnect.apple.com/apps/6794186602/testflight/ios',
defaultType: 'crash',
})
).toThrow('Could not tell whether');
});
it('ignores query strings when reading the id', () => {
expect(
resolveBetaFeedbackTarget({
idOrUrl:
'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/abc?include=build',
defaultType: 'screenshot',
})
).toEqual({ id: 'abc', type: 'crash' });
});
});
+191
View File
@@ -0,0 +1,191 @@
import { App, RequestContext, Token } from '@expo/apple-utils';
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils, SubmitProfile } from '@expo/eas-json';
import { MissingEasJsonError } from '@expo/eas-json/build/errors';
import assert from 'assert';
import { Analytics } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { AppStoreConnectApiKeyQuery as AccountAppStoreConnectApiKeyQuery } from '../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../log';
import { getAppStoreAuthAsync } from '../metadata/auth';
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { Actor } from '../user/User';
import { Client } from '../vcs/vcs';
/** How long the ASC JWTs we mint stay valid, matching the credentials service default. */
const ASC_TOKEN_DURATION_SECONDS = 1200;
/**
* TestFlight data lives in App Store Connect, so we resolve the bundle identifier and the ASC API
* key from the submit profile the same way `eas metadata` does. When the project has no eas.json
* we fall back to an empty profile, which resolves the bundle identifier from the app config and
* the API key from env vars or the EAS credentials service.
*/
async function resolveSubmitProfileAsync({
projectDir,
profileName,
}: {
projectDir: string;
profileName?: string;
}): Promise<SubmitProfile<Platform.IOS>> {
try {
return await EasJsonUtils.getSubmitProfileAsync(
EasJsonAccessor.fromProjectPath(projectDir),
Platform.IOS,
profileName
);
} catch (error: any) {
if (error instanceof MissingEasJsonError && !profileName) {
return {} as SubmitProfile<Platform.IOS>;
}
throw error;
}
}
/**
* Look for the app in every ASC API key registered on the account that owns the project. This is
* the resolution the Expo MCP server uses for its TestFlight tools, and it covers projects that
* have no ASC API key assigned to their iOS submission credentials.
*/
async function findAppWithAccountAscApiKeysAsync({
bundleId,
graphqlClient,
projectId,
}: {
bundleId: string;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<App | null> {
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const keys = await AccountAppStoreConnectApiKeyQuery.getAllForAccountAsync(
graphqlClient,
account.name
);
for (const key of keys) {
try {
const { keyP8, keyIdentifier, issuerIdentifier } =
await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, key.id);
const context: RequestContext = {
token: new Token({
key: keyP8,
keyId: keyIdentifier,
issuerId: issuerIdentifier,
duration: ASC_TOKEN_DURATION_SECONDS,
}),
};
const app = await App.findAsync(context, { bundleId });
if (app) {
Log.log(
`Using App Store Connect API Key ${key.name ?? key.keyIdentifier} from your Expo account.`
);
return app;
}
} catch (error: unknown) {
// Never read `.message` off an unknown rejection: a non-Error value (a string, or
// null from a wrapped fetch) would make this handler throw a TypeError, hiding the
// real failure and aborting the loop instead of falling through to the next key.
Log.debug(
`ASC API Key ${key.keyIdentifier} cannot access ${bundleId}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
return null;
}
/** Resolve the App Store Connect app that TestFlight feedback and crashes should be read from. */
export async function resolveTestFlightAppAsync({
actor,
analytics,
exp,
graphqlClient,
nonInteractive,
profileName,
projectDir,
projectId,
vcsClient,
}: {
actor: Actor;
analytics: Analytics;
exp: ExpoConfig;
graphqlClient: ExpoGraphqlClient;
nonInteractive: boolean;
profileName?: string;
projectDir: string;
projectId: string;
vcsClient: Client;
}): Promise<App> {
const profile = await resolveSubmitProfileAsync({ projectDir, profileName });
const createCredentialsContext = (contextNonInteractive: boolean): CredentialsContext =>
new CredentialsContext({
projectInfo: { exp, projectId },
projectDir,
user: actor,
graphqlClient,
analytics,
nonInteractive: contextNonInteractive,
vcsClient,
});
// Prefer the key configured for this project: ASC env vars, the submit profile, or the key
// assigned to its iOS submission credentials. Forced non-interactive so that a project without
// any of those falls through to the account-wide lookup instead of prompting for an Apple login.
try {
const { app } = await getAppStoreAuthAsync({
exp,
credentialsCtx: createCredentialsContext(true),
graphqlClient,
nonInteractive: true,
profile,
projectDir,
projectId,
});
return app;
} catch (error: unknown) {
Log.debug(
`Could not resolve an App Store Connect API Key for this project: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const bundleId =
('bundleIdentifier' in profile ? profile.bundleIdentifier : undefined) ??
(await getBundleIdentifierAsync(projectDir, exp, vcsClient));
const appFromAccountKeys = await findAppWithAccountAscApiKeysAsync({
bundleId,
graphqlClient,
projectId,
});
if (appFromAccountKeys) {
return appFromAccountKeys;
}
if (nonInteractive) {
throw new Error(
`Could not find "${bundleId}" in App Store Connect with any App Store Connect API Key available to this project.\n` +
'Provide a key with access to the app via:\n' +
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
' - EAS credentials service: run `eas credentials` to set up an API key'
);
}
// Last resort: an interactive Apple ID login.
const credentialsCtx = createCredentialsContext(false);
const authCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync();
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
return app;
}
+210
View File
@@ -0,0 +1,210 @@
import {
App,
BetaCrashLog,
BetaFeedbackCrashSubmission,
BetaFeedbackScreenshotSubmission,
} from '@expo/apple-utils';
/**
* The largest page size the App Store Connect API accepts. The apple-utils helpers always walk
* every page, so we ask for the largest pages possible and paginate client-side from there.
*/
const MAX_PAGE_SIZE = 200;
/** Which kind of TestFlight beta feedback a submission is. Matches the workflow trigger context. */
export type BetaFeedbackType = 'crash' | 'screenshot';
/** Screenshot attached to a piece of TestFlight feedback. Its URL expires after a short while. */
export type TestFlightScreenshot = {
url: string;
width: number;
height: number;
expirationDate: string;
};
/** Fields shared by TestFlight screenshot feedback and TestFlight crash submissions. */
export type TestFlightSubmission = {
id: string;
createdDate: string;
comment: string | null;
deviceModel: string;
osVersion: string;
locale: string | null;
timeZone: string | null;
architecture: string | null;
connectionType: string | null;
deviceFamily: string | null;
appUptimeInMilliseconds: number | null;
batteryPercentage: number | null;
diskBytesAvailable: number | null;
diskBytesTotal: number | null;
buildVersion: string | null;
testerName: string | null;
testerEmail: string | null;
};
export type TestFlightFeedback = TestFlightSubmission & {
screenshots: TestFlightScreenshot[];
};
export type TestFlightCrash = TestFlightSubmission;
/**
* A paginated view over one App Store Connect collection. App Store Connect has no offset-based
* paging and apple-utils always walks every page, so the underlying fetch happens once and pages
* are served from that result — which also makes the total count available for free.
*/
export type TestFlightQuery<T> = {
queryAsync(limit: number, offset: number): Promise<T[]>;
getTotalAsync(): Promise<number>;
};
function createQuery<T>(fetchAllAsync: () => Promise<T[]>): TestFlightQuery<T> {
let all: Promise<T[]> | undefined;
const allAsync = async (): Promise<T[]> => {
all ??= fetchAllAsync();
return await all;
};
return {
async queryAsync(limit: number, offset: number): Promise<T[]> {
return (await allAsync()).slice(offset, offset + limit);
},
async getTotalAsync(): Promise<number> {
return (await allAsync()).length;
},
};
}
/**
* The `build` and `tester` relationships are requested through `includes`, so they land on the
* model attributes at runtime without being part of the attribute types.
*/
type SubmissionRelationships = {
build?: { id: string; attributes?: { version?: string | null } } | null;
tester?: {
id: string;
attributes?: { firstName?: string | null; lastName?: string | null; email?: string | null };
} | null;
};
/**
* Everything here is attacker-influenced text: any TestFlight tester can put arbitrary bytes in a
* feedback comment or their own name, and crash logs carry thread and symbol names from the build.
* Terminal escape sequences in that text could clear the screen or spoof output when it is printed,
* so strip control characters at the boundary — before the data reaches either the formatters or
* `--json`. Newlines and tabs survive because multi-line comments and crash logs need them.
*/
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
function sanitizeText(value: string): string {
return value.replace(/\r\n?/g, '\n').replace(CONTROL_CHARACTERS, '');
}
function sanitizeOptionalText(value: unknown): string | null {
return typeof value === 'string' ? sanitizeText(value) : null;
}
function formatTesterName(tester: SubmissionRelationships['tester']): string | null {
const parts = [tester?.attributes?.firstName, tester?.attributes?.lastName].filter(
(part): part is string => !!part
);
return parts.length > 0 ? sanitizeText(parts.join(' ')) : null;
}
function normalizeSubmission(submission: {
id: string;
attributes: Record<string, any>;
}): TestFlightSubmission {
const attributes = submission.attributes as Record<string, any> & SubmissionRelationships;
return {
id: submission.id,
createdDate: attributes.createdDate,
comment: sanitizeOptionalText(attributes.comment),
deviceModel: sanitizeText(attributes.deviceModel ?? ''),
osVersion: sanitizeText(attributes.osVersion ?? ''),
locale: sanitizeOptionalText(attributes.locale),
timeZone: sanitizeOptionalText(attributes.timeZone),
architecture: sanitizeOptionalText(attributes.architecture),
connectionType: sanitizeOptionalText(attributes.connectionType),
deviceFamily: sanitizeOptionalText(attributes.deviceFamily),
appUptimeInMilliseconds: attributes.appUptimeInMilliseconds ?? null,
batteryPercentage: attributes.batteryPercentage ?? null,
diskBytesAvailable: attributes.diskBytesAvailable ?? null,
diskBytesTotal: attributes.diskBytesTotal ?? null,
buildVersion: sanitizeOptionalText(attributes.build?.attributes?.version),
testerName: formatTesterName(attributes.tester),
// The tester relationship hides the email of testers who joined through a public link, in
// which case the submission itself still carries the address they submitted feedback with.
testerEmail: sanitizeOptionalText(attributes.tester?.attributes?.email ?? attributes.email),
};
}
function normalizeFeedback(submission: {
id: string;
attributes: Record<string, any>;
}): TestFlightFeedback {
return {
...normalizeSubmission(submission),
screenshots: ((submission.attributes.screenshots ?? []) as TestFlightScreenshot[]).map(
screenshot => ({
url: sanitizeText(screenshot.url ?? ''),
width: screenshot.width,
height: screenshot.height,
expirationDate: screenshot.expirationDate,
})
),
};
}
/** Screenshot feedback that TestFlight testers submitted for the app, newest first. */
export function getTestFlightFeedbackQuery(app: App): TestFlightQuery<TestFlightFeedback> {
return createQuery(async () => {
const submissions = await app.getBetaFeedbackScreenshotSubmissionsAsync({
query: { limit: MAX_PAGE_SIZE },
});
return submissions.map(normalizeFeedback);
});
}
/** Crashes that TestFlight testers reported for the app, newest first. */
export function getTestFlightCrashesQuery(app: App): TestFlightQuery<TestFlightCrash> {
return createQuery(async () => {
const submissions = await app.getBetaFeedbackCrashSubmissionsAsync({
query: { limit: MAX_PAGE_SIZE },
});
return submissions.map(normalizeSubmission);
});
}
/** Fetch a single screenshot feedback submission by ID. */
export async function fetchTestFlightFeedbackAsync(
app: App,
feedbackId: string
): Promise<TestFlightFeedback> {
const submission = await BetaFeedbackScreenshotSubmission.infoAsync(app.context, {
id: feedbackId,
});
return normalizeFeedback(submission);
}
export type TestFlightCrashDetails = {
crash: TestFlightCrash;
/** The crash log, or `null` when App Store Connect has none for this submission. */
logText: string | null;
};
/** Fetch a single crash submission together with its full crash log. */
export async function fetchTestFlightCrashAsync(
app: App,
crashId: string
): Promise<TestFlightCrashDetails> {
const submission = await BetaFeedbackCrashSubmission.infoAsync(app.context, { id: crashId });
const crashLog = await BetaCrashLog.getCrashLogAsync(app.context, {
betaFeedbackCrashSubmissionId: crashId,
});
return {
crash: normalizeSubmission(submission),
logText: sanitizeOptionalText(crashLog.attributes.logText),
};
}
+123
View File
@@ -0,0 +1,123 @@
import chalk from 'chalk';
import { TestFlightCrash, TestFlightFeedback, TestFlightSubmission } from './fetch';
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
import { fromNow } from '../utils/date';
function formatCreatedDate(createdDate: string): string {
const date = new Date(createdDate);
if (isNaN(date.getTime())) {
return createdDate;
}
return `${date.toLocaleString()} (${fromNow(date)} ago)`;
}
function formatDevice(submission: TestFlightSubmission): string {
return [submission.deviceModel, `iOS ${submission.osVersion}`].join(', ');
}
function formatTester(submission: TestFlightSubmission): string | null {
if (submission.testerName && submission.testerEmail) {
return `${submission.testerName} <${submission.testerEmail}>`;
}
return submission.testerName ?? submission.testerEmail ?? null;
}
function formatBytes(bytes: number): string {
const gigabytes = bytes / 1024 ** 3;
if (gigabytes >= 1) {
return `${gigabytes.toFixed(1)} GB`;
}
return `${(bytes / 1024 ** 2).toFixed(0)} MB`;
}
function formatDeviceState(submission: TestFlightSubmission): string | null {
const parts: string[] = [];
if (submission.batteryPercentage !== null) {
parts.push(`battery ${submission.batteryPercentage}%`);
}
if (submission.diskBytesAvailable !== null && submission.diskBytesTotal !== null) {
parts.push(
`disk ${formatBytes(submission.diskBytesAvailable)} free of ${formatBytes(
submission.diskBytesTotal
)}`
);
}
if (submission.connectionType) {
parts.push(submission.connectionType.toLowerCase().replace('_', ' '));
}
return parts.length > 0 ? parts.join(', ') : null;
}
/** Indent every line of a possibly multi-line value so it lines up under its label. */
function indentValue(value: string, indent: number): string {
return value.split('\n').join(`\n${' '.repeat(indent)}`);
}
function commonFields(submission: TestFlightSubmission): FormatFieldsItem[] {
const tester = formatTester(submission);
const deviceState = formatDeviceState(submission);
return [
{ label: 'Submitted', value: formatCreatedDate(submission.createdDate) },
...(submission.buildVersion ? [{ label: 'Build', value: submission.buildVersion }] : []),
{ label: 'Device', value: formatDevice(submission) },
...(tester ? [{ label: 'Tester', value: tester }] : []),
...(submission.locale ? [{ label: 'Locale', value: submission.locale }] : []),
...(deviceState ? [{ label: 'State', value: deviceState }] : []),
];
}
function formatBlock(heading: string, fields: FormatFieldsItem[]): string {
// `formatFields` pads labels to the widest one, so multi-line values must be indented by that
// width plus the two spaces it puts between label and value.
const labelWidth = Math.max(...fields.map(field => field.label.length)) + 2;
return [
chalk.bold(heading),
formatFields(fields.map(field => ({ ...field, value: indentValue(field.value, labelWidth) }))),
].join('\n');
}
/** Prefix list entries with their absolute position, so `--offset` numbering stays meaningful. */
function heading(kind: string, id: string, position?: number): string {
return position === undefined ? `${kind} ${id}` : `${position}. ${kind} ${id}`;
}
export function formatTestFlightFeedback(feedback: TestFlightFeedback, position?: number): string {
const screenshots = feedback.screenshots.map(
screenshot => `${screenshot.url} (${screenshot.width}x${screenshot.height})`
);
return formatBlock(heading('Feedback', feedback.id, position), [
...commonFields(feedback),
{ label: 'Comment', value: feedback.comment ?? chalk.dim('(no comment)') },
...(screenshots.length > 0 ? [{ label: 'Screenshots', value: screenshots.join('\n') }] : []),
]);
}
export function formatTestFlightCrash(crash: TestFlightCrash, position?: number): string {
return formatBlock(heading('Crash', crash.id, position), [
...commonFields(crash),
...(crash.comment ? [{ label: 'Comment', value: crash.comment }] : []),
]);
}
export function formatTestFlightCrashDetails(
crash: TestFlightCrash,
logText: string | null
): string {
const details = formatBlock(heading('Crash', crash.id), [
...commonFields(crash),
...(crash.architecture ? [{ label: 'Arch', value: crash.architecture }] : []),
...(crash.appUptimeInMilliseconds !== null
? [{ label: 'Uptime', value: `${(crash.appUptimeInMilliseconds / 1000).toFixed(1)}s` }]
: []),
...(crash.comment ? [{ label: 'Comment', value: crash.comment }] : []),
]);
return [
details,
'',
chalk.bold('Crash log'),
logText ?? chalk.dim('No crash log is available for this submission yet.'),
].join('\n');
}
+182
View File
@@ -0,0 +1,182 @@
import { App } from '@expo/apple-utils';
import chalk from 'chalk';
import {
TestFlightCrash,
TestFlightFeedback,
TestFlightQuery,
TestFlightSubmission,
fetchTestFlightCrashAsync,
fetchTestFlightFeedbackAsync,
getTestFlightCrashesQuery,
getTestFlightFeedbackQuery,
} from './fetch';
import {
formatTestFlightCrash,
formatTestFlightCrashDetails,
formatTestFlightFeedback,
} from './format';
import { BetaFeedbackTarget } from './target';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import Log from '../log';
import { printJsonOnlyOutput } from '../utils/json';
import { paginatedQueryWithConfirmPromptAsync } from '../utils/queries';
/** Feedback and crashes render as multi-line blocks, so default to a smaller page than lists do. */
export const TESTFLIGHT_PAGE_SIZE = 20;
type ListRendering<T extends TestFlightSubmission> = {
/** Key the items are nested under in `--json` output. */
jsonKey: string;
formatItem: (item: T, position?: number) => string;
emptyMessage: (bundleId: string) => string;
loadMorePrompt: string;
/** Hint printed after a page when more items exist but interactive paging is unavailable. */
moreAvailableHint: (shown: number, total: number) => string;
};
const FEEDBACK_RENDERING: ListRendering<TestFlightFeedback> = {
jsonKey: 'feedback',
formatItem: formatTestFlightFeedback,
emptyMessage: bundleId =>
`No TestFlight feedback has been submitted for ${chalk.bold(bundleId)}.`,
loadMorePrompt: 'Load more feedback?',
moreAvailableHint: (shown, total) =>
`Showing ${shown} of ${total} feedback submissions. Use --offset and --limit to see more.`,
};
const CRASHES_RENDERING: ListRendering<TestFlightCrash> = {
jsonKey: 'crashes',
formatItem: formatTestFlightCrash,
emptyMessage: bundleId => `No TestFlight crashes have been reported for ${chalk.bold(bundleId)}.`,
loadMorePrompt: 'Load more crashes?',
moreAvailableHint: (shown, total) =>
`Showing ${shown} of ${total} crashes. Use --offset and --limit to see more.`,
};
async function listAndRenderAsync<T extends TestFlightSubmission>({
bundleId,
query,
rendering,
paginatedQueryOptions,
}: {
bundleId: string;
query: TestFlightQuery<T>;
rendering: ListRendering<T>;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<void> {
const { json, nonInteractive, offset } = paginatedQueryOptions;
const limit = paginatedQueryOptions.limit ?? TESTFLIGHT_PAGE_SIZE;
if (json) {
const [items, total] = await Promise.all([
query.queryAsync(limit, offset),
query.getTotalAsync(),
]);
printJsonOnlyOutput({
[rendering.jsonKey]: items,
total,
offset,
limit,
hasNextPage: offset + items.length < total,
});
return;
}
const renderPage = (items: T[], pageOffset: number): void => {
Log.addNewLineIfNone();
if (items.length === 0) {
Log.log(
pageOffset === 0
? rendering.emptyMessage(bundleId)
: chalk.dim('No items past this offset.')
);
return;
}
Log.log(
items.map((item, index) => rendering.formatItem(item, pageOffset + index + 1)).join('\n\n')
);
};
if (nonInteractive) {
const items = await query.queryAsync(limit, offset);
renderPage(items, offset);
const total = await query.getTotalAsync();
if (offset + items.length < total) {
Log.newLine();
Log.log(chalk.dim(rendering.moreAvailableHint(offset + items.length, total)));
}
return;
}
// `paginatedQueryWithConfirmPromptAsync` renders one page at a time without reporting the offset
// it used, so track how many items have been rendered to keep the numbering absolute.
let renderedCount = 0;
await paginatedQueryWithConfirmPromptAsync({
limit,
offset,
queryToPerform: (pageLimit, pageOffset) => query.queryAsync(pageLimit, pageOffset),
promptOptions: {
title: rendering.loadMorePrompt,
renderListItems: items => {
renderPage(items, offset + renderedCount);
renderedCount += items.length;
},
},
});
}
/** List screenshot feedback submitted by TestFlight testers. */
export async function listAndRenderTestFlightFeedbackAsync(
app: App,
paginatedQueryOptions: PaginatedQueryOptions
): Promise<void> {
await listAndRenderAsync({
bundleId: app.attributes.bundleId,
query: getTestFlightFeedbackQuery(app),
rendering: FEEDBACK_RENDERING,
paginatedQueryOptions,
});
}
/** List crashes reported by TestFlight testers. */
export async function listAndRenderTestFlightCrashesAsync(
app: App,
paginatedQueryOptions: PaginatedQueryOptions
): Promise<void> {
await listAndRenderAsync({
bundleId: app.attributes.bundleId,
query: getTestFlightCrashesQuery(app),
rendering: CRASHES_RENDERING,
paginatedQueryOptions,
});
}
/**
* Render a single submission identified by an EAS workflow trigger's
* `app_store_connect.beta_feedback` payload. Crashes are rendered with their full crash log.
*/
export async function viewAndRenderBetaFeedbackAsync(
app: App,
target: BetaFeedbackTarget,
{ json }: { json: boolean }
): Promise<void> {
if (target.type === 'crash') {
const { crash, logText } = await fetchTestFlightCrashAsync(app, target.id);
if (json) {
printJsonOnlyOutput({ crash, logText });
return;
}
Log.addNewLineIfNone();
Log.log(formatTestFlightCrashDetails(crash, logText));
return;
}
const feedback = await fetchTestFlightFeedbackAsync(app, target.id);
if (json) {
printJsonOnlyOutput({ feedback });
return;
}
Log.addNewLineIfNone();
Log.log(formatTestFlightFeedback(feedback));
}
+52
View File
@@ -0,0 +1,52 @@
import { BetaFeedbackType } from './fetch';
/**
* App Store Connect self-links, which are what EAS workflow triggers put in
* `app_store_connect.beta_feedback.url`:
*
* https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/<id>
* https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/<id>
*/
const FEEDBACK_URL_PATTERN =
/\/(betaFeedbackCrashSubmissions|betaFeedbackScreenshotSubmissions)\/([^/?#]+)/i;
export type BetaFeedbackTarget = {
id: string;
type: BetaFeedbackType;
};
/**
* Resolve a single submission to look up from what an EAS workflow trigger provides in
* `app_store_connect.beta_feedback`: either the `url` (which encodes both `id` and `type`) or the
* bare `id` alongside `type`. Without either, the command's own kind of feedback is assumed.
*/
export function resolveBetaFeedbackTarget({
idOrUrl,
type,
defaultType,
}: {
idOrUrl: string;
type?: BetaFeedbackType;
defaultType: BetaFeedbackType;
}): BetaFeedbackTarget {
const match = idOrUrl.match(FEEDBACK_URL_PATTERN);
if (match) {
const urlType: BetaFeedbackType = match[1].toLowerCase().startsWith('betafeedbackcrash')
? 'crash'
: 'screenshot';
if (type && type !== urlType) {
throw new Error(
`--type ${type} does not match the URL, which points at ${urlType} feedback. Omit --type when passing a URL.`
);
}
return { id: decodeURIComponent(match[2]), type: urlType };
}
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(idOrUrl)) {
throw new Error(
`Could not tell whether "${idOrUrl}" points at crash or screenshot feedback. Pass the App Store Connect API URL from \${{ app_store_connect.beta_feedback.url }}, or pass the ID with --type.`
);
}
return { id: idOrUrl, type: type ?? defaultType };
}
+28 -1
View File
@@ -5,7 +5,11 @@ import promiseRetry from 'promise-retry';
import { ExpoGraphqlClient } from './commandUtils/context/contextUtils/createGraphqlClient';
import fetch from './fetch';
import { AccountUploadSessionType, UploadSessionType } from './graphql/generated';
import {
AccountUploadSessionType,
AppUploadSessionType,
UploadSessionType,
} from './graphql/generated';
import { SignedUrl, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
import { ProgressHandler } from './utils/progress';
@@ -53,6 +57,29 @@ export async function uploadAccountScopedFileAtPathToGCSAsync(
return signedUrl.bucketKey;
}
export async function uploadAppScopedFileAtPathToGCSAsync(
graphqlClient: ExpoGraphqlClient,
{
type,
appId,
path,
handleProgressEvent = () => {},
}: {
type: AppUploadSessionType;
appId: string;
path: string;
handleProgressEvent?: ProgressHandler;
}
): Promise<string> {
const signedUrl = await UploadSessionMutation.createAppScopedUploadSessionAsync(graphqlClient, {
type,
appID: appId,
});
await uploadWithSignedUrlWithProgressAsync(path, signedUrl, handleProgressEvent);
return signedUrl.bucketKey;
}
export async function uploadWithPresignedPostWithRetryAsync(
file: string,
presignedPost: PresignedPost,
+20 -1
View File
@@ -1,7 +1,26 @@
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
import { CurrentUserQuery, PartnerActor, Robot, Role, SsoUser, User } from '../graphql/generated';
export type Actor = NonNullable<CurrentUserQuery['meActor']>;
/**
* Names of the accounts where the actor can create projects (non-ViewOnly role), listed in the
* same order as account selection prompts: the personal account first, then team accounts,
* then organization accounts.
*/
export function getCreatableAccountNames(actor: Actor): string[] {
const creatableAccounts = actor.accounts.filter(
a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly
);
if (actor.__typename === 'Robot') {
return creatableAccounts.map(it => it.name);
}
return [
...creatableAccounts.filter(a => a.ownerUserActor?.id === actor.id),
...creatableAccounts.filter(a => a.ownerUserActor && a.ownerUserActor.id !== actor.id),
...creatableAccounts.filter(a => !a.ownerUserActor),
].map(it => it.name);
}
/**
* Resolve the name of the actor, either normal user, sso user or robot user.
* This should be used whenever the "current user" needs to be displayed.
@@ -1,5 +1,5 @@
import { Role } from '../../graphql/generated';
import { Actor, getActorDisplayName } from '../User';
import { Actor, getActorDisplayName, getCreatableAccountNames } from '../User';
const userStub: Actor = {
__typename: 'User',
@@ -72,3 +72,37 @@ describe('getActorDisplayName', () => {
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
});
});
describe('getCreatableAccountNames', () => {
it('lists the personal account first, then team accounts, then organizations, excluding view-only accounts', () => {
const actor: Actor = {
...userStub,
accounts: [
{
id: 'account_id_1',
name: 'some-org',
users: [{ role: Role.Owner, actor: { id: 'userId' } }],
},
{
id: 'account_id_2',
name: 'other-user-team',
ownerUserActor: { id: 'otherUserId', username: 'other-user' },
users: [{ role: Role.Admin, actor: { id: 'userId' } }],
},
{
id: 'account_id_3',
name: 'view-only-org',
users: [{ role: Role.ViewOnly, actor: { id: 'userId' } }],
},
{
id: 'account_id_4',
name: 'username',
ownerUserActor: { id: 'userId', username: 'username' },
users: [{ role: Role.Owner, actor: { id: 'userId' } }],
},
],
};
expect(getCreatableAccountNames(actor)).toEqual(['username', 'other-user-team', 'some-org']);
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "21.1.0",
"version": "21.3.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "21.1.0",
"@expo/eas-build-job": "21.3.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "21.1.0",
"version": "21.3.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "21.1.0",
"@expo/eas-build-job": "21.1.0",
"@expo/build-tools": "21.3.0",
"@expo/eas-build-job": "21.3.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "21.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "21.1.0",
"version": "21.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "21.1.0",
"@expo/eas-build-job": "21.3.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+13
View File
@@ -8,6 +8,7 @@ import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInputValueType, BuildStepInputValueTypeName } from './BuildStepInput';
import { BuildConfigError, BuildWorkflowError } from './errors';
import { isLocalCompositeFunctionPath } from './utils/localCompositeFunctions';
import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX } from './utils/template';
export type BuildFunctions = Record<string, BuildFunctionConfig>;
@@ -438,6 +439,18 @@ export function validateAllFunctionsExist(
}
}
const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet);
const compositeFunctionPaths = calledFunctionsOrFunctionGroup.filter(
isLocalCompositeFunctionPath
);
if (compositeFunctionPaths.length > 0) {
throw new BuildConfigError(
`Local composite functions (${compositeFunctionPaths
.map(compositeFunctionPath => `"${compositeFunctionPath}"`)
.join(
', '
)}) are not supported in ".eas/build/*.yml" custom builds. Local composite functions can only be used in EAS workflows (".eas/workflows/*.yml").`
);
}
const externalFunctionIdsSet = new Set(externalFunctionIds);
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupsIds);
const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter(
+9 -1
View File
@@ -3,6 +3,7 @@ import assert from 'assert';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStep, BuildStepFunction } from './BuildStep';
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInputProvider } from './BuildStepInput';
@@ -117,6 +118,7 @@ export class BuildFunction {
shell,
env,
ifCondition,
compositeFunctionScope,
timeoutMs,
}: {
id?: string;
@@ -126,6 +128,7 @@ export class BuildFunction {
shell?: string;
env?: BuildStepEnv;
ifCondition?: string;
compositeFunctionScope?: BuildStepCompositeFunctionScope;
timeoutMs?: number;
} = {}
): BuildStep {
@@ -158,9 +161,14 @@ export class BuildFunction {
supportedRuntimePlatforms: this.supportedRuntimePlatforms,
env,
ifCondition,
compositeFunctionScope,
timeoutMs,
__metricsId: this.__metricsId,
__hookId: this.__hookId,
// The declaration does not survive composite function expansion: hooks
// never fire around steps inside a composite function, so an expanded
// step must not carry an anchor mark for any discovery mechanism
// (parse-time or a future runtime scan) to find.
__hookId: compositeFunctionScope === undefined ? this.__hookId : undefined,
});
}
}
+181 -44
View File
@@ -6,6 +6,7 @@ import path from 'path';
import util from 'util';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput';
@@ -23,7 +24,13 @@ import {
} from './BuildTemporaryFiles';
import { BuildStepRuntimeError } from './errors';
import { interpolateJobContext } from './interpolation';
import { evaluateIfCondition } from './utils/jsepEval';
import {
containsUnresolvedTemplateReference,
resolveInterpolatedTarget,
stringifyInterpolatedResult,
stringifyOptionalInterpolatedResult,
} from './utils/compositeFunctionInterpolation';
import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/jsepEval';
import { BIN_PATH } from './utils/shell/bin';
import { getShellCommandAndArgs } from './utils/shell/command';
import { spawnAsync } from './utils/shell/spawn';
@@ -134,6 +141,7 @@ export class BuildStep extends BuildStepOutputAccessor {
public readonly ctx: BuildStepContext;
public readonly stepEnvOverrides: BuildStepEnv;
public readonly ifCondition?: string;
public readonly compositeFunctionScope?: BuildStepCompositeFunctionScope;
public readonly timeoutMs?: number;
public readonly __metricsId?: string;
public readonly __hookId?: HookAnchorId;
@@ -162,6 +170,7 @@ export class BuildStep extends BuildStepOutputAccessor {
supportedRuntimePlatforms: maybeSupportedRuntimePlatforms,
env,
ifCondition,
compositeFunctionScope,
timeoutMs,
__metricsId,
__hookId,
@@ -177,6 +186,7 @@ export class BuildStep extends BuildStepOutputAccessor {
supportedRuntimePlatforms?: BuildRuntimePlatform[];
env?: BuildStepEnv;
ifCondition?: string;
compositeFunctionScope?: BuildStepCompositeFunctionScope;
timeoutMs?: number;
__metricsId?: string;
__hookId?: HookAnchorId;
@@ -197,6 +207,7 @@ export class BuildStep extends BuildStepOutputAccessor {
this.command = command;
this.shell = shell ?? '/bin/bash -eo pipefail';
this.ifCondition = ifCondition;
this.compositeFunctionScope = compositeFunctionScope;
this.timeoutMs = timeoutMs;
this.__metricsId = __metricsId;
this.__hookId = __hookId;
@@ -212,15 +223,26 @@ export class BuildStep extends BuildStepOutputAccessor {
this.outputsDir = getTemporaryOutputsDirPath(ctx, this.id);
this.envsDir = getTemporaryEnvsDirPath(ctx, this.id);
this.registerSelf(ctx);
}
/**
* Consulted by registerSelf() in the constructor; overrides may only read state
* assigned before that call (outputById, compositeFunctionScope).
*/
public get isCompositeFunctionInternal(): boolean {
return this.compositeFunctionScope !== undefined;
}
protected registerSelf(ctx: BuildStepGlobalContext): void {
ctx.registerStep(this);
}
public async executeAsync(): Promise<void> {
try {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.START_STEP },
`Executing build step "${this.displayName}"`
);
this.resolveTemplatedWorkingDirectoryIfNeeded();
this.logStepStart();
this.status = BuildStepStatus.IN_PROGRESS;
await fs.mkdir(this.outputsDir, { recursive: true });
@@ -269,10 +291,7 @@ export class BuildStep extends BuildStepOutputAccessor {
await executionPromise;
}
this.ctx.logger.info(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS },
`Finished build step "${this.displayName}" successfully`
);
this.logStepSuccess();
this.status = BuildStepStatus.SUCCESS;
} catch (err) {
// Downstream error handling relies on real Errors; wrap non-Error
@@ -283,11 +302,7 @@ export class BuildStep extends BuildStepOutputAccessor {
: new BuildStepRuntimeError(
`Build step "${this.displayName}" threw a non-Error value: ${util.inspect(err)}`
);
this.ctx.logger.error({ err: error });
this.ctx.logger.error(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
`Build step "${this.displayName}" failed`
);
this.logStepFailed(error);
this.status = BuildStepStatus.FAIL;
throw error;
} finally {
@@ -300,6 +315,7 @@ export class BuildStep extends BuildStepOutputAccessor {
} catch (error) {
// If the step succeeded, we expect the outputs to be collected successfully.
if (this.status === BuildStepStatus.SUCCESS) {
// Rethrow so BuildWorkflow.recordFailure marks the global failure flag.
throw error;
}
@@ -318,32 +334,86 @@ export class BuildStep extends BuildStepOutputAccessor {
}
public shouldExecuteStep(): boolean {
const hasAnyPreviousStepFailed = this.ctx.global.hasAnyPreviousStepFailed;
if (!this.ifCondition) {
return !hasAnyPreviousStepFailed;
if (
this.compositeFunctionScope &&
!this.compositeFunctionScope.isActive(evaluateIfConditionExpression)
) {
return false;
}
if (!this.ifCondition) {
return !this.ctx.global.hasAnyPreviousStepFailed;
}
return this.evaluateIfCondition(this.ifCondition, {
scope: this.compositeFunctionScope,
env: this.getScriptEnv(),
inputs: this.compositeFunctionScope ? {} : this.evaluateOwnStepInputs(),
});
}
return evaluateIfCondition(
this.ifCondition,
this.ctx.global.getIfConditionContext({
inputs:
this.inputs?.reduce(
(acc, input) => {
acc[input.id] = input.getValue({
interpolationContext: this.getInterpolationContext(),
});
return acc;
},
{} as Record<string, unknown>
) ?? {},
env: this.getScriptEnv(),
})
private evaluateOwnStepInputs(): Record<string, unknown> {
return (
this.inputs?.reduce(
(acc, input) => {
acc[input.id] = input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
});
return acc;
},
{} as Record<string, unknown>
) ?? {}
);
}
private evaluateIfCondition(
ifCondition: string,
{
scope,
env,
inputs,
}: {
scope: BuildStepCompositeFunctionScope | undefined;
env: BuildStepEnv;
inputs: Record<string, unknown>;
}
): boolean {
const baseContext = this.ctx.global.getIfConditionContext({
inputs,
env,
}) as JobInterpolationContext;
// Overlay the scope last so `inputs`/`steps` come from the composite function while `eas.*` stays global.
const context = scope ? scope.getScopedInterpolationContext(baseContext) : baseContext;
return evaluateIfConditionExpression(ifCondition, context);
}
public skip(): void {
this.status = BuildStepStatus.SKIPPED;
this.logStepSkipped();
}
protected logStepStart(): void {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.START_STEP },
`Executing build step "${this.displayName}"`
);
}
protected logStepSuccess(): void {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS },
`Finished build step "${this.displayName}" successfully`
);
}
protected logStepFailed(error: Error): void {
this.ctx.logger.error({ err: error });
this.ctx.logger.error(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
`Build step "${this.displayName}" failed`
);
}
protected logStepSkipped(): void {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.START_STEP },
'Executing build step "${this.displayName}"'
@@ -356,10 +426,11 @@ export class BuildStep extends BuildStepOutputAccessor {
}
private getInterpolationContext(): JobInterpolationContext {
return {
const base: JobInterpolationContext = {
...this.ctx.global.getInterpolationContext(),
env: this.getScriptEnv(),
};
return this.compositeFunctionScope?.getScopedInterpolationContext(base) ?? base;
}
private async executeCommandAsync({ signal }: { signal: AbortSignal | null }): Promise<void> {
@@ -423,7 +494,12 @@ export class BuildStep extends BuildStepOutputAccessor {
inputs: Object.fromEntries(
Object.entries(this.inputById).map(([key, input]) => [
key,
{ value: input.getValue({ interpolationContext: this.getInterpolationContext() }) },
{
value: input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
}),
},
])
),
outputs: this.outputById,
@@ -438,25 +514,42 @@ export class BuildStep extends BuildStepOutputAccessor {
template: string,
inputs?: BuildStepInput[]
): string {
// Composite functions support only `${{ }}`; a literal `${ steps.x.y }` reaching bash is the accepted
// behavior, so skip legacy output interpolation for composite-function-scoped steps.
const skipLegacyOutputInterpolation = this.compositeFunctionScope !== undefined;
if (!inputs) {
return interpolateWithOutputs(
this.ctx.global.interpolate(template),
path => this.ctx.global.getStepOutputValue(path) ?? ''
);
const interpolatedWithGlobalContext = this.ctx.global.interpolate(template);
return skipLegacyOutputInterpolation
? interpolatedWithGlobalContext
: interpolateWithOutputs(interpolatedWithGlobalContext, path =>
this.getLegacyStepOutputValue(path)
);
}
const vars = inputs.reduce(
(acc, input) => {
const value = input.getValue({ interpolationContext: this.getInterpolationContext() });
const value = input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation,
});
acc[input.id] =
typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? '');
return acc;
},
{} as Record<string, string>
);
return interpolateWithOutputs(
interpolateWithInputs(this.ctx.global.interpolate(template), vars),
path => this.ctx.global.getStepOutputValue(path) ?? ''
const interpolatedWithInputsAndGlobalContext = interpolateWithInputs(
this.ctx.global.interpolate(template),
vars
);
return skipLegacyOutputInterpolation
? interpolatedWithInputsAndGlobalContext
: interpolateWithOutputs(interpolatedWithInputsAndGlobalContext, path =>
this.getLegacyStepOutputValue(path)
);
}
private getLegacyStepOutputValue(path: string): string {
return this.ctx.global.getStepOutputValue(path) ?? '';
}
private async collectAndValidateOutputsAsync(outputsDir: string): Promise<void> {
@@ -512,8 +605,32 @@ export class BuildStep extends BuildStepOutputAccessor {
});
}
private getInterpolatedEnvOverrides(): BuildStepEnv {
const ownOverrides = this.stepEnvOverrides;
if (!this.compositeFunctionScope) {
return ownOverrides;
}
// Use global env here to avoid recursing through getScriptEnv.
const base: JobInterpolationContext = {
...this.ctx.global.getInterpolationContext(),
env: this.ctx.global.env,
};
// Call-site env uses caller scope; step env uses action scope.
const inheritedEnv = this.compositeFunctionScope.resolveInheritedEnv(base);
const scoped = this.compositeFunctionScope.getScopedInterpolationContext(base);
const ownEnv = Object.fromEntries(
Object.entries(ownOverrides).map(([key, value]) => {
if (typeof value !== 'string' || !containsUnresolvedTemplateReference(value)) {
return [key, value];
}
return [key, stringifyInterpolatedResult(resolveInterpolatedTarget(value, scoped))];
})
);
return { ...inheritedEnv, ...ownEnv };
}
private getScriptEnv(): Record<string, string> {
const effectiveEnv = { ...this.ctx.global.env, ...this.stepEnvOverrides };
const effectiveEnv = { ...this.ctx.global.env, ...this.getInterpolatedEnvOverrides() };
const currentPath = effectiveEnv.PATH ?? process.env.PATH;
const newPath = currentPath ? `${BIN_PATH}:${currentPath}` : BIN_PATH;
return {
@@ -524,4 +641,24 @@ export class BuildStep extends BuildStepOutputAccessor {
PATH: newPath,
};
}
private resolveTemplatedWorkingDirectoryIfNeeded(): void {
const scope = this.compositeFunctionScope;
if (!scope) {
return;
}
const relativeWorkingDirectory = this.ctx.relativeWorkingDirectory;
if (
relativeWorkingDirectory === undefined ||
!containsUnresolvedTemplateReference(relativeWorkingDirectory)
) {
return;
}
const context = scope.getScopedInterpolationContext({
...this.ctx.global.getInterpolationContext(),
env: this.getScriptEnv(),
});
const resolved = resolveInterpolatedTarget(relativeWorkingDirectory, context);
this.ctx.updateRelativeWorkingDirectory(stringifyOptionalInterpolatedResult(resolved));
}
}
@@ -0,0 +1,196 @@
/**
* Runtime scope for one expanded composite function call.
*
* Composite functions are flattened into the workflow, but inner steps still need composite-function-local semantics:
* `${{ steps.read }}` must mean the inner `read` step, not a workflow step with the same id.
* This class overlays a `steps` view built from the call's own children on top of the global interpolation context.
* success()/failure() use global workflow status (GitHub composite action parity).
*/
import { JobInterpolationContext } from '@expo/eas-build-job';
import type { BuildStepOutputAccessor } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInput } from './BuildStepInput';
import { BuildStepRuntimeError } from './errors';
import {
resolveInterpolatedTarget,
stringifyInterpolatedResult,
} from './utils/compositeFunctionInterpolation';
export type EvaluateIfExpression = (
expression: string,
context: JobInterpolationContext
) => boolean;
type ScopedInterpolationContext = JobInterpolationContext & { inputs: Record<string, unknown> };
export class BuildStepCompositeFunctionScope {
public readonly parent?: BuildStepCompositeFunctionScope;
public readonly env?: BuildStepEnv;
private readonly ctx: BuildStepGlobalContext;
private readonly ifCondition?: string;
private readonly compositeFunctionPath: string;
private readonly inputs: Map<string, BuildStepInput>;
private readonly providedInputKeys: ReadonlySet<string>;
// Filled by the expander after construction; children and scope need each other.
private readonly childrenByLocalId: Map<string, BuildStepOutputAccessor>;
private cachedIsActive?: boolean;
// Detects cycles while resolving input default values.
private readonly resolvingInputs = new Set<string>();
constructor({
ctx,
parent,
ifCondition,
env,
compositeFunctionPath,
inputs,
providedInputKeys,
childrenByLocalId,
}: {
ctx: BuildStepGlobalContext;
parent?: BuildStepCompositeFunctionScope;
ifCondition?: string;
env?: BuildStepEnv;
compositeFunctionPath: string;
inputs: Map<string, BuildStepInput>;
providedInputKeys: ReadonlySet<string>;
childrenByLocalId: Map<string, BuildStepOutputAccessor>;
}) {
this.ctx = ctx;
this.parent = parent;
this.ifCondition = ifCondition;
this.env = env;
this.compositeFunctionPath = compositeFunctionPath;
this.inputs = inputs;
this.providedInputKeys = providedInputKeys;
this.childrenByLocalId = childrenByLocalId;
}
/**
* Walks the parent chain; the call-site `if` gates the whole composite function call, not individual inner steps.
* Memoized: global status can change mid-expansion, and re-evaluating would flip a passed
* success() gate and skip remaining always()/failure() inner steps.
*/
public isActive(evaluate: EvaluateIfExpression): boolean {
if (this.parent && !this.parent.isActive(evaluate)) {
return false;
}
this.cachedIsActive ??= this.evaluateCallIfCondition(evaluate);
return this.cachedIsActive;
}
// Call-site if uses caller env/inputs/steps, not expanded inner steps.
private evaluateCallIfCondition(evaluate: EvaluateIfExpression): boolean {
if (!this.ifCondition) {
return !this.ctx.hasAnyPreviousStepFailed;
}
const callerBase: JobInterpolationContext = {
...this.ctx.getInterpolationContext(),
env: this.ctx.env,
};
const env = { ...this.ctx.env, ...this.resolveInheritedEnv(callerBase) };
const baseContext = this.ctx.getIfConditionContext({
inputs: {},
env,
}) as JobInterpolationContext;
const context = this.parent
? this.parent.getScopedInterpolationContext(baseContext)
: baseContext;
return evaluate(this.ifCondition, context);
}
public getScopedInterpolationContext(base: JobInterpolationContext): JobInterpolationContext {
// Spread `base` into a new object; never spread the returned context, as that would eagerly
// resolve every lazy input getter.
const scoped: ScopedInterpolationContext = {
...base,
steps: this.buildStepsView(),
inputs: this.buildInputsObject(base),
};
return scoped;
}
/** Caller scope for `with:` values and call-site env. */
public getCallerInterpolationContext(base: JobInterpolationContext): JobInterpolationContext {
return this.parent ? this.parent.getScopedInterpolationContext(base) : base;
}
private resolveScopeEnv(base: JobInterpolationContext, parentEnv: BuildStepEnv): BuildStepEnv {
if (!this.env) {
return {};
}
const callerBase: JobInterpolationContext = { ...base, env: { ...base.env, ...parentEnv } };
const callerContext = this.getCallerInterpolationContext(callerBase);
return Object.fromEntries(
Object.entries(this.env).map(([key, value]) => [
key,
stringifyInterpolatedResult(resolveInterpolatedTarget(value, callerContext)),
])
);
}
/** Merge call-site env from each scope level, outer to inner; inner keys win. */
public resolveInheritedEnv(base: JobInterpolationContext): BuildStepEnv {
const parentEnv = this.parent?.resolveInheritedEnv(base) ?? {};
return { ...parentEnv, ...this.resolveScopeEnv(base, parentEnv) };
}
// Workflow hides prefixed ids; re-expose the call's children under their local ids.
private buildStepsView(): JobInterpolationContext['steps'] {
const view: JobInterpolationContext['steps'] = {};
for (const [localId, child] of this.childrenByLocalId) {
view[localId] = {
outputs: Object.fromEntries(child.outputs.map(output => [output.id, output.rawValue])),
};
}
return view;
}
private buildInputsObject(base: JobInterpolationContext): Record<string, unknown> {
const inputs: Record<string, unknown> = {};
for (const [name, input] of this.inputs) {
Object.defineProperty(inputs, name, {
enumerable: true,
configurable: true,
get: () => this.resolveInputValue(name, input, base),
});
}
return inputs;
}
private resolveInputValue(
name: string,
input: BuildStepInput,
base: JobInterpolationContext
): unknown {
if (this.resolvingInputs.has(name)) {
throw new BuildStepRuntimeError(
`Composite function "${this.compositeFunctionPath}" input "${name}" references itself, directly or indirectly, through its default value.`
);
}
this.resolvingInputs.add(name);
try {
const isProvided = this.providedInputKeys.has(name);
// Env binds at the call boundary so inner step `env:` overrides do not leak into inputs.
const boundBase: JobInterpolationContext = {
...base,
env: this.resolveCompositeFunctionBoundaryEnv(base),
};
// Caller-provided values resolve in the caller's scope; defaults resolve in this composite function's.
const interpolationContext = isProvided
? this.getCallerInterpolationContext(boundBase)
: this.getScopedInterpolationContext(boundBase);
return input.getValue({ interpolationContext, skipLegacyOutputInterpolation: true });
} finally {
this.resolvingInputs.delete(name);
}
}
// Global + inherited call-site env; inputs ignore inner overrides.
public resolveCompositeFunctionBoundaryEnv(base: JobInterpolationContext): BuildStepEnv {
const boundaryBase: JobInterpolationContext = { ...base, env: this.ctx.env };
return { ...this.ctx.env, ...this.resolveInheritedEnv(boundaryBase) };
}
}
+31 -12
View File
@@ -47,6 +47,8 @@ export interface ExternalBuildContextProvider {
export interface SerializedBuildStepGlobalContext {
stepsInternalBuildDirectory: string;
stepById: Record<string, SerializedBuildStepOutputAccessor>;
// Absent on older serialized payloads; treat as empty.
internalStepIds?: string[];
provider: SerializedExternalBuildContextProvider;
skipCleanup: boolean;
}
@@ -58,6 +60,8 @@ export class BuildStepGlobalContext {
private didCheckOut = false;
private _hasAnyPreviousStepFailed = false;
private stepById: Record<string, BuildStepOutputAccessor> = {};
// Prefixed expansion steps, omitted from the workflow steps view.
private internalStepIds = new Set<string>();
constructor(
private readonly provider: ExternalBuildContextProvider,
public readonly skipCleanup: boolean
@@ -91,19 +95,21 @@ export class BuildStepGlobalContext {
public get staticContext(): StaticJobInterpolationContext {
return {
...this.provider.staticContext(),
steps: Object.fromEntries(
Object.values(this.stepById).map(step => [
steps: this.buildStepsInterpolationMap(),
};
}
private buildStepsInterpolationMap(): StaticJobInterpolationContext['steps'] {
return Object.fromEntries(
Object.values(this.stepById)
.filter(step => !this.internalStepIds.has(step.id))
.map(step => [
step.id,
{
outputs: Object.fromEntries(
step.outputs.map(output => {
return [output.id, output.rawValue];
})
),
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output.rawValue])),
},
])
),
};
);
}
public updateEnv(updatedEnv: BuildStepEnv): void {
@@ -112,11 +118,14 @@ export class BuildStepGlobalContext {
public registerStep(step: BuildStep): void {
this.stepById[step.id] = step;
if (step.isCompositeFunctionInternal) {
this.internalStepIds.add(step.id);
}
}
public getStepOutputValue(path: string): string | undefined {
const { stepId, outputId } = parseOutputPath(path);
if (!(stepId in this.stepById)) {
if (!(stepId in this.stepById) || this.internalStepIds.has(stepId)) {
throw new BuildStepRuntimeError(`Step "${stepId}" does not exist.`);
}
return this.stepById[stepId].getOutputValueByName(outputId);
@@ -263,6 +272,7 @@ export class BuildStepGlobalContext {
stepById: Object.fromEntries(
Object.entries(this.stepById).map(([id, step]) => [id, step.serialize()])
),
internalStepIds: [...this.internalStepIds],
provider: {
projectSourceDirectory: this.provider.projectSourceDirectory,
projectTargetDirectory: this.provider.projectTargetDirectory,
@@ -295,6 +305,7 @@ export class BuildStepGlobalContext {
for (const [id, stepOutputAccessor] of Object.entries(serialized.stepById)) {
ctx.stepById[id] = BuildStepOutputAccessor.deserialize(stepOutputAccessor);
}
ctx.internalStepIds = new Set(serialized.internalStepIds ?? []);
ctx.stepsInternalBuildDirectory = serialized.stepsInternalBuildDirectory;
return ctx;
@@ -308,7 +319,7 @@ export interface SerializedBuildStepContext {
export class BuildStepContext {
public readonly logger: bunyan;
public readonly relativeWorkingDirectory?: string;
private _relativeWorkingDirectory?: string;
constructor(
private readonly ctx: BuildStepGlobalContext,
@@ -321,7 +332,15 @@ export class BuildStepContext {
}
) {
this.logger = logger ?? ctx.baseLogger;
this.relativeWorkingDirectory = relativeWorkingDirectory;
this._relativeWorkingDirectory = relativeWorkingDirectory;
}
public get relativeWorkingDirectory(): string | undefined {
return this._relativeWorkingDirectory;
}
public updateRelativeWorkingDirectory(value: string | undefined): void {
this._relativeWorkingDirectory = value;
}
public get global(): BuildStepGlobalContext {
+28 -4
View File
@@ -88,8 +88,10 @@ export class BuildStepInput<
public getValue({
interpolationContext,
skipLegacyOutputInterpolation = false,
}: {
interpolationContext: JobInterpolationContext;
skipLegacyOutputInterpolation?: boolean;
}): R extends true ? BuildStepInputValueType<T> : BuildStepInputValueType<T> | undefined {
const rawValue = this._value ?? this.defaultValue;
if (this.required && rawValue === undefined) {
@@ -124,10 +126,13 @@ export class BuildStepInput<
// so this will never be true.
assert(interpolatedValue !== undefined);
const valueInterpolatedWithGlobalContext = this.ctx.interpolate(interpolatedValue);
const valueInterpolatedWithOutputsAndGlobalContext = interpolateWithOutputs(
valueInterpolatedWithGlobalContext,
path => this.ctx.getStepOutputValue(path) ?? ''
);
// Composite functions support only `${{ }}`; legacy `${ steps.* }` stays literal in composite-function-scoped inputs.
const valueInterpolatedWithOutputsAndGlobalContext = skipLegacyOutputInterpolation
? valueInterpolatedWithGlobalContext
: interpolateWithOutputs(
valueInterpolatedWithGlobalContext,
path => this.ctx.getStepOutputValue(path) ?? ''
);
returnValue = this.parseInputValueToAllowedType(valueInterpolatedWithOutputsAndGlobalContext);
}
return returnValue;
@@ -231,6 +236,25 @@ export class BuildStepInput<
}
}
/** Raw allowed-values check; skips step/context refs that only resolve at runtime. */
export function getDisallowedInputValueError(
input: BuildStepInput,
stepDisplayName: string
): string | undefined {
if (input.rawValue === undefined) {
return undefined;
}
if (input.isRawValueStepOrContextReference() || input.isRawValueOneOfAllowedValues()) {
return undefined;
}
const rendered =
typeof input.rawValue === 'object' ? JSON.stringify(input.rawValue) : String(input.rawValue);
const allowedValues = (input.allowedValues ?? [])
.map(value => (typeof value === 'object' ? JSON.stringify(value) : `"${value}"`))
.join(', ');
return `Input parameter "${input.id}" for step "${stepDisplayName}" is set to "${rendered}" which is not one of the allowed values: ${allowedValues}.`;
}
export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById {
if (inputs === undefined) {
return {};
+7 -12
View File
@@ -3,7 +3,7 @@ import path from 'path';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStep } from './BuildStep';
import { BuildStepInputValueTypeName } from './BuildStepInput';
import { BuildStepInputValueTypeName, getDisallowedInputValueError } from './BuildStepInput';
// Type-only to keep the runtime module graph acyclic (BuildWorkflow imports
// hooks.ts, which imports this module for the shared aggregate checks).
import type { BuildWorkflow } from './BuildWorkflow';
@@ -128,17 +128,12 @@ function validateInputs(steps: readonly BuildStep[]): BuildConfigError[] {
if (currentStepInput.defaultValue === undefined) {
continue;
}
if (!currentStepInput.isRawValueOneOfAllowedValues()) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${
currentStep.displayName
}" is set to "${
currentStepInput.rawValue
}" which is not one of the allowed values: ${nullthrows(currentStepInput.allowedValues)
.map(i => `"${i}"`)
.join(', ')}.`
);
errors.push(error);
const disallowedValueError = getDisallowedInputValueError(
currentStepInput,
currentStep.displayName
);
if (disallowedValueError) {
errors.push(new BuildConfigError(disallowedValueError));
}
const paths =
typeof currentStepInput.defaultValue === 'string'
+109
View File
@@ -0,0 +1,109 @@
import { JobInterpolationContext } from '@expo/eas-build-job';
import { BuildStep, BuildStepFunction } from './BuildStep';
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepOutput } from './BuildStepOutput';
import {
resolveInterpolatedTarget,
stringifyInterpolatedResult,
} from './utils/compositeFunctionInterpolation';
/**
* Parse-time node for one composite function call (`uses: ./...`).
* Flattening contributes children (and this node only when it declares outputs);
* when it runs, it resolves output templates onto the caller's step id.
*/
export class CompositeBuildStep extends BuildStep {
public readonly children: BuildStep[];
constructor(
ctx: BuildStepGlobalContext,
{
id,
displayName,
scope,
children,
outputTemplates,
}: {
id: string;
displayName: string;
scope: BuildStepCompositeFunctionScope;
children: BuildStep[];
outputTemplates: Array<{ name: string; template: string }>;
}
) {
const outputs = outputTemplates.map(
({ name }) =>
new BuildStepOutput(ctx, {
id: name,
stepDisplayName: displayName,
required: true,
})
);
// Closes over locals only, never `this`: created before super() runs.
const fn: BuildStepFunction = (stepCtx, { outputs: outputById, env: stepEnv }) => {
const base: JobInterpolationContext = {
...stepCtx.global.getInterpolationContext(),
env: stepEnv,
};
const scopedContext = scope.getScopedInterpolationContext(base);
for (const { name, template } of outputTemplates) {
outputById[name].set(
stringifyInterpolatedResult(resolveInterpolatedTarget(template, scopedContext))
);
}
};
super(ctx, {
id,
displayName,
outputs,
fn,
// `scope` already applies the call-site `if`, so `always()` disables the default skip-on-failure.
ifCondition: '${{ always() }}',
compositeFunctionScope: scope,
});
this.children = children;
}
// Safe during super(): outputById is assigned before registerSelf() runs.
public get hasDeclaredOutputs(): boolean {
return this.outputs.length > 0;
}
/**
* Nested calls stay hidden; a top-level call is public only when it declares outputs.
* Consulted by registerSelf() in the base constructor; may only read state assigned
* before that call (outputById, compositeFunctionScope), never fields like `children`.
*/
public override get isCompositeFunctionInternal(): boolean {
return this.compositeFunctionScope?.parent !== undefined || !this.hasDeclaredOutputs;
}
// Skip the registry when there are no outputs so we do not shadow a public step that reuses this id.
protected override registerSelf(ctx: BuildStepGlobalContext): void {
if (this.hasDeclaredOutputs) {
super.registerSelf(ctx);
}
}
public getFlattenedSteps(): BuildStep[] {
const flattened = this.children.flatMap(child =>
child instanceof CompositeBuildStep ? child.getFlattenedSteps() : [child]
);
return this.hasDeclaredOutputs ? [...flattened, this] : flattened;
}
// Suppress the step-section log markers so log viewers do not render this bookkeeping node as a phantom step.
protected override logStepStart(): void {}
protected override logStepSuccess(): void {}
protected override logStepSkipped(): void {}
// Open a step section on failure so error lines do not bleed into the previous step.
protected override logStepFailed(error: Error): void {
super.logStepStart();
super.logStepFailed(error);
}
}
@@ -0,0 +1,448 @@
/**
* Expands local composite functions (`uses: ./path/to/function`) into a
* {@link CompositeBuildStep} tree at parse time.
*
* Each call becomes a node with prefixed child ids (`caller__inner`); the parser
* flattens the tree into the workflow. Expanded steps carry a
* {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and `${{ inputs.* }}`
* resolve against composite-function-local names.
*/
import {
CompositeFunctionCatalog,
CompositeFunctionConfig,
FunctionStep,
ShellStep,
Step,
isStepFunctionStep,
isStepShellStep,
} from '@expo/eas-build-job';
import { BuildFunctionById } from './BuildFunction';
import { BuildFunctionGroupById } from './BuildFunctionGroup';
import { BuildStep, BuildStepOutputAccessor } from './BuildStep';
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import {
BuildStepInput,
BuildStepInputValueTypeName,
getDisallowedInputValueError,
} from './BuildStepInput';
import { CompositeBuildStep } from './CompositeBuildStep';
import { BuildConfigError } from './errors';
import { duplicates } from './utils/expodash/duplicates';
import {
getLocalCompositeFunctionCallWorkingDirectoryError,
isLocalCompositeFunctionPath,
parseLocalCompositeFunctionPath,
} from './utils/localCompositeFunctions';
import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step';
const MAX_COMPOSITE_FUNCTION_NESTING_DEPTH = 10;
export type FunctionMaps = {
buildFunctionById: BuildFunctionById;
buildFunctionGroupById: BuildFunctionGroupById;
};
type CompositeFunctionCall = {
compositeFunctionPath: string;
/** Caller-assigned id used as prefix for all inner step ids. */
syntheticStepId: string;
name?: string;
/** Caller-provided input values, consumed when composite function inputs are interpolated. */
callWith?: Record<string, unknown>;
callIf?: string;
parentScope?: BuildStepCompositeFunctionScope;
inheritedEnv?: BuildStepEnv;
};
type StepOverrides = {
env?: BuildStepEnv;
workingDirectory?: string;
ifCondition?: string;
};
export class CompositeFunctionExpander {
constructor(
private readonly ctx: BuildStepGlobalContext,
private readonly compositeFunctionCatalog: CompositeFunctionCatalog,
private readonly functionMaps: FunctionMaps
) {}
public expandCompositeFunctionStep(
step: FunctionStep,
compositeFunctionPath: string,
syntheticStepId: string
): CompositeBuildStep {
this.rejectCompositeFunctionCallWorkingDirectory(step);
return this.expand(
{
compositeFunctionPath,
syntheticStepId,
name: step.name,
callWith: step.with,
callIf: step.if,
inheritedEnv: step.env,
},
new Set<string>()
);
}
// The call step expands away; `working_directory` on it would never apply.
private rejectCompositeFunctionCallWorkingDirectory(step: FunctionStep): void {
if (step.working_directory !== undefined) {
throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses));
}
}
// `visited.size` is the current composite function nesting depth.
private expand(call: CompositeFunctionCall, visited: ReadonlySet<string>): CompositeBuildStep {
const { compositeFunctionPath, syntheticStepId } = call;
this.guardAgainstRunawayRecursion(compositeFunctionPath, visited);
const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath);
const compositeFunctionDisplayName =
call.name ?? compositeFunction.name ?? compositeFunctionPath;
const innerSteps = compositeFunction.runs.steps;
const innerStepIds = this.buildInnerStepIdMap(
innerSteps,
syntheticStepId,
compositeFunctionPath
);
const { inputs, providedInputKeys } = this.buildCompositeFunctionInputs(
compositeFunction,
compositeFunctionPath,
call.callWith
);
const nestedVisited = new Set(visited).add(compositeFunctionPath);
const childrenByLocalId = new Map<string, BuildStepOutputAccessor>();
const scope = new BuildStepCompositeFunctionScope({
ctx: this.ctx,
parent: call.parentScope,
ifCondition: call.callIf,
env: call.inheritedEnv,
compositeFunctionPath,
inputs,
providedInputKeys,
childrenByLocalId,
});
const children = innerSteps.map((innerStep, index) => {
const { localId, newId } = innerStepIds[index];
const child = this.expandInnerStep(innerStep, newId, {
compositeFunctionPath,
scope,
nestedVisited,
});
// Omit output-less nested nodes: `${{ steps.mid }}` is undefined today,
// and `{ outputs: {} }` would make it truthy.
if (!(child instanceof CompositeBuildStep) || child.hasDeclaredOutputs) {
childrenByLocalId.set(localId, child);
}
return child;
});
const outputTemplates = Object.entries(compositeFunction.outputs ?? {}).map(
([name, output]) => ({ name, template: output.value })
);
return new CompositeBuildStep(this.ctx, {
id: syntheticStepId,
displayName: compositeFunctionDisplayName,
scope,
children,
outputTemplates,
});
}
private guardAgainstRunawayRecursion(
compositeFunctionPath: string,
visited: ReadonlySet<string>
): void {
if (visited.has(compositeFunctionPath)) {
const cyclePath = [...visited, compositeFunctionPath].join(' -> ');
throw new BuildConfigError(
`Detected a cycle while expanding composite functions: ${cyclePath}. A composite function cannot reference itself, directly or indirectly.`
);
}
if (visited.size >= MAX_COMPOSITE_FUNCTION_NESTING_DEPTH) {
throw new BuildConfigError(
`Maximum composite function nesting depth (${MAX_COMPOSITE_FUNCTION_NESTING_DEPTH}) exceeded while expanding composite function "${compositeFunctionPath}".`
);
}
}
private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig {
const compositeFunction = this.compositeFunctionCatalog[compositeFunctionPath];
if (!compositeFunction) {
throw new BuildConfigError(
`Local composite function "${compositeFunctionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${compositeFunctionPath}" relative to the EAS project root (convention: ".eas/functions/<name>").`
);
}
return compositeFunction;
}
private expandInnerStep(
innerStep: Step,
newId: string,
{
compositeFunctionPath,
scope,
nestedVisited,
}: {
compositeFunctionPath: string;
scope: BuildStepCompositeFunctionScope;
nestedVisited: ReadonlySet<string>;
}
): BuildStep {
const overrides = this.resolveStepOverrides(innerStep);
if (isStepFunctionStep(innerStep)) {
if (isLocalCompositeFunctionPath(innerStep.uses)) {
this.rejectCompositeFunctionCallWorkingDirectory(innerStep);
return this.expandNestedCompositeFunctionCall(innerStep, {
compositeFunctionPath: parseLocalCompositeFunctionPath(innerStep.uses),
newId,
overrides,
scope,
visited: nestedVisited,
});
}
return this.createExpandedFunctionStep(innerStep, {
newId,
overrides,
scope,
compositeFunctionPath,
});
}
if (isStepShellStep(innerStep)) {
return this.createExpandedShellStep(innerStep, { newId, overrides, scope });
}
throw new BuildConfigError(
`Invalid step configuration in composite function "${compositeFunctionPath}". Step must be a shell or function step.`
);
}
private resolveStepOverrides(innerStep: Step): StepOverrides {
return {
env: innerStep.env,
ifCondition: innerStep.if,
workingDirectory: innerStep.working_directory,
};
}
private expandNestedCompositeFunctionCall(
innerStep: FunctionStep,
{
compositeFunctionPath,
newId,
overrides,
scope,
visited,
}: {
compositeFunctionPath: string;
newId: string;
overrides: StepOverrides;
scope: BuildStepCompositeFunctionScope;
visited: ReadonlySet<string>;
}
): CompositeBuildStep {
return this.expand(
{
compositeFunctionPath,
syntheticStepId: newId,
name: innerStep.name,
callWith: innerStep.with,
callIf: overrides.ifCondition,
parentScope: scope,
inheritedEnv: overrides.env,
},
visited
);
}
private createExpandedShellStep(
step: ShellStep,
{
newId,
overrides,
scope,
}: {
newId: string;
overrides: StepOverrides;
scope: BuildStepCompositeFunctionScope;
}
): BuildStep {
const command = step.run;
const displayName = getShellStepDisplayName(step);
const outputs =
step.outputs && createBuildStepOutputsFromDefinition(this.ctx, step.outputs, displayName);
return new BuildStep(this.ctx, {
id: newId,
displayName,
outputs,
workingDirectory: overrides.workingDirectory,
shell: step.shell,
command,
env: overrides.env,
ifCondition: overrides.ifCondition,
compositeFunctionScope: scope,
__metricsId: step.__metrics_id,
});
}
private createExpandedFunctionStep(
step: FunctionStep,
{
newId,
overrides,
scope,
compositeFunctionPath,
}: {
newId: string;
overrides: StepOverrides;
scope: BuildStepCompositeFunctionScope;
compositeFunctionPath: string;
}
): BuildStep {
const functionId = step.uses;
const { buildFunctionById, buildFunctionGroupById } = this.functionMaps;
const maybeFunctionGroup = buildFunctionGroupById[functionId];
if (maybeFunctionGroup) {
throw new BuildConfigError(
`Function group "${functionId}" cannot be used inside a composite function. Function groups expand to multiple steps with their own ids and do not support composite-function-level id, env, if, or working_directory overrides. Use individual function steps instead.`
);
}
const buildFunction = buildFunctionById[functionId];
if (!buildFunction) {
throw new BuildConfigError(
`Composite function "${compositeFunctionPath}" calls non-existent function "${functionId}".`
);
}
const callInputs = step.with as Record<string, unknown> | undefined;
return buildFunction.createBuildStepFromFunctionCall(this.ctx, {
id: newId,
name: step.name,
callInputs,
workingDirectory: overrides.workingDirectory,
shell: step.shell,
env: overrides.env,
ifCondition: overrides.ifCondition,
compositeFunctionScope: scope,
});
}
/**
* Assigns globally unique ids to inner steps. Without namespacing, two
* composite function calls with inner step `read` would collide.
*/
private buildInnerStepIdMap(
innerSteps: Step[],
syntheticStepId: string,
compositeFunctionPath: string
): Array<{ localId: string; newId: string }> {
const declaredIdList = innerSteps.map(step => step.id).filter((id): id is string => !!id);
const duplicatedIds = duplicates(declaredIdList);
if (duplicatedIds.length > 0) {
throw new BuildConfigError(
`Composite function "${compositeFunctionPath}" declares duplicated step IDs: ${duplicatedIds
.map(id => `"${id}"`)
.join(', ')}. Step IDs within a composite function must be unique.`
);
}
const declaredIds = new Set(declaredIdList);
let generatedStepIdCounter = 0;
return innerSteps.map(innerStep => {
let localId = innerStep.id;
if (!localId) {
do {
localId = `composite_function_step_${++generatedStepIdCounter}`;
} while (declaredIds.has(localId));
}
return { localId, newId: `${syntheticStepId}__${localId}` };
});
}
// Nullish `with` values are treated as absent so defaults resolve in the composite function's own scope.
private buildCompositeFunctionInputs(
compositeFunction: CompositeFunctionConfig,
compositeFunctionPath: string,
callWith: Record<string, unknown> | undefined
): { inputs: Map<string, BuildStepInput>; providedInputKeys: Set<string> } {
const inputs = new Map<string, BuildStepInput>();
const providedInputKeys = new Set<string>();
for (const declared of compositeFunction.inputs ?? []) {
const definition =
typeof declared === 'string'
? {
name: declared,
type: 'string',
required: false,
allowedValues: undefined as unknown[] | undefined,
defaultValue: undefined as unknown,
}
: {
name: declared.name,
type: declared.type,
required: declared.required ?? false,
allowedValues: declared.allowed_values,
defaultValue: declared.default_value,
};
const input = new BuildStepInput(this.ctx, {
id: definition.name,
stepDisplayName: compositeFunctionPath,
defaultValue: definition.defaultValue,
required: definition.required,
allowedValues: definition.allowedValues,
allowedValueTypeName: this.toInputValueTypeName(
definition.type,
compositeFunctionPath,
definition.name
),
});
if (callWith && Object.prototype.hasOwnProperty.call(callWith, definition.name)) {
const value = callWith[definition.name];
if (value !== undefined) {
input.set(value);
}
if (value != null) {
providedInputKeys.add(definition.name);
}
}
if (definition.required && input.rawValue === undefined) {
throw new BuildConfigError(
`Input parameter "${definition.name}" for step "${compositeFunctionPath}" is required but it was not set.`
);
}
const disallowedValueError = getDisallowedInputValueError(input, compositeFunctionPath);
if (disallowedValueError) {
throw new BuildConfigError(disallowedValueError);
}
inputs.set(definition.name, input);
}
return { inputs, providedInputKeys };
}
private toInputValueTypeName(
type: string,
compositeFunctionPath: string,
inputName: string
): BuildStepInputValueTypeName {
const supported = Object.values(BuildStepInputValueTypeName) as string[];
if (!supported.includes(type)) {
throw new BuildConfigError(
`Composite function "${compositeFunctionPath}" input "${inputName}" has unsupported type "${type}". Supported types: ${supported.join(
', '
)}.`
);
}
return type as BuildStepInputValueTypeName;
}
}
+87 -27
View File
@@ -1,4 +1,5 @@
import {
CompositeFunctionCatalog,
FunctionStep,
HookAnchorId,
Hooks,
@@ -12,6 +13,7 @@ import {
import assert from 'node:assert';
import { AbstractConfigParser } from './AbstractConfigParser';
import { CompositeFunctionExpander, FunctionMaps } from './CompositeFunctionExpander';
import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction';
import {
BuildFunctionGroup,
@@ -28,10 +30,16 @@ import {
createBuildStepFromShellStep,
validateAllStepFunctionsExist,
} from './hooks';
import {
isLocalCompositeFunctionPath,
parseLocalCompositeFunctionPath,
} from './utils/localCompositeFunctions';
export class StepsConfigParser extends AbstractConfigParser {
private readonly steps: Step[];
private readonly hooks: Hooks;
/** Pre-loaded composite function configs keyed by normalized path (e.g. `./.eas/functions/setup`). */
private readonly compositeFunctionCatalog: CompositeFunctionCatalog;
constructor(
ctx: BuildStepGlobalContext,
@@ -40,6 +48,7 @@ export class StepsConfigParser extends AbstractConfigParser {
hooks,
externalFunctions,
externalFunctionGroups,
compositeFunctionCatalog,
}: {
steps: Step[];
// Required (not `hooks?:`) so a call site cannot silently forget to pass
@@ -47,6 +56,7 @@ export class StepsConfigParser extends AbstractConfigParser {
hooks: Hooks | undefined;
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
compositeFunctionCatalog?: CompositeFunctionCatalog;
}
) {
super(ctx, {
@@ -56,6 +66,7 @@ export class StepsConfigParser extends AbstractConfigParser {
this.steps = steps;
this.hooks = hooks ?? {};
this.compositeFunctionCatalog = compositeFunctionCatalog ?? {};
}
protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
@@ -76,6 +87,14 @@ export class StepsConfigParser extends AbstractConfigParser {
const buildFunctionGroupById = createBuildFunctionGroupByIdMapping(
this.externalFunctionGroups ?? []
);
const compositeFunctionExpander = new CompositeFunctionExpander(
this.ctx,
this.compositeFunctionCatalog,
{
buildFunctionById,
buildFunctionGroupById,
}
);
// Only the job's own steps are scanned — steps constructed from hooks are
// never treated as anchors (no nesting). Construction order (before →
@@ -85,9 +104,10 @@ export class StepsConfigParser extends AbstractConfigParser {
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
for (const stepConfig of validatedSteps) {
const maybeFunctionGroup = isStepFunctionStep(stepConfig)
? buildFunctionGroupById[stepConfig.uses]
: undefined;
const maybeFunctionGroup =
isStepFunctionStep(stepConfig) && !isLocalCompositeFunctionPath(stepConfig.uses)
? buildFunctionGroupById[stepConfig.uses]
: undefined;
if (maybeFunctionGroup !== undefined) {
// The group expands FIRST (its internal steps get their ids), then the
// anchors found among expanded steps get their hook steps constructed.
@@ -113,14 +133,36 @@ export class StepsConfigParser extends AbstractConfigParser {
continue;
}
const maps = { buildFunctionById, buildFunctionGroupById };
const anchorId = StepsConfigParser.resolveStepAnchor(stepConfig, buildFunctionById);
if (anchorId === undefined) {
buildSteps.push(this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById));
buildSteps.push(
...this.createBuildStepsFromNonGroupStepConfig(
stepConfig,
maps,
compositeFunctionExpander
)
);
continue;
}
const maps = { buildFunctionById, buildFunctionGroupById };
// Rejected regardless of expansion size: the anchor would land on an
// expanded inner step, and hooks never fire inside a composite function.
if (isStepFunctionStep(stepConfig) && isLocalCompositeFunctionPath(stepConfig.uses)) {
throw new BuildConfigError(
'Hook anchors are not supported on local composite function steps.'
);
}
const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps);
const anchorStep = this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById);
const createdSteps = this.createBuildStepsFromNonGroupStepConfig(
stepConfig,
maps,
compositeFunctionExpander
);
assert(
createdSteps.length === 1,
'a non-composite step config must create exactly one build step'
);
const anchorStep = createdSteps[0];
buildSteps.push(anchorStep);
const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps);
if (before.length > 0 || after.length > 0) {
@@ -214,36 +256,54 @@ export class StepsConfigParser extends AbstractConfigParser {
return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, maps);
}
private createBuildStepFromNonGroupStepConfig(
private createBuildStepsFromNonGroupStepConfig(
stepConfig: Step,
buildFunctionById: BuildFunctionById
): BuildStep {
maps: FunctionMaps,
compositeFunctionExpander: CompositeFunctionExpander
): BuildStep[] {
if (isStepShellStep(stepConfig)) {
return createBuildStepFromShellStep(this.ctx, stepConfig);
} else if (isStepFunctionStep(stepConfig)) {
return this.createBuildStepFromFunctionStepConfig(stepConfig, buildFunctionById);
} else {
throw new BuildConfigError(
'Invalid job step configuration detected. Step must be shell or function step'
return [createBuildStepFromShellStep(this.ctx, stepConfig)];
}
if (isStepFunctionStep(stepConfig)) {
return this.createBuildStepsFromFunctionStepConfig(
stepConfig,
maps,
compositeFunctionExpander
);
}
throw new BuildConfigError(
'Invalid job step configuration detected. Step must be shell or function step'
);
}
private createBuildStepFromFunctionStepConfig(
private createBuildStepsFromFunctionStepConfig(
step: FunctionStep,
buildFunctionById: BuildFunctionById
): BuildStep {
{ buildFunctionById }: FunctionMaps,
compositeFunctionExpander: CompositeFunctionExpander
): BuildStep[] {
if (isLocalCompositeFunctionPath(step.uses)) {
return compositeFunctionExpander
.expandCompositeFunctionStep(
step,
parseLocalCompositeFunctionPath(step.uses),
BuildStep.getNewId(step.id)
)
.getFlattenedSteps();
}
const buildFunction = buildFunctionById[step.uses];
assert(buildFunction, 'function ID must be ID of function or function group');
return buildFunction.createBuildStepFromFunctionCall(this.ctx, {
id: step.id,
name: step.name,
callInputs: step.with,
workingDirectory: step.working_directory,
shell: step.shell,
env: step.env,
ifCondition: step.if,
});
return [
buildFunction.createBuildStepFromFunctionCall(this.ctx, {
id: step.id,
name: step.name,
callInputs: step.with,
workingDirectory: step.working_directory,
shell: step.shell,
env: step.env,
ifCondition: step.if,
}),
];
}
}
@@ -1042,6 +1042,40 @@ describe(validateAllFunctionsExist, () => {
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
}).toThrowError(/Calling non-existent functions: "eas\/build"/);
});
test('rejects local composite function references with a dedicated error', () => {
const buildConfig: BuildConfig = {
build: {
steps: ['./.eas/functions/my-function'],
},
};
expect(() => {
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
}).toThrowError(
/Local composite functions \("\.\/\.eas\/functions\/my-function"\) are not supported in "\.eas\/build\/\*\.yml" custom builds\. Local composite functions can only be used in EAS workflows \("\.eas\/workflows\/\*\.yml"\)\./
);
});
test('rejects object-form local composite function references with a dedicated error', () => {
const buildConfig: BuildConfig = {
build: {
steps: [
{
'./.eas/functions/my-function': {
inputs: {
foo: 'bar',
},
},
},
],
},
};
expect(() => {
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
}).toThrow(
/Local composite functions \("\.\/\.eas\/functions\/my-function"\) are not supported in "\.eas\/build\/\*\.yml" custom builds\. Local composite functions can only be used in EAS workflows \("\.eas\/workflows\/\*\.yml"\)\./
);
});
test('non-existent namespaced functions with skipNamespacedFunctionsOrFunctionGroupsCheck = false', () => {
const buildConfig: BuildConfig = {
build: {
@@ -10,10 +10,12 @@ import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
import { BuildFunction } from '../BuildFunction';
import { BuildRuntimePlatform } from '../BuildRuntimePlatform';
import { BuildStep, BuildStepFunction, BuildStepStatus } from '../BuildStep';
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
import { BuildStepContext, BuildStepGlobalContext } from '../BuildStepContext';
import { BuildStepEnv } from '../BuildStepEnv';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { BuildWorkflow } from '../BuildWorkflow';
import { BuildStepRuntimeError } from '../errors';
import { nullthrows } from '../utils/nullthrows';
import { spawnAsync } from '../utils/shell/spawn';
@@ -630,6 +632,46 @@ describe(BuildStep, () => {
expect(error).toBeInstanceOf(BuildStepRuntimeError);
expect(error.message).toMatch(/Some required outputs have not been set: "abc"/);
});
it('skips a later default-gated inner step when a required output is missing on an earlier one', async () => {
const compositeFunctionScope = new BuildStepCompositeFunctionScope({
ctx: baseStepCtx,
compositeFunctionPath: 'test-action',
inputs: new Map(),
providedInputKeys: new Set(),
childrenByLocalId: new Map(),
});
const firstStep = new BuildStep(baseStepCtx, {
id: 'first',
displayName: 'first',
command: 'echo 123',
outputs: [
new BuildStepOutput(baseStepCtx, {
id: 'abc',
stepDisplayName: 'first',
required: true,
}),
],
compositeFunctionScope,
});
const secondStep = new BuildStep(baseStepCtx, {
id: 'second',
displayName: 'second',
command: 'echo 456',
compositeFunctionScope,
});
// Via workflow so recordFailure marks global status; second step then skips.
const workflow = new BuildWorkflow(baseStepCtx, {
buildSteps: [firstStep, secondStep],
buildFunctions: {},
});
const error = await getErrorAsync<BuildStepRuntimeError>(() => workflow.executeAsync());
expect(error.message).toMatch(/Some required outputs have not been set: "abc"/);
expect(secondStep.status).toBe(BuildStepStatus.SKIPPED);
});
});
describe('fn', () => {
@@ -1120,6 +1162,26 @@ describe(BuildStep.deserialize, () => {
});
describe(BuildStep.prototype.shouldExecuteStep, () => {
it('does not evaluate inputs for a step without an if condition after a failure', () => {
const ctx = createGlobalContextMock();
ctx.markAsFailed();
const step = new BuildStep(ctx, {
id: 'test1',
displayName: 'Test 1',
command: 'echo 123',
inputs: [
new BuildStepInput(ctx, {
id: 'required',
stepDisplayName: 'Test 1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
});
expect(step.shouldExecuteStep()).toBe(false);
});
it('returns true when if condition is always and previous steps failed', () => {
const ctx = createGlobalContextMock();
ctx.markAsFailed();
@@ -0,0 +1,56 @@
import { JobInterpolationContext } from '@expo/eas-build-job';
import { instance, mock, when } from 'ts-mockito';
import { createGlobalContextMock } from './utils/context';
import { BuildStep } from '../BuildStep';
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { interpolateJobContext } from '../interpolation';
describe(BuildStepCompositeFunctionScope, () => {
const baseContext = {} as unknown as JobInterpolationContext;
function makeScope(): BuildStepCompositeFunctionScope {
const ctx = createGlobalContextMock();
const versionOutput = mock<BuildStepOutput>();
when(versionOutput.id).thenReturn('version');
when(versionOutput.rawValue).thenReturn('1.0.0');
const innerStep = mock<BuildStep>();
when(innerStep.outputs).thenReturn([instance(versionOutput)]);
const greeting = new BuildStepInput(ctx, {
id: 'greeting',
stepDisplayName: 'test-action',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
});
greeting.set('hello');
return new BuildStepCompositeFunctionScope({
ctx,
compositeFunctionPath: 'test-action',
inputs: new Map([['greeting', greeting]]),
providedInputKeys: new Set(['greeting']),
childrenByLocalId: new Map([['build', instance(innerStep)]]),
});
}
it('exposes declared inputs and composite-function-local step aliases in the interpolation context', () => {
const scope = makeScope();
const context = scope.getScopedInterpolationContext(baseContext);
expect(interpolateJobContext({ target: '${{ inputs.greeting }}', context })).toBe('hello');
expect(interpolateJobContext({ target: '${{ steps.build.outputs.version }}', context })).toBe(
'1.0.0'
);
});
it('returns undefined for references outside the composite function scope', () => {
const scope = makeScope();
const context = scope.getScopedInterpolationContext(baseContext);
expect(interpolateJobContext({ target: '${{ inputs.gretting }}', context })).toBeUndefined();
expect(
interpolateJobContext({ target: '${{ steps.checkout.outputs.sha }}', context })
).toBeUndefined();
});
});
@@ -172,6 +172,7 @@ describe(BuildStepGlobalContext, () => {
const mockStep = mock<BuildStep>();
when(mockStep.id).thenReturn('abc');
when(mockStep.isCompositeFunctionInternal).thenReturn(false);
when(mockStep.getOutputValueByName('def')).thenReturn('ghi');
const step = instance(mockStep);
@@ -0,0 +1,334 @@
import assert from 'node:assert';
import {
SETUP,
echoFunction,
parseCompositeFunctions,
} from './StepsConfigParser-composite-functions-test-utils';
import { getErrorAsync } from './utils/error';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildConfigError, BuildWorkflowError } from '../errors';
describe('StepsConfigParser local composite functions', () => {
describe('expansion', () => {
it('expands a single-level composite function and leaves input templates raw', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
name: 'Setup',
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
runs: {
steps: [
{ id: 'read', run: 'set-output version "1.0.0"' },
{ run: 'echo "${{ inputs.greeting }}"' },
],
},
},
},
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }],
});
expect(workflow.buildSteps).toHaveLength(2);
const [readStep, echoStep] = workflow.buildSteps;
expect(readStep.id).toBe('setup__read');
expect(readStep.displayName).toBe('read');
expect(echoStep.id).toBe('setup__composite_function_step_1');
expect(echoStep.command).toBe('echo "${{ inputs.greeting }}"');
});
it('generates a synthetic id when the caller step has no id', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
name: 'Setup',
runs: {
steps: [
{ id: 'read', run: 'set-output version "1.0.0"' },
{ id: 'echo', run: 'echo "${{ steps.read.outputs.version }}"' },
],
},
},
},
steps: [{ uses: SETUP }],
});
const [readStep, echoStep] = workflow.buildSteps;
const syntheticStepId = readStep.id.split('__')[0];
expect(workflow.buildSteps).toHaveLength(2);
expect(syntheticStepId).toMatch(/^step-\d{3,}$/);
expect(readStep.id).toBe(`${syntheticStepId}__read`);
expect(echoStep.command).toBe('echo "${{ steps.read.outputs.version }}"');
});
it('keeps templated inner step names raw at parse time', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [{ name: 'env', type: 'string', default_value: 'staging' }],
runs: {
steps: [{ name: 'Deploy ${{ inputs.env }}', run: 'echo deploy' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup', with: { env: 'production' } }],
});
expect(workflow.buildSteps[0].displayName).toBe('Deploy ${{ inputs.env }}');
});
it('derives the fallback display name of an unnamed inner shell step from the raw run', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [{ name: 'target', type: 'string', default_value: 'build' }],
runs: {
steps: [{ run: '${{ inputs.target }} do-thing' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup', with: { target: 'release' } }],
});
const [innerStep] = workflow.buildSteps;
expect(innerStep.command).toBe('${{ inputs.target }} do-thing');
expect(innerStep.displayName).toBe('${{ inputs.target }} do-thing');
});
it('expands nested composites with accumulated prefixes', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.eas/functions/outer': {
runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] },
},
'./.eas/functions/inner': {
runs: { steps: [{ id: 'leaf', run: 'echo leaf' }] },
},
},
steps: [{ uses: './.eas/functions/outer', id: 'top' }],
});
expect(workflow.buildSteps.map(s => s.id)).toEqual(['top__mid__leaf']);
});
it('expands a composite function defined at the project root ("uses: ./")', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.': {
name: 'Root function',
runs: { steps: [{ id: 'root-step', run: 'echo root' }] },
},
},
steps: [{ uses: './', id: 'root' }],
});
expect(workflow.buildSteps.map(s => s.id)).toEqual(['root__root-step']);
});
it('expands a composite function defined at the project root parent ("uses: ../")', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'..': {
runs: { steps: [{ id: 'parent-step', run: 'echo parent' }] },
},
},
steps: [{ uses: '../', id: 'parent' }],
});
expect(workflow.buildSteps.map(s => s.id)).toEqual(['parent__parent-step']);
});
it('avoids collisions between generated inner step ids and declared inner step ids', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
runs: {
steps: [
{ run: 'echo first (no id)' },
{ id: 'composite_function_step_1', run: 'echo second (declared)' },
],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
});
expect(workflow.buildSteps.map(s => s.id)).toEqual([
'setup__composite_function_step_2',
'setup__composite_function_step_1',
]);
});
});
describe('resolution errors', () => {
it('throws a clear error for an unknown composite function', async () => {
await expect(
parseCompositeFunctions({
steps: [{ uses: './.eas/functions/missing', id: 'x' }],
})
).rejects.toThrow(/Local composite function ".\/.eas\/functions\/missing"/);
});
it.each([
[
'direct self-reference',
{
'./.eas/functions/loop': {
runs: { steps: [{ uses: './.eas/functions/loop', id: 'again' }] },
},
},
[{ uses: './.eas/functions/loop', id: 'loop' }],
],
[
'indirect reference',
{
'./.eas/functions/a': { runs: { steps: [{ uses: './.eas/functions/b', id: 'b' }] } },
'./.eas/functions/b': { runs: { steps: [{ uses: './.eas/functions/a', id: 'a' }] } },
},
[{ uses: './.eas/functions/a', id: 'a' }],
],
])('detects %s cycles', async (_, catalog, steps) => {
await expect(parseCompositeFunctions({ catalog, steps })).rejects.toThrow(/cycle/i);
});
it('errors when a composite function declares duplicated inner step ids', async () => {
const error = await getErrorAsync<BuildConfigError>(() =>
parseCompositeFunctions({
catalog: {
[SETUP]: {
runs: {
steps: [
{ run: 'echo one', id: 'dup' },
{ run: 'echo two', id: 'dup' },
],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
})
);
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toBe(
`Composite function "${SETUP}" declares duplicated step IDs: "dup". Step IDs within a composite function must be unique.`
);
});
it('errors when an inner step references a non-existent function', async () => {
const error = await getErrorAsync<BuildConfigError>(() =>
parseCompositeFunctions({
catalog: {
'./.eas/functions/wrap': {
runs: { steps: [{ uses: 'eas/typo', id: 'echo' }] },
},
},
steps: [{ uses: './.eas/functions/wrap', id: 'wrap' }],
externalFunctions: [echoFunction()],
})
);
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toBe(
'Composite function "./.eas/functions/wrap" calls non-existent function "eas/typo".'
);
});
it('errors when an inner step uses a function group', async () => {
await expect(
parseCompositeFunctions({
catalog: {
'./.eas/functions/wrap': {
runs: { steps: [{ uses: 'eas/build', id: 'build' }] },
},
},
steps: [{ uses: './.eas/functions/wrap', id: 'wrap' }],
externalFunctionGroups: [
new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
createBuildStepsFromFunctionGroupCall: () => [],
}),
],
})
).rejects.toThrow(/Function group "eas\/build" cannot be used inside a composite function/);
});
it('allows composite function chains at the maximum nesting depth without a cycle', async () => {
const entries: Record<string, unknown> = {};
for (let i = 0; i < 10; i++) {
entries[`./.eas/functions/a${i}`] =
i < 9
? { runs: { steps: [{ uses: `./.eas/functions/a${i + 1}`, id: `s${i}` }] } }
: { runs: { steps: [{ run: 'echo leaf' }] } };
}
const workflow = await parseCompositeFunctions({
catalog: entries,
steps: [{ uses: './.eas/functions/a0', id: 'top' }],
});
expect(workflow).toBeDefined();
});
it('throws when composite function nesting exceeds the maximum depth without a cycle', async () => {
const entries: Record<string, unknown> = {};
for (let i = 0; i <= 10; i++) {
entries[`./.eas/functions/a${i}`] =
i < 10
? { runs: { steps: [{ uses: `./.eas/functions/a${i + 1}`, id: `s${i}` }] } }
: { runs: { steps: [{ run: 'echo leaf' }] } };
}
const error = await getErrorAsync<BuildConfigError>(() =>
parseCompositeFunctions({
catalog: entries,
steps: [{ uses: './.eas/functions/a0', id: 'top' }],
})
);
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/Maximum composite function nesting depth \(10\) exceeded/);
});
});
describe('step id collisions', () => {
it('reports a clear error when a user step id collides with an expanded composite function step id', async () => {
const error = await getErrorAsync<BuildWorkflowError>(() =>
parseCompositeFunctions({
catalog: { [SETUP]: { runs: { steps: [{ id: 'read', run: 'true' }] } } },
steps: [
{ id: 'setup__read', run: 'true' },
{ uses: SETUP, id: 'setup' },
],
})
);
expect(error).toBeInstanceOf(BuildWorkflowError);
assert(error instanceof BuildWorkflowError);
expect(error.errors[0].message).toBe('Duplicated step IDs: "setup__read"');
});
});
describe('expression tokenization', () => {
it('substitutes inputs adjacent to arithmetic operators in inner run commands', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [
{ name: 'total', type: 'number', default_value: 10 },
{ name: 'count', type: 'number', default_value: 2 },
],
runs: {
steps: [{ run: 'echo ${{ inputs.total/inputs.count }}' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
});
expect(workflow.buildSteps[0].command).toBe('echo ${{ inputs.total/inputs.count }}');
});
it('does not truncate inner expressions at }} inside string literals', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [{ name: 'platform', type: 'string', default_value: 'android' }],
runs: {
steps: [{ run: 'echo hi', if: '${{ contains(inputs.platform, "a}}b") }}' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
});
expect(workflow.buildSteps[0].ifCondition).toBe('${{ contains(inputs.platform, "a}}b") }}');
});
});
});
@@ -0,0 +1,222 @@
import {
SETUP,
actionReadingInput,
echoInputAction,
parseCompositeFunctions,
passThroughFunction,
} from './StepsConfigParser-composite-functions-test-utils';
import { getErrorAsync } from './utils/error';
import { BuildConfigError, BuildStepRuntimeError } from '../errors';
describe('StepsConfigParser local composite functions', () => {
describe('input handling', () => {
it('uses the composite function input default when the caller omits the value', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: echoInputAction('greeting', {
name: 'greeting',
type: 'string',
default_value: 'hello',
}),
},
steps: [{ uses: SETUP, id: 'setup' }],
});
expect(workflow.buildSteps[0].command).toBe('echo "${{ inputs.greeting }}"');
});
it.each([
[
'boolean',
{ name: 'enabled', type: 'boolean' },
{ enabled: true },
'echo "${{ inputs.enabled }}"',
],
['number', { name: 'count', type: 'number' }, { count: 3 }, 'echo "${{ inputs.count }}"'],
])(
'accepts an explicit %s value, leaving the command raw',
async (_typeLabel, inputDef, withInputs, expectedCommand) => {
const workflow = await parseCompositeFunctions({
catalog: { [SETUP]: echoInputAction(inputDef.name, inputDef) },
steps: [{ uses: SETUP, id: 'setup', with: withInputs }],
});
expect(workflow.buildSteps[0].command).toBe(expectedCommand);
}
);
it('ignores unknown caller inputs, matching function-step behavior', async () => {
const workflow = await parseCompositeFunctions({
catalog: { [SETUP]: actionReadingInput({ name: 'greeting', type: 'string' }) },
steps: [{ uses: SETUP, id: 'setup', with: { greetng: 'hi' } }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
).toBe('');
});
it.each([
[
'never referenced',
{
inputs: [{ name: 'token', type: 'string', required: true }],
runs: { steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: 'static' } }] },
},
],
['referenced', actionReadingInput({ name: 'token', type: 'string', required: true })],
])('rejects at parse time a missing required input that is %s', async (_, actionConfig) => {
const error = await getErrorAsync<BuildConfigError>(() =>
parseCompositeFunctions({
catalog: { [SETUP]: actionConfig },
steps: [{ uses: SETUP, id: 'setup' }],
externalFunctions: [passThroughFunction()],
})
);
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(
/Input parameter "token" for step ".+" is required but it was not set/
);
});
// Required-ness is a static presence check, like for regular function steps.
// Whether a provided interpolation resolves to a value is not validated.
it('treats a required input set to an interpolation as provided, even one resolving to undefined', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: actionReadingInput({ name: 'token', type: 'string', required: true }),
},
steps: [{ uses: SETUP, id: 'setup', with: { token: '${{ env.UNSET_VAR }}' } }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
).toBe('');
});
it.each([
[
'a provided input has the wrong type',
actionReadingInput({ name: 'count', type: 'number' }),
[{ uses: SETUP, id: 'setup', with: { count: 'two' } }],
/Input parameter "count" for step ".+" must be of type "number"/,
],
[
'a string does not parse as JSON for a json input',
actionReadingInput({ name: 'config', type: 'json' }),
[{ uses: SETUP, id: 'setup', with: { config: 'literal' } }],
/Input parameter "config" for step ".+" must be of type "json"/,
],
])('errors at runtime when %s', async (_, actionConfig, steps, message) => {
const workflow = await parseCompositeFunctions({
catalog: { [SETUP]: actionConfig },
steps,
externalFunctions: [passThroughFunction()],
});
const error = await getErrorAsync<BuildStepRuntimeError>(() => workflow.executeAsync());
expect(error).toBeInstanceOf(BuildStepRuntimeError);
expect(error.message).toMatch(message);
});
it('coerces a provided string value to the declared number type', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [{ name: 'count', type: 'number' }],
runs: {
steps: [
{
id: 'inner',
uses: 'test/passthrough',
// Without number coercion, `'2' + 1` would render as `21`.
with: { value: 'next-${{ inputs.count + 1 }}' },
},
],
},
},
},
steps: [{ uses: SETUP, id: 'setup', with: { count: '2' } }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
).toBe('next-3');
});
it('falls back to the default resolved in the composite function scope when the caller passes null', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: actionReadingInput({ name: 'greeting', type: 'string', default_value: 'hello' }),
},
steps: [{ uses: SETUP, id: 'setup', with: { greeting: null } }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
).toBe('hello');
});
it('accepts a provided literal value that is one of allowed_values', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: actionReadingInput({
name: 'greeting',
type: 'string',
allowed_values: ['hi', 'hello'],
}),
},
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hello' } }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
).toBe('hello');
});
it('rejects at parse time a provided literal value outside allowed_values', async () => {
const error = await getErrorAsync<BuildConfigError>(() =>
parseCompositeFunctions({
catalog: {
[SETUP]: actionReadingInput({
name: 'greeting',
type: 'string',
allowed_values: ['hi', 'hello'],
}),
},
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'bye' } }],
externalFunctions: [passThroughFunction()],
})
);
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/not one of the allowed values/);
});
it('resolves an undeclared input reference to an empty value', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
runs: {
steps: [
{
id: 'inner',
uses: 'test/passthrough',
with: { value: '${{ inputs.gretting }}' },
},
],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
).toBe('');
});
});
});
@@ -0,0 +1,343 @@
import {
SETUP,
failingFunction,
parseCompositeFunctions,
passThroughFunction,
setVersionFunction,
} from './StepsConfigParser-composite-functions-test-utils';
import { getErrorAsync } from './utils/error';
import { BuildStepStatus } from '../BuildStep';
describe('StepsConfigParser local composite functions', () => {
describe('outputs', () => {
it('creates a CompositeBuildStep that exposes declared composite function outputs', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
name: 'Setup',
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [
{ id: 'read', run: 'set-output version "1.0.0"' },
{ run: 'echo "${{ inputs.greeting }}"' },
],
},
},
},
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }],
});
expect(workflow.buildSteps).toHaveLength(3);
const [readStep, echoStep, outputsStep] = workflow.buildSteps;
expect(readStep.id).toBe('setup__read');
expect(readStep.displayName).toBe('read');
expect(echoStep.id).toBe('setup__composite_function_step_1');
expect(echoStep.command).toBe('echo "${{ inputs.greeting }}"');
expect(outputsStep.id).toBe('setup');
expect(outputsStep.displayName).toBe('Setup');
expect(outputsStep.command).toBeUndefined();
expect(outputsStep.fn).toBeDefined();
expect(Object.keys(outputsStep.outputById)).toEqual(['version']);
});
it('uses a generated call id for the outputs node when the caller has no id', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
name: 'Setup',
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [
{ id: 'read', run: 'set-output version "1.0.0"' },
{ id: 'echo', run: 'echo "${{ steps.read.outputs.version }}"' },
],
},
},
},
steps: [{ uses: SETUP }],
});
const [readStep, echoStep, outputsStep] = workflow.buildSteps;
const callStepId = readStep.id.split('__')[0];
expect(callStepId).toMatch(/^step-\d{3,}$/);
expect(readStep.id).toBe(`${callStepId}__read`);
expect(echoStep.command).toBe('echo "${{ steps.read.outputs.version }}"');
expect(outputsStep.id).toBe(callStepId);
});
it('uses the caller step name for the CompositeBuildStep display name', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
name: 'Setup',
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', run: 'set-output version "1.0.0"' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup', name: 'Prepare release' }],
});
const outputsStep = workflow.buildSteps[1];
expect(outputsStep.id).toBe('setup');
expect(outputsStep.displayName).toBe('Prepare release');
});
it('sets composite composite function outputs via fn without shell interpolation', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', uses: 'test/set-version' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
externalFunctions: [setVersionFunction()],
});
await workflow.executeAsync();
const outputsStep = workflow.buildSteps[1];
expect(outputsStep.getOutputValueByName('version')).toBe('$(echo injected)');
});
it('leaves legacy ${ steps.* } references in composite function outputs uninterpolated', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { version: { value: '${ steps.read.outputs.version }' } },
runs: {
steps: [{ id: 'read', uses: 'test/set-version' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
externalFunctions: [setVersionFunction()],
});
await workflow.executeAsync();
const outputsStep = workflow.buildSteps[1];
expect(outputsStep.getOutputValueByName('version')).toBe('${ steps.read.outputs.version }');
});
it('runs the outputs step after a failed inner step when caller uses always(), setting composite function outputs to empty strings', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', uses: 'test/fail' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup', if: '${{ always() }}' }],
externalFunctions: [failingFunction()],
});
const innerStep = workflow.buildSteps[0];
const outputsStep = workflow.buildSteps[1];
expect(outputsStep.ifCondition).toBe('${{ always() }}');
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
expect(error.message).toBe('inner failed');
expect(innerStep.status).toBe(BuildStepStatus.FAIL);
expect(outputsStep.status).toBe(BuildStepStatus.SUCCESS);
expect(outputsStep.getOutputValueByName('version')).toBe('');
});
it('exposes outputs from both the outer and a nested inner action', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.eas/functions/outer': {
outputs: { outer_version: { value: '${{ steps.mid.outputs.inner_version }}' } },
runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] },
},
'./.eas/functions/inner': {
outputs: { inner_version: { value: '${{ steps.read.outputs.version }}' } },
runs: { steps: [{ id: 'read', uses: 'test/set-version' }] },
},
},
steps: [{ uses: './.eas/functions/outer', id: 'top' }],
externalFunctions: [setVersionFunction()],
});
await workflow.executeAsync();
const innerOutputsStep = workflow.buildSteps.find(s => s.id === 'top__mid');
const outerOutputsStep = workflow.buildSteps.find(s => s.id === 'top');
expect(innerOutputsStep?.getOutputValueByName('inner_version')).toBe('$(echo injected)');
expect(outerOutputsStep?.getOutputValueByName('outer_version')).toBe('$(echo injected)');
});
it('resolves call-site env in the caller scope for the outputs step', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { out: { value: '${{ env.FOO }}' } },
runs: {
steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: '${{ env.FOO }}' } }],
},
},
},
steps: [
{ id: 'prev', uses: 'test/set-version' },
{ uses: SETUP, id: 'setup', env: { FOO: '${{ steps.prev.outputs.version }}' } },
],
externalFunctions: [setVersionFunction(), passThroughFunction()],
});
await workflow.executeAsync();
const innerStep = workflow.buildSteps.find(s => s.id === 'setup__inner');
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
expect(innerStep?.getOutputValueByName('out')).toBe('$(echo injected)');
expect(outputsStep?.getOutputValueByName('out')).toBe('$(echo injected)');
});
it('resolves inputs referenced in output templates', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
inputs: [
{ name: 'greeting', type: 'string' },
{ name: 'farewell', type: 'string', default_value: 'bye' },
],
outputs: {
greeting: { value: '${{ inputs.greeting }}' },
farewell: { value: '${{ inputs.farewell }}' },
},
runs: {
steps: [{ id: 'noop', uses: 'test/passthrough', with: { value: 'x' } }],
},
},
},
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
expect(outputsStep?.getOutputValueByName('greeting')).toBe('hi');
expect(outputsStep?.getOutputValueByName('farewell')).toBe('bye');
});
it('skips the outputs step when the call-site if skips the whole action', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', uses: 'test/set-version' }],
},
},
},
steps: [
{ uses: SETUP, id: 'setup', if: '${{ false }}' },
{
id: 'after',
uses: 'test/passthrough',
with: { value: '${{ steps.setup.outputs.version }}' },
},
],
externalFunctions: [setVersionFunction(), passThroughFunction()],
});
await workflow.executeAsync();
const innerStep = workflow.buildSteps.find(s => s.id === 'setup__read');
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
const afterStep = workflow.buildSteps.find(s => s.id === 'after');
expect(innerStep?.status).toBe(BuildStepStatus.SKIPPED);
expect(outputsStep?.status).toBe(BuildStepStatus.SKIPPED);
expect(afterStep?.getOutputValueByName('out')).toBe('');
});
it('hides flattened inner step ids from the workflow realm while exposing declared outputs', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', uses: 'test/set-version' }],
},
},
},
steps: [
{ uses: SETUP, id: 'setup' },
{
id: 'public',
uses: 'test/passthrough',
with: { value: '${{ steps.setup.outputs.version }}' },
},
{
id: 'leak',
uses: 'test/passthrough',
with: { value: '${{ steps.setup__read.outputs.version }}' },
},
],
externalFunctions: [setVersionFunction(), passThroughFunction()],
});
await workflow.executeAsync();
const outputFor = (id: string): string | undefined =>
workflow.buildSteps.find(s => s.id === id)?.getOutputValueByName('out');
expect(outputFor('public')).toBe('$(echo injected)');
expect(workflow.buildSteps.some(s => s.id === 'setup__read')).toBe(true);
expect(outputFor('leak')).toBe('');
});
});
describe('skipped inner steps', () => {
it('resolves ${{ }} outputs referencing a skipped inner step to empty strings and leaves legacy refs literal', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: {
version: { value: '${ steps.read.outputs.version }' },
version_expr: { value: '${{ steps.read.outputs.version }}' },
},
runs: {
steps: [{ id: 'read', uses: 'test/set-version', if: '${{ 1 == 2 }}' }],
},
},
},
steps: [{ uses: SETUP, id: 'setup' }],
externalFunctions: [setVersionFunction()],
});
await workflow.executeAsync();
const innerStep = workflow.buildSteps[0];
const outputsStep = workflow.buildSteps[1];
expect(innerStep.status).toBe(BuildStepStatus.SKIPPED);
expect(outputsStep.status).toBe(BuildStepStatus.SUCCESS);
expect(outputsStep.getOutputValueByName('version')).toBe('${ steps.read.outputs.version }');
expect(outputsStep.getOutputValueByName('version_expr')).toBe('');
});
it('resolves an out-of-scope action output reference to an empty string', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.eas/functions/notify': {
outputs: { sha: { value: '${{ steps.checkout.outputs.sha }}' } },
runs: {
steps: [
{ id: 'noop', uses: 'test/passthrough', with: { value: 'x' }, if: '${{ false }}' },
],
},
},
},
steps: [{ uses: './.eas/functions/notify', id: 'notify' }],
externalFunctions: [passThroughFunction()],
});
await workflow.executeAsync();
expect(workflow.buildSteps.find(s => s.id === 'notify')?.getOutputValueByName('sha')).toBe(
''
);
});
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/eas-build-job';
import { createGlobalContextMock } from './utils/context';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { BuildWorkflow } from '../BuildWorkflow';
import { StepsConfigParser } from '../StepsConfigParser';
export const SETUP = './.eas/functions/setup';
export function makeCatalog(entries: Record<string, unknown>): CompositeFunctionCatalog {
const catalog: CompositeFunctionCatalog = {};
for (const [compositeFunctionPath, raw] of Object.entries(entries)) {
catalog[compositeFunctionPath] = CompositeFunctionConfigZ.parse(raw);
}
return catalog;
}
export async function parseCompositeFunctions(options: {
catalog?: Record<string, unknown>;
steps: Step[];
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
}): Promise<BuildWorkflow> {
const ctx = createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
steps: options.steps,
hooks: undefined,
compositeFunctionCatalog: makeCatalog(options.catalog ?? {}),
externalFunctions: options.externalFunctions,
externalFunctionGroups: options.externalFunctionGroups,
});
return parser.parseAsync();
}
export function echoFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'echo',
fn: () => {},
inputProviders: [
BuildStepInput.createProvider({
id: 'value',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
],
});
}
export function setVersionFunction(): BuildFunction {
return new BuildFunction({
namespace: 'test',
id: 'set-version',
fn: (_ctx, { outputs }) => {
outputs.version.set('$(echo injected)');
},
outputProviders: [
BuildStepOutput.createProvider({
id: 'version',
required: true,
}),
],
});
}
export function failingFunction(): BuildFunction {
return new BuildFunction({
namespace: 'test',
id: 'fail',
fn: () => {
throw new Error('inner failed');
},
});
}
export function passThroughFunction(): BuildFunction {
return new BuildFunction({
namespace: 'test',
id: 'passthrough',
fn: (_ctx, { inputs, outputs }) => {
outputs.out.set(String(inputs.value.value ?? ''));
},
inputProviders: [
BuildStepInput.createProvider({
id: 'value',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
],
outputProviders: [BuildStepOutput.createProvider({ id: 'out', required: true })],
});
}
export function captureEnvFunction(
sink: (env: Record<string, string | undefined>) => void
): BuildFunction {
return new BuildFunction({
namespace: 'test',
id: 'capture-env',
fn: (_ctx, { env }) => {
sink(env);
},
});
}
export function echoInputAction(inputName: string, input: Record<string, unknown>) {
return {
inputs: [input],
runs: { steps: [{ run: `echo "\${{ inputs.${inputName} }}"` }] },
};
}
export function actionReadingInput(input: Record<string, unknown>) {
return {
inputs: [input],
runs: {
steps: [
{ id: 'inner', uses: 'test/passthrough', with: { value: `\${{ inputs.${input.name} }}` } },
],
},
};
}
@@ -1,5 +1,6 @@
import { Hooks, Step } from '@expo/eas-build-job';
import { CompositeFunctionCatalog, Hooks, Step } from '@expo/eas-build-job';
import { makeCatalog } from './StepsConfigParser-composite-functions-test-utils';
import { createGlobalContextMock } from './utils/context';
import { getErrorAsync } from './utils/error';
import { BuildFunction } from '../BuildFunction';
@@ -38,12 +39,14 @@ async function parseWorkflowAsync({
hooks,
externalFunctions,
externalFunctionGroups,
compositeFunctionCatalog,
}: {
ctx: BuildStepGlobalContext;
steps: Step[];
hooks: Hooks | undefined;
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
compositeFunctionCatalog?: CompositeFunctionCatalog;
}): Promise<BuildWorkflow> {
const parser = new StepsConfigParser(ctx, {
steps,
@@ -53,6 +56,7 @@ async function parseWorkflowAsync({
createCheckoutFunction(),
],
externalFunctionGroups,
compositeFunctionCatalog,
});
return await parser.parseAsync();
}
@@ -260,6 +264,18 @@ describe('StepsConfigParser hook construction', () => {
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/nonexistent_function/);
});
it('rejects a hook step using a local composite function with BuildConfigError, not an assertion crash', async () => {
const error = await getErrorAsync<BuildConfigError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] },
});
});
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/not supported in hooks/);
});
});
describe('constructHookEntriesAsync (public API)', () => {
@@ -487,6 +503,111 @@ describe('StepsConfigParser hooks with function groups', () => {
});
});
describe('StepsConfigParser hooks with composite functions', () => {
let ctx: BuildStepGlobalContext;
beforeEach(() => {
ctx = createGlobalContextMock();
});
it('never treats an anchored function inside a composite function as a hook trigger', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
hooks: {
before_install_node_modules: [{ run: 'echo never' }],
after_install_node_modules: [{ run: 'echo never' }],
},
compositeFunctionCatalog: makeCatalog({
'./.eas/functions/setup': {
runs: { steps: [{ uses: 'eas/install_node_modules' }] },
},
}),
});
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']);
expect(workflow.hooksByAnchorStep.size).toBe(0);
// Structural invariant, not just parse-time behavior: the expanded step
// carries NO anchor mark, so a future runtime discovery mechanism (the
// native hook runner) cannot resolve it as an anchor occurrence either.
expect(workflow.buildSteps[0].__hookId).toBeUndefined();
});
it('anchors only the job-level occurrence when the same function is also called inside a composite function', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [
{ uses: './.eas/functions/setup', id: 'setup' },
{ uses: 'eas/install_node_modules' },
],
hooks: {
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
},
compositeFunctionCatalog: makeCatalog({
'./.eas/functions/setup': {
runs: { steps: [{ uses: 'eas/install_node_modules' }] },
},
}),
});
expect(workflow.hooksByAnchorStep.size).toBe(1);
expect(orderedDisplayNames(workflow)).toEqual([
'Install node modules',
'before-hook',
'Install node modules',
'after-hook',
]);
});
it('rejects a REGISTERED stamp on a composite call even when it expands into a single step', async () => {
const error = await getErrorAsync<BuildConfigError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }],
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
compositeFunctionCatalog: makeCatalog({
'./.eas/functions/setup': {
runs: { steps: [{ run: 'echo setup' }] },
},
}),
});
});
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toBe('Hook anchors are not supported on local composite function steps.');
});
it('rejects a REGISTERED stamp on a composite call that expands into multiple steps', async () => {
const error = await getErrorAsync<BuildConfigError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }],
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
compositeFunctionCatalog: makeCatalog({
'./.eas/functions/setup': {
runs: { steps: [{ run: 'echo one' }, { run: 'echo two' }] },
},
}),
});
});
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toBe('Hook anchors are not supported on local composite function steps.');
});
it('treats an UNREGISTERED-stamped composite call as an inert ordinary step (skew tolerance)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'some_future_anchor' }],
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
compositeFunctionCatalog: makeCatalog({
'./.eas/functions/setup': {
runs: { steps: [{ uses: 'eas/install_node_modules' }] },
},
}),
});
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']);
expect(workflow.hooksByAnchorStep.size).toBe(0);
});
});
describe('StepsConfigParser hook validation view', () => {
let ctx: BuildStepGlobalContext;
+12 -20
View File
@@ -15,9 +15,10 @@ import {
} from './BuildFunctionGroup';
import { BuildStep } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepOutput } from './BuildStepOutput';
import { collectAggregateStepErrors } from './BuildWorkflowValidator';
import { BuildConfigError, BuildWorkflowError } from './errors';
import { isLocalCompositeFunctionPath } from './utils/localCompositeFunctions';
import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step';
/**
* One entry per AUTHORED hook step — the unit the user wrote. The wrapper
@@ -125,6 +126,11 @@ export function constructHookEntriesFromValidatedSteps(
});
continue;
}
if (isLocalCompositeFunctionPath(step.uses)) {
throw new BuildConfigError(
`Local composite function steps ("uses: ${step.uses}") are not supported in hooks.`
);
}
const maybeFunctionGroup = buildFunctionGroupById[step.uses];
if (maybeFunctionGroup !== undefined) {
entries.push({
@@ -159,25 +165,11 @@ export function createBuildStepFromShellStep(
ctx: BuildStepGlobalContext,
step: ShellStep
): BuildStep {
const id = BuildStep.getNewId(step.id);
const displayName =
step.name ??
step.id ??
step.run
.split('\n')
.find(line => line.trim())
?.trim() ??
step.run;
const outputs = step.outputs?.map(
entry =>
new BuildStepOutput(ctx, {
id: entry.name,
stepDisplayName: displayName,
required: entry.required ?? true,
})
);
const displayName = getShellStepDisplayName(step);
const outputs =
step.outputs && createBuildStepOutputsFromDefinition(ctx, step.outputs, displayName);
return new BuildStep(ctx, {
id,
id: BuildStep.getNewId(step.id),
displayName,
outputs,
workingDirectory: step.working_directory,
@@ -202,7 +194,7 @@ export function validateAllStepFunctionsExist(
): void {
const calledFunctionsOrFunctionGroupsSet = new Set<string>();
for (const step of steps) {
if (step.uses) {
if (step.uses && !isLocalCompositeFunctionPath(step.uses)) {
calledFunctionsOrFunctionGroupsSet.add(step.uses);
}
}
@@ -0,0 +1,80 @@
import { JobInterpolationContext } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../__tests__/utils/context';
import {
containsUnresolvedTemplateReference,
resolveInterpolatedTarget,
stringifyInterpolatedResult,
} from '../compositeFunctionInterpolation';
function contextWith({
steps = {},
env = {},
}: {
steps?: JobInterpolationContext['steps'];
env?: Record<string, string | undefined>;
} = {}): JobInterpolationContext {
const globalCtx = createGlobalContextMock();
return { ...globalCtx.getInterpolationContext(), steps, env } as JobInterpolationContext;
}
describe(resolveInterpolatedTarget, () => {
it('passes literal targets through unchanged', () => {
expect(resolveInterpolatedTarget('literal', contextWith())).toBe('literal');
});
it('resolves ${{ }} expressions against the context', () => {
expect(resolveInterpolatedTarget('${{ env.FOO }}', contextWith({ env: { FOO: 'bar' } }))).toBe(
'bar'
);
});
it('leaves legacy ${ steps.* } references untouched', () => {
expect(resolveInterpolatedTarget('v${ steps.read.version }', contextWith())).toBe(
'v${ steps.read.version }'
);
});
it('returns non-string results as-is', () => {
expect(resolveInterpolatedTarget('${{ fromJSON(\'{"a":1}\') }}', contextWith())).toEqual({
a: 1,
});
});
});
describe(stringifyInterpolatedResult, () => {
it('renders nullish values as an empty string and objects as JSON', () => {
expect(stringifyInterpolatedResult(undefined)).toBe('');
expect(stringifyInterpolatedResult(null)).toBe('');
expect(stringifyInterpolatedResult({ a: 1 })).toBe('{"a":1}');
expect(stringifyInterpolatedResult(3)).toBe('3');
});
});
describe(containsUnresolvedTemplateReference, () => {
it('recognizes ${{ }} template references', () => {
expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}')).toBe(true);
expect(containsUnresolvedTemplateReference(' ${{ steps.a.outputs.v }} ')).toBe(true);
expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}\n')).toBe(true);
});
it('recognizes ${{ }} template references embedded in a larger string', () => {
expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}px')).toBe(true);
expect(containsUnresolvedTemplateReference('${{ inputs.p }}-variant')).toBe(true);
expect(containsUnresolvedTemplateReference('a-${{ env.FOO }}-b')).toBe(true);
});
it('does not recognize legacy ${ } spans, which are unsupported inside actions', () => {
expect(containsUnresolvedTemplateReference('${ steps.a.outputs.v }')).toBe(false);
expect(containsUnresolvedTemplateReference('${ steps.a.v }')).toBe(false);
expect(containsUnresolvedTemplateReference('a-${ steps.a.outputs.v }-b')).toBe(false);
expect(containsUnresolvedTemplateReference('${ inputs.count }')).toBe(false);
expect(containsUnresolvedTemplateReference('echo ${FOO:-bar}')).toBe(false);
});
it('returns false for plain strings and non-strings', () => {
expect(containsUnresolvedTemplateReference('plain')).toBe(false);
expect(containsUnresolvedTemplateReference(5)).toBe(false);
expect(containsUnresolvedTemplateReference(undefined)).toBe(false);
});
});
@@ -0,0 +1,81 @@
import {
isLocalCompositeFunctionPath,
parseLocalCompositeFunctionPath,
} from '../localCompositeFunctions';
describe(isLocalCompositeFunctionPath, () => {
it('recognizes relative paths as local composite function paths', () => {
expect(isLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(true);
expect(isLocalCompositeFunctionPath('../../shared/functions/setup')).toBe(true);
expect(isLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe(true);
});
it('rejects function ids and absolute or backslash-prefixed paths', () => {
expect(isLocalCompositeFunctionPath('eas/build')).toBe(false);
expect(isLocalCompositeFunctionPath('/functions/setup')).toBe(false);
expect(isLocalCompositeFunctionPath('..\\functions\\setup')).toBe(false);
});
});
describe(parseLocalCompositeFunctionPath, () => {
it('parses local composite function paths', () => {
expect(parseLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(
'./.eas/functions/setup'
);
expect(parseLocalCompositeFunctionPath('../../shared/functions/setup')).toBe(
'../../shared/functions/setup'
);
});
it('normalizes local composite function paths', () => {
expect(parseLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe(
'./.eas/functions/setup'
);
});
it('collapses equivalent paths to the same canonical path', () => {
expect(parseLocalCompositeFunctionPath('././.eas/functions/setup')).toBe(
'./.eas/functions/setup'
);
expect(parseLocalCompositeFunctionPath('./.eas/functions/other/../setup')).toBe(
'./.eas/functions/setup'
);
expect(parseLocalCompositeFunctionPath('../shared/other/../functions/setup')).toBe(
'../shared/functions/setup'
);
});
it('keeps the "./" prefix for under-root directories whose name starts with ".."', () => {
expect(parseLocalCompositeFunctionPath('./..functions/setup')).toBe('./..functions/setup');
});
it('canonicalizes paths pointing at the project root or its parent', () => {
expect(parseLocalCompositeFunctionPath('./')).toBe('./.');
expect(parseLocalCompositeFunctionPath(' ./ ')).toBe('./.');
expect(parseLocalCompositeFunctionPath('../')).toBe('..');
expect(parseLocalCompositeFunctionPath('./..')).toBe('..');
});
it('is stable when re-parsing its own canonical output', () => {
expect(parseLocalCompositeFunctionPath('./.')).toBe('./.');
expect(parseLocalCompositeFunctionPath('../.')).toBe('..');
});
it('throws for backslash-based paths', () => {
expect(() => parseLocalCompositeFunctionPath('./functions\\setup')).toThrow(
/must not contain backslashes/
);
});
it('throws for interpolated local composite function paths', () => {
expect(() => parseLocalCompositeFunctionPath('./.eas/functions/${{ inputs.name }}')).toThrow(
/must not contain interpolation/
);
});
it('parses local composite function paths that contain }}${{ as literal characters', () => {
expect(parseLocalCompositeFunctionPath('./.eas/functions/weird}}${{name')).toBe(
'./.eas/functions/weird}}${{name'
);
});
});
@@ -0,0 +1,33 @@
import { JobInterpolationContext } from '@expo/eas-build-job';
import { interpolateJobContext } from '../interpolation';
// Composite functions support only `${{ }}`; legacy `${ steps.* }` is intentionally left uninterpolated here.
export function resolveInterpolatedTarget(
target: unknown,
context: JobInterpolationContext
): unknown {
return interpolateJobContext({ target, context });
}
export function stringifyInterpolatedResult(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
export function stringifyOptionalInterpolatedResult(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
return stringifyInterpolatedResult(value);
}
// Matches `${{ }}` only; legacy `${ steps.* }` is unsupported inside actions.
export function containsUnresolvedTemplateReference(value: unknown): boolean {
return typeof value === 'string' && value.includes('${{');
}
@@ -0,0 +1,42 @@
import path from 'path';
import { BuildConfigError } from '../errors';
// Local composite functions referenced via `uses: ./path` or `uses: ../path` in EAS workflows.
// Not supported in `.eas/build/*.yml` custom build configs.
const JOB_CONTEXT_INTERPOLATION_REGEXP = /\$\{\{(.+?)\}\}/;
function doesLocalCompositeFunctionPathRequireInterpolation(uses: string): boolean {
return JOB_CONTEXT_INTERPOLATION_REGEXP.test(uses);
}
export function parseLocalCompositeFunctionPath(uses: string): string {
const trimmed = uses.trim();
// The composite function catalog is built before the workflow runs, so a local path must be
// known statically.
if (doesLocalCompositeFunctionPathRequireInterpolation(trimmed)) {
throw new BuildConfigError(
`Local composite function path "${trimmed}" must not contain interpolation ("\${{ ... }}"). The "uses" path for a local composite function must be a static, literal path.`
);
}
if (trimmed.includes('\\')) {
throw new BuildConfigError(
`Local composite function path "${trimmed}" must not contain backslashes. Use forward slashes as path separators.`
);
}
const normalized = path.posix.normalize(trimmed.replace(/\/+$/, ''));
if (normalized === '..' || normalized.startsWith('../')) {
return normalized;
}
return `./${normalized}`;
}
export function isLocalCompositeFunctionPath(uses: string): boolean {
const trimmed = uses.trim();
return trimmed.startsWith('./') || trimmed.startsWith('../');
}
export function getLocalCompositeFunctionCallWorkingDirectoryError(uses: string): string {
return `"working_directory" is not supported on a step that calls a local composite function ("uses: ${uses.trim()}"). Set "working_directory" on the steps inside the composite function instead.`;
}
+31
View File
@@ -0,0 +1,31 @@
import { ShellStep } from '@expo/eas-build-job';
import { BuildStepGlobalContext } from '../BuildStepContext';
import { BuildStepOutput } from '../BuildStepOutput';
export function getShellStepDisplayName(step: ShellStep): string {
return (
step.name ??
step.id ??
step.run
.split('\n')
.find(line => line.trim())
?.trim() ??
step.run
);
}
export function createBuildStepOutputsFromDefinition(
ctx: BuildStepGlobalContext,
stepOutputs: Required<ShellStep>['outputs'],
stepDisplayName: string
): BuildStepOutput[] {
return stepOutputs.map(
entry =>
new BuildStepOutput(ctx, {
id: entry.name,
stepDisplayName,
required: entry.required ?? true,
})
);
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "21.1.0",
"version": "21.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "21.1.0",
"@expo/build-tools": "21.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.1.0",
"@expo/eas-build-job": "21.3.0",
"@expo/logger": "21.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.1.0",
"@expo/steps": "21.3.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@hapi/boom": "10.0.1",
Generated
+17 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:21.1.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.3.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:21.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.1.0"
"@expo/steps": "npm:21.3.0"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2547,7 +2547,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:21.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:21.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2566,12 +2566,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:21.1.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:21.3.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.3.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2884,11 +2884,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:21.1.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:21.3.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2957,16 +2957,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:21.1.0"
"@expo/build-tools": "npm:21.3.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/logger": "npm:21.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.1.0"
"@expo/steps": "npm:21.3.0"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9722,7 +9722,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:21.1.0"
"@expo/steps": "npm:21.3.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10294,8 +10294,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/build-tools": "npm:21.3.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:21.0.0"
@@ -10352,8 +10352,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:21.1.0"
"@expo/eas-json": "npm:21.1.0"
"@expo/eas-build-job": "npm:21.3.0"
"@expo/eas-json": "npm:21.3.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:21.0.0"
@@ -10368,7 +10368,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.1.0"
"@expo/steps": "npm:21.3.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"