Compare commits

..
8 Commits
Author SHA1 Message Date
Expo CI a62289b988 v21.0.1 2026-07-15 06:42:00 +00:00
Brent Vatne 78c0584331 [eas-cli] Add -p alias for --platform in simulator:start (#3902) 2026-07-14 15:46:37 -07:00
Brent Vatne c4a7b7c9f1 [eas-cli] Add eas project:delete command (#4012) 2026-07-14 09:52:30 -07:00
Ash Wu f448bc75b5 [build-tools] unify workflows hook (#3957)
* [eas-build-job] Add hook anchors registry and step stamp fields

Signed-off-by: Ash Wu <[email protected]>

* [steps] Add hook insertion, after-hook gate, and source function retention

Signed-off-by: Ash Wu <[email protected]>

* [build-tools] Pass job hooks into the steps parser

Signed-off-by: Ash Wu <[email protected]>

* address comments

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-07-14 15:31:11 +08:00
Brent VatneandDouglas Lowder 0dda7ba880 [eas-cli] Add --dev-domain flag to eas deploy (#4004)
Co-authored-by: Douglas Lowder <[email protected]>
2026-07-13 20:26:02 -07:00
Jon SampandDouglas Lowder 9679af9670 [eas-cli] Add eas browse command (#4003)
* [eas-cli] Add `eas browse` command

Add `eas browse [PAGE]` to open the project page on expo.dev, with an
optional whitelisted page argument (e.g. `build`, `hosting`, `cicd`) for
project subpages and a `--no-browser` flag to print the URL instead.
`eas open` becomes a thin alias that opens the project dashboard.

* [eas-cli] Update changelog PR link

* [eas-cli] Add `observe` page to `eas browse`

* [eas-cli] Support --json and --non-interactive in eas browse, hide eas open

* Fix CHANGELOG

---------

Co-authored-by: Douglas Lowder <[email protected]>
2026-07-13 17:44:19 -05:00
Szymon Dziedzic 3e0e823e56 Bump tar (#3971)
* Bump tar

* Add changelog for tar bump
2026-07-13 21:28:20 +02:00
Expo CI fbb8af6fca update CHANGELOG.md 2026-07-13 17:25:08 +00:00
64 changed files with 4147 additions and 640 deletions

No files matched your search

+17
View File
@@ -6,6 +6,23 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
- [eas-cli] Add `eas project:delete` command to delete a project — the current directory's project by default, or any project passed as an argument (`@account/slug` or ID). Confirmation requires the project's full name, typed at the interactive prompt or passed via `--dangerously-confirm-deletion` (required in non-interactive mode). Deletion requires sudo mode: the command prompts for your password (and OTP) to upgrade the session when the server requires it. Deletion was previously only possible from the website. ([#4012](https://github.com/expo/eas-cli/pull/4012) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`<name>.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `-p` as an alias for `--platform` in `eas simulator:start`. ([#3902](https://github.com/expo/eas-cli/pull/3902) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
### 🧹 Chores
- [eas-cli][build-tools][local-build-plugin][worker] Bump `tar` to `7.5.19`. ([#3971](https://github.com/expo/eas-cli/pull/3971) by [@szdziedzic](https://github.com/szdziedzic))
## [21.0.0](https://github.com/expo/eas-cli/releases/tag/v21.0.0) - 2026-07-13
### 🛠 Breaking changes
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.0.0",
"version": "21.0.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.0.0",
"version": "21.0.1",
"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.0.0",
"@expo/eas-build-job": "21.0.1",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.0.0",
"@expo/steps": "21.0.1",
"@expo/template-file": "21.0.0",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -72,7 +72,7 @@
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.7",
"tar": "7.5.19",
"uuid": "9.0.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
@@ -111,6 +111,37 @@ describe(runGenericJobAsync, () => {
expect(rejected).toBe(true);
});
it('passes the job hooks into the steps parser', async () => {
// @expo/steps is fully mocked here — assert the plumbing only (real
// insertion behavior is covered by the parser's own tests).
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockCtx.job.hooks = { before_install_node_modules: [{ run: 'echo hi' }] };
await runGenericJobAsync(mockCtx);
expect(StepsConfigParser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hooks: mockCtx.job.hooks })
);
});
it('passes hooks: undefined into the steps parser for a hook-less job (dormancy)', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await runGenericJobAsync(mockCtx);
expect(StepsConfigParser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hooks: undefined })
);
});
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
mockCtx.expoApiV2BaseUrl = undefined;
@@ -10,7 +10,10 @@ import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { createTestIosJob } from './job';
import { createMockLogger } from './logger';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
export class MockContextProvider implements ExternalBuildContextProvider {
private _env: BuildStepEnv = {};
@@ -72,6 +75,19 @@ export function createStepContextMock({
});
}
export function createCustomBuildContextMock(): CustomBuildContext {
const ctx = new BuildContext(createTestIosJob({}), {
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
return new CustomBuildContext(ctx);
}
export function createGlobalContextMock({
logger,
skipCleanup,
@@ -63,6 +63,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
externalFunctions: easFunctions,
externalFunctionGroups: easFunctionGroups,
steps: ctx.job.steps,
hooks: ctx.job.hooks,
})
: new BuildConfigParser(globalContext, {
externalFunctions: easFunctions,
+11 -1
View File
@@ -9,12 +9,18 @@ import {
StaticJobInterpolationContext,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
import {
BuildRuntimePlatform,
ExternalBuildContextProvider,
StepMetric,
WorkflowHookMetric,
} from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
import { ArtifactToUpload, BuildContext } from './context';
import { reportWorkflowHookMetricToDatadog } from './utils/hookMetrics';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
@@ -165,6 +171,10 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.pendingMetricUploads.push(p);
}
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
reportWorkflowHookMetricToDatadog(metric);
}
public async drainPendingMetricUploads(): Promise<void> {
await Promise.allSettled(this.pendingMetricUploads);
}
+1
View File
@@ -47,6 +47,7 @@ export async function runGenericJobAsync(
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
steps: ctx.job.steps,
hooks: ctx.job.hooks,
});
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
@@ -0,0 +1,117 @@
import { HookAnchorId } from '@expo/eas-build-job';
import { StepsConfigParser } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../__tests__/utils/context';
import { getEasFunctionGroups } from '../easFunctionGroups';
import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
declarations[buildFunction.getFullId()] = buildFunction.__hookId;
}
}
// An empty declaration set must fail: without these, every user-authored
// unstamped `uses:` step and every `eas/build` expansion loses hooks.
expect(declarations).toEqual({
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
it('no function group id collides with a declaring function id', () => {
// Group lookup shadows function lookup in StepsConfigParser, so a
// collision would silently unbind the anchor.
const declaringFunctionIds = getEasFunctions(createCustomBuildContextMock())
.filter(buildFunction => buildFunction.__hookId !== undefined)
.map(buildFunction => buildFunction.getFullId());
const functionGroupIds = getEasFunctionGroups(createCustomBuildContextMock()).map(
functionGroup => functionGroup.getFullId()
);
for (const functionId of declaringFunctionIds) {
expect(functionGroupIds).not.toContain(functionId);
}
});
it('attaches hooks inside a uses: eas/build expansion (declaration-driven anchor discovery)', async () => {
// This is the guard against a semantically stale declaration — the
// declaration existence test alone cannot catch a binding that no longer
// corresponds to what the group actually expands to.
const customBuildCtx = createCustomBuildContextMock();
const globalContext = createGlobalContextMock({});
const parser = new StepsConfigParser(globalContext, {
steps: [{ uses: 'eas/build' }],
hooks: {
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
},
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
});
const workflow = await parser.parseAsync();
const installStep = workflow.buildSteps.find(step => step.__hookId === 'install_node_modules');
expect(installStep).toBeDefined();
const anchorHooks = workflow.hooksByAnchorStep.get(installStep!);
expect(anchorHooks?.anchor).toBe('install_node_modules');
expect(anchorHooks?.before[0].steps[0].id).toBe('before-hook');
expect(anchorHooks?.after[0].steps[0].id).toBe('after-hook');
});
it.each([
['eas/checkout', 'before_checkout', 'after_checkout', undefined],
[
'eas/install_node_modules',
'before_install_node_modules',
'after_install_node_modules',
undefined,
],
[
'eas/upload_to_asc',
'before_submit',
'after_submit',
{
ipa_path: 'app.ipa',
asc_api_key_path: 'key.p8',
apple_app_identifier: '1234567890',
bundle_version: '1',
bundle_short_version: '1.0.0',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
'after_maestro_tests',
{ flow_path: ['flow.yaml'] },
],
] as const)(
'an unstamped user-authored %s step fires its hook keys end-to-end',
async (functionId, beforeKey, afterKey, callInputs) => {
const customBuildCtx = createCustomBuildContextMock();
const globalContext = createGlobalContextMock({});
const parser = new StepsConfigParser(globalContext, {
steps: [{ uses: functionId, ...(callInputs !== undefined ? { with: callInputs } : null) }],
hooks: {
[beforeKey]: [{ run: 'echo before', id: 'before-hook' }],
[afterKey]: [{ run: 'echo after', id: 'after-hook' }],
},
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
});
const workflow = await parser.parseAsync();
expect(workflow.hooksByAnchorStep.size).toBe(1);
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
expect(anchorHooks.before[0].steps[0].id).toBe('before-hook');
expect(anchorHooks.after[0].steps[0].id).toBe('after-hook');
}
);
});
@@ -7,6 +7,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
id: 'checkout',
name: 'Checkout',
__metricsId: 'eas/checkout',
__hookId: 'checkout',
fn: async stepsCtx => {
if (stepsCtx.global.wasCheckedOut()) {
stepsCtx.logger.info('Project directory is already checked out');
@@ -16,6 +16,7 @@ export function createInstallNodeModulesBuildFunction(): BuildFunction {
id: 'install_node_modules',
name: 'Install node modules',
__metricsId: 'eas/install_node_modules',
__hookId: 'install_node_modules',
fn: async (stepCtx, { env }) => {
await installNodeModules(stepCtx, env);
},
@@ -97,6 +97,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
__hookId: 'maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
@@ -23,6 +23,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
id: 'upload_to_asc',
name: 'Upload to App Store Connect',
__metricsId: 'eas/upload_to_asc',
__hookId: 'submit',
inputProviders: [
BuildStepInput.createProvider({
id: 'ipa_path',
@@ -0,0 +1,39 @@
import { Datadog } from '../../datadog';
import { reportWorkflowHookMetricToDatadog } from '../hookMetrics';
describe(reportWorkflowHookMetricToDatadog, () => {
const distributionSpy = jest.spyOn(Datadog, 'distribution').mockImplementation(() => {});
beforeEach(() => {
distributionSpy.mockClear();
});
it('emits one eas.workflow.hook distribution tagged by anchor/timing/result', () => {
reportWorkflowHookMetricToDatadog({
anchor: 'install_node_modules',
timing: 'before',
result: 'success',
});
expect(distributionSpy).toHaveBeenCalledTimes(1);
expect(distributionSpy).toHaveBeenCalledWith('eas.workflow.hook', 1, {
anchor: 'install_node_modules',
timing: 'before',
result: 'success',
});
});
it('adds the anchor_result tag on after events', () => {
reportWorkflowHookMetricToDatadog({
anchor: 'submit',
timing: 'after',
result: 'failed',
anchorResult: 'failed',
});
expect(distributionSpy).toHaveBeenCalledWith('eas.workflow.hook', 1, {
anchor: 'submit',
timing: 'after',
result: 'failed',
anchor_result: 'failed',
});
});
});
@@ -0,0 +1,18 @@
import { WorkflowHookMetric } from '@expo/steps';
import { Datadog } from '../datadog';
/**
* The engine's provider callback is the ONLY `eas.workflow.hook` emitter —
* never emit directly on top. The turtle-job-runs proxy forwards these tags
* as-is; the turtle-builds proxy (custom builds) also adds the standard build
* tags (image, sdk_version, build_mode, …), the same as every build metric.
*/
export function reportWorkflowHookMetricToDatadog(metric: WorkflowHookMetric): void {
Datadog.distribution('eas.workflow.hook', 1, {
anchor: metric.anchor,
timing: metric.timing,
result: metric.result,
...(metric.anchorResult !== undefined ? { anchor_result: metric.anchorResult } : null),
});
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.0.0",
"version": "21.0.1",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "21.0.0"
"@expo/steps": "21.0.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.0.0",
"version": "21.0.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -0,0 +1,44 @@
import { HOOK_ANCHORS, parseHookKey } from '../hooks';
describe('HOOK_ANCHORS', () => {
it('contains the v1 anchors', () => {
expect(Object.keys(HOOK_ANCHORS).sort()).toEqual([
'checkout',
'install_node_modules',
'maestro_cloud',
'maestro_tests',
'submit',
]);
});
it('every entry has a non-empty description', () => {
for (const entry of Object.values(HOOK_ANCHORS)) {
expect(entry.description.length).toBeGreaterThan(0);
}
});
});
describe(parseHookKey, () => {
it('parses valid keys', () => {
expect(parseHookKey('before_install_node_modules')).toEqual({
side: 'before',
anchorId: 'install_node_modules',
});
expect(parseHookKey('after_maestro_cloud')).toEqual({
side: 'after',
anchorId: 'maestro_cloud',
});
});
it('returns null for unknown anchors and malformed keys', () => {
expect(parseHookKey('before_nonexistent')).toBeNull();
expect(parseHookKey('install_node_modules')).toBeNull();
expect(parseHookKey('')).toBeNull();
});
it('does not treat Object prototype property names as registered anchors', () => {
expect(parseHookKey('before___proto__')).toBeNull();
expect(parseHookKey('after_toString')).toBeNull();
expect(parseHookKey('before_constructor')).toBeNull();
});
});
@@ -71,6 +71,30 @@ describe('StepZ', () => {
});
});
describe('hook anchor stamps', () => {
it('accepts the internal __hook_id stamp field on shell steps', () => {
const steps = [{ run: 'echo submit', __hook_id: 'submit' }];
expect(validateSteps(steps)).toEqual(steps);
});
it('accepts and retains the __hook_id stamp on uses: steps', () => {
const steps = [{ uses: 'eas/upload_to_asc', __hook_id: 'submit' }];
expect(validateSteps(steps)).toEqual(steps);
});
it('strips the removed split-pair stamp fields (legacy __hook_before_id/__hook_after_id)', () => {
expect(
validateSteps([
{ run: 'echo x', __hook_before_id: 'maestro_cloud', __hook_after_id: 'maestro_cloud' },
])
).toEqual([{ run: 'echo x' }]);
});
it('treats stamp values as loose strings so old workers never hard-fail on new anchors', () => {
expect(() => validateSteps([{ run: 'echo x', __hook_id: 'some_future_anchor' }])).not.toThrow();
});
});
describe(isStepShellStep, () => {
it('returns true for shell step', () => {
expect(isStepShellStep({ run: 'echo Hello, world!', shell: 'sh' })).toBe(true);
+54
View File
@@ -0,0 +1,54 @@
/**
* Single source of hook anchor names.
*
* An anchor is a named moment in a job that `before_<anchor>` / `after_<anchor>`
* hook keys attach to. Which job types expose which keys is declared on the EAS
* servers (per-job key lists), not here: anchor existence (this registry)
* ≠ binding (self-declared by the carrier) ≠ availability (per-job key lists).
*
* Binding is self-declared by the anchor's carrier: EAS-generated steps carry
* an internal `__hook_id` stamp, and EAS build functions declare `__hookId` on
* their definition (any step invoking a declaring function — directly, or
* inside a function-group expansion — is an occurrence of the anchor). Several
* steps and several functions may declare the same anchor.
*/
export const HOOK_ANCHORS = {
install_node_modules: {
description: 'The step that installs node modules.',
},
submit: {
description: 'The step that submits the application binary to the store.',
},
maestro_tests: {
description: 'The step that runs Maestro tests.',
},
maestro_cloud: {
description: 'The steps that upload to Maestro Cloud and gather its results.',
},
checkout: {
description: 'The step that checks out the project sources.',
},
} as const satisfies Record<string, { description: string }>;
export type HookAnchorId = keyof typeof HOOK_ANCHORS;
export type HookKey = `before_${HookAnchorId}` | `after_${HookAnchorId}`;
export function isHookAnchorId(value: string): value is HookAnchorId {
// Own-property check: `in` would also match Object prototype names
// ("toString", "__proto__", …), breaking the unknown-anchors-are-inert rule.
return Object.hasOwn(HOOK_ANCHORS, value);
}
export function parseHookKey(
key: string
): { side: 'before' | 'after'; anchorId: HookAnchorId } | null {
const match = key.match(/^(before|after)_(.+)$/);
if (!match) {
return null;
}
const [, side, anchorId] = match;
if (!isHookAnchorId(anchorId)) {
return null;
}
return { side: side as 'before' | 'after', anchorId };
}
+1
View File
@@ -24,6 +24,7 @@ export * as errors from './errors';
export * from './artifacts';
export * from './context';
export * from './generic';
export * from './hooks';
export * from './step';
export * from './submission-config';
export * from './projectPackage';
+6
View File
@@ -54,6 +54,12 @@ const CommonStepZ = z.object({
// Internal field for metrics collection. Not documented publicly.
// YAML uses snake_case, but we transform to camelCase for internal use.
__metrics_id: z.string().optional(),
// Internal hook-anchor stamp set by EAS servers on generated anchor steps,
// `uses:` steps included (unstamped function steps bind through the invoked
// function's own anchor declaration instead). Loose strings on purpose
// (never enums): an older worker must treat unknown anchor names as inert
// metadata instead of failing validation.
__hook_id: z.string().optional(),
});
export const FunctionStepZ = CommonStepZ.extend({
+158 -118
View File
@@ -67,6 +67,7 @@ eas --help COMMAND
* [`eas branch:list`](#eas-branchlist)
* [`eas branch:rename`](#eas-branchrename)
* [`eas branch:view [NAME]`](#eas-branchview-name)
* [`eas browse [PAGE]`](#eas-browse-page)
* [`eas build`](#eas-build)
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
* [`eas build:configure`](#eas-buildconfigure)
@@ -142,7 +143,7 @@ eas --help COMMAND
* [`eas observe:routes`](#eas-observeroutes)
* [`eas observe:session SESSIONID`](#eas-observesession-sessionid)
* [`eas observe:versions`](#eas-observeversions)
* [`eas open`](#eas-open)
* [`eas project:delete [NAME]`](#eas-projectdelete-name)
* [`eas project:info`](#eas-projectinfo)
* [`eas project:init`](#eas-projectinit)
* [`eas project:new [PATH]`](#eas-projectnew-path)
@@ -204,7 +205,7 @@ DESCRIPTION
view the audit logs for an account
```
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/audit.ts)_
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/account/audit.ts)_
## `eas account:login`
@@ -225,7 +226,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -242,7 +243,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -264,7 +265,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -281,7 +282,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -295,7 +296,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -347,7 +348,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -368,7 +369,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -388,7 +389,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -408,7 +409,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -431,7 +432,30 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas browse [PAGE]`
Transition from the terminal to the web browser to view and interact with your project on https://expo.dev
```
USAGE
$ eas browse [PAGE] [-n] [--json] [--non-interactive]
ARGUMENTS
[PAGE] (build|builds|submit|submissions|update|updates|workflow|workflows|cicd|hosting|deployments|credentials|env|in
sights|observe|settings) Project subpage to open. Defaults to the project dashboard.
FLAGS
-n, --no-browser Print the URL instead of opening it in a web browser
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
Transition from the terminal to the web browser to view and interact with your project on https://expo.dev
```
_See code: [packages/eas-cli/src/commands/browse.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/browse.ts)_
## `eas build`
@@ -472,7 +496,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -492,7 +516,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -510,7 +534,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -530,7 +554,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -555,7 +579,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -582,7 +606,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -620,7 +644,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -660,7 +684,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -690,7 +714,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -718,7 +742,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -770,7 +794,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -788,7 +812,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -806,7 +830,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -823,7 +847,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -844,7 +868,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -865,7 +889,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -887,7 +911,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -911,7 +935,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -931,7 +955,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -953,7 +977,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -975,7 +999,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -1010,7 +1034,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -1033,7 +1057,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1054,7 +1078,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1071,7 +1095,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1089,7 +1113,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1099,9 +1123,12 @@ deploy your Expo Router web build and API Routes
USAGE
$ eas deploy [options]
$ eas deploy --prod
$ eas deploy --non-interactive --dev-domain my-app
FLAGS
--alias=name Custom alias to assign to the new deployment.
--dev-domain=name Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app"
for my-app.expo.app. Required with --non-interactive if you want to customize the preview URL.
--dry-run Outputs a tarball of the new deployment instead of uploading it.
--environment=<value> Environment variable's environment, e.g. 'production', 'preview', 'development'
--export-dir=dir [default: dist] Directory where the Expo project was exported.
@@ -1118,7 +1145,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1143,7 +1170,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1164,7 +1191,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1185,7 +1212,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1222,7 +1249,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1242,7 +1269,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1263,7 +1290,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1284,7 +1311,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1298,7 +1325,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1312,7 +1339,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1344,7 +1371,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1370,7 +1397,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1392,7 +1419,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1420,7 +1447,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1448,7 +1475,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1471,7 +1498,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1493,7 +1520,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1527,7 +1554,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1571,7 +1598,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1602,7 +1629,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1665,7 +1692,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1684,7 +1711,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1702,7 +1729,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
@@ -1724,7 +1751,7 @@ DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
@@ -1738,7 +1765,7 @@ DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
@@ -1752,7 +1779,7 @@ DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
@@ -1770,7 +1797,7 @@ DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
@@ -1784,7 +1811,7 @@ DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
@@ -1805,7 +1832,7 @@ DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
@@ -1825,7 +1852,7 @@ DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas integrations:posthog:connect`
@@ -1852,7 +1879,7 @@ DESCRIPTION
connect PostHog to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
## `eas integrations:posthog:dashboard`
@@ -1872,7 +1899,7 @@ DESCRIPTION
open the PostHog dashboard for the linked PostHog project
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
## `eas integrations:posthog:disconnect`
@@ -1891,7 +1918,7 @@ DESCRIPTION
remove the PostHog project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
## `eas login`
@@ -1943,7 +1970,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1961,7 +1988,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1979,7 +2006,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -2038,7 +2065,7 @@ DESCRIPTION
arguments, a list of the available event names and associated event counts is returned.
```
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/events.ts)_
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/observe/events.ts)_
## `eas observe:metrics [METRIC]`
@@ -2074,7 +2101,7 @@ DESCRIPTION
display individual performance metric samples ordered by value
```
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/metrics.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/observe/metrics.ts)_
## `eas observe:metrics-summary`
@@ -2106,7 +2133,7 @@ DESCRIPTION
display aggregated performance metric statistics grouped by app version
```
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
## `eas observe:routes`
@@ -2143,7 +2170,7 @@ DESCRIPTION
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
```
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/routes.ts)_
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/observe/routes.ts)_
## `eas observe:session SESSIONID`
@@ -2165,7 +2192,7 @@ DESCRIPTION
display the timeline of metric and log events for a specific session
```
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/session.ts)_
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/observe/session.ts)_
## `eas observe:versions`
@@ -2190,21 +2217,31 @@ DESCRIPTION
display app versions with build and update details
```
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/versions.ts)_
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/observe/versions.ts)_
## `eas open`
## `eas project:delete [NAME]`
open the project page in a web browser
delete a project
```
USAGE
$ eas open
$ eas project:delete [NAME] [--dangerously-confirm-deletion <value>] [--json] [--non-interactive]
ARGUMENTS
[NAME] Full name (@account/slug) or ID of the project to delete. Defaults to the project in the current directory.
FLAGS
--dangerously-confirm-deletion=<value> The project's full name (@account/slug), to confirm deletion. Required in
non-interactive mode.
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies
--non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
open the project page in a web browser
delete a project
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/project/delete.ts)_
## `eas project:info`
@@ -2218,7 +2255,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -2241,7 +2278,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2265,7 +2302,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/project/new.ts)_
## `eas submit`
@@ -2298,7 +2335,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2341,7 +2378,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2362,7 +2399,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2383,7 +2420,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2408,7 +2445,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:embedded:delete ID`
@@ -2429,7 +2466,7 @@ DESCRIPTION
delete an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/embedded/delete.ts)_
## `eas update:embedded:list`
@@ -2455,7 +2492,7 @@ DESCRIPTION
list embedded updates registered with EAS Update for this project
```
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/embedded/list.ts)_
## `eas update:embedded:upload`
@@ -2485,7 +2522,7 @@ EXAMPLES
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
```
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/embedded/upload.ts)_
## `eas update:embedded:view ID`
@@ -2505,7 +2542,7 @@ DESCRIPTION
view details of an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/embedded/view.ts)_
## `eas update:insights GROUPID`
@@ -2532,7 +2569,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2559,7 +2596,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2597,7 +2634,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2625,7 +2662,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2655,7 +2692,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback [GROUPID]`
@@ -2687,7 +2724,7 @@ DESCRIPTION
roll back to an embedded update or an existing update
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2711,7 +2748,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2732,7 +2769,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2754,7 +2791,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2774,7 +2811,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2793,7 +2830,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2816,7 +2853,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2833,7 +2870,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2919,9 +2956,12 @@ deploy your Expo Router web build and API Routes
USAGE
$ eas worker:deploy deploy [options]
$ eas worker:deploy deploy --prod
$ eas worker:deploy deploy --non-interactive --dev-domain my-app
FLAGS
--alias=name Custom alias to assign to the new deployment.
--dev-domain=name Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app"
for my-app.expo.app. Required with --non-interactive if you want to customize the preview URL.
--dry-run Outputs a tarball of the new deployment instead of uploading it.
--environment=<value> Environment variable's environment, e.g. 'production', 'preview', 'development'
--export-dir=dir [default: dist] Directory where the Expo project was exported.
@@ -2954,7 +2994,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2976,7 +3016,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -3000,7 +3040,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -3040,7 +3080,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -3062,7 +3102,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -3090,7 +3130,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -3110,7 +3150,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -3132,5 +3172,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v21.0.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+29 -29
View File
@@ -58471,7 +58471,7 @@
},
{
"name": "setPreferences",
"description": "Sets user preferences. This is a key-value store for user-specific settings. Provided values are\nkey-level merged with existing values.",
"description": "Legacy user preferences are no longer stored; this mutation accepts and discards\nits input. Use userPreference.set instead.",
"args": [
{
"name": "preferences",
@@ -58499,8 +58499,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "No longer stored; this mutation has no effect. Use userPreference.set instead."
},
{
"name": "setPrimarySecondFactorDevice",
@@ -66775,8 +66775,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "No longer stored. Use userPreference instead."
},
{
"name": "primaryAccount",
@@ -76602,8 +76602,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "No longer stored. Use userPreference instead."
},
{
"name": "primaryAccount",
@@ -77305,8 +77305,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "No longer stored. Use userPreference instead."
},
{
"name": "primaryAccount",
@@ -80045,20 +80045,8 @@
"name": "UserPreferencesOnboarding",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "selectedAccountName",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": true,
"deprecationReason": "Use userPreference.get(key: \"selected-account-id\") instead."
"deprecationReason": "No longer stored; always null."
}
],
"inputFields": null,
@@ -80112,7 +80100,7 @@
},
{
"name": "deviceType",
"description": "Can be null if the user has not selected one yet.",
"description": null,
"args": [],
"type": {
"kind": "ENUM",
@@ -80124,7 +80112,7 @@
},
{
"name": "environment",
"description": "Can be null if the user has not selected one yet.",
"description": null,
"args": [],
"type": {
"kind": "ENUM",
@@ -80136,7 +80124,7 @@
},
{
"name": "isCLIDone",
"description": "Set by CLI when the user has completed that phase. Used by the website to determine when\nthe next step can be shown.",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
@@ -80148,7 +80136,7 @@
},
{
"name": "lastUsed",
"description": "The last time when this object was updated.",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
@@ -80164,7 +80152,7 @@
},
{
"name": "platform",
"description": "User selects a platform for which they want to build the app. CLI uses this information to start the build.",
"description": null,
"args": [],
"type": {
"kind": "ENUM",
@@ -88995,6 +88983,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "reviewingActor",
"description": null,
"args": [],
"type": {
"kind": "INTERFACE",
"name": "Actor",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": null,
@@ -89020,8 +89020,8 @@
"name": "UserActor",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use reviewingActor instead"
},
{
"name": "workflowJob",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.0.0",
"version": "21.0.1",
"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.0.0",
"@expo/eas-json": "21.0.0",
"@expo/eas-build-job": "21.0.1",
"@expo/eas-json": "21.0.1",
"@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.0.0",
"@expo/steps": "21.0.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -121,7 +121,7 @@
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
"tar": "7.5.7",
"tar": "7.5.19",
"tar-stream": "3.1.7",
"terminal-link": "2.1.1",
"ts-deepmerge": "6.2.0",
@@ -0,0 +1,35 @@
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
describe(getProjectPageUrl, () => {
it('builds the project dashboard URL when no page is provided', () => {
expect(getProjectPageUrl('testuser', 'testapp')).toBe(
'https://expo.dev/accounts/testuser/projects/testapp'
);
});
it('appends the page path when provided', () => {
expect(getProjectPageUrl('testuser', 'testapp', 'builds')).toBe(
'https://expo.dev/accounts/testuser/projects/testapp/builds'
);
});
it('preserves nested page paths without encoding the separator', () => {
expect(getProjectPageUrl('testuser', 'testapp', 'hosting/deployments')).toBe(
'https://expo.dev/accounts/testuser/projects/testapp/hosting/deployments'
);
});
it('encodes account and project names', () => {
expect(getProjectPageUrl('my org', 'my app', 'updates')).toBe(
'https://expo.dev/accounts/my%20org/projects/my%20app/updates'
);
});
});
describe(getProjectDashboardUrl, () => {
it('matches the page URL with no page', () => {
expect(getProjectDashboardUrl('testuser', 'testapp')).toBe(
getProjectPageUrl('testuser', 'testapp')
);
});
});
+12 -4
View File
@@ -4,10 +4,18 @@ import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
import { AppPlatform, BuildFragment } from '../../graphql/generated';
export function getProjectDashboardUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${accountName}/projects/${projectName}`,
getExpoWebsiteBaseUrl()
).toString();
return getProjectPageUrl(accountName, projectName);
}
export function getProjectPageUrl(
accountName: string,
projectName: string,
page?: string | null
): string {
const projectPath = `/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}`;
return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString();
}
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
@@ -0,0 +1,142 @@
import openBrowserAsync from 'better-opn';
import { getMockOclifConfig } from '../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../credentials/__tests__/fixtures-constants';
import { AccountFragment } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import Browse from '../browse';
jest.mock('better-opn');
jest.mock('../../log');
jest.mock('../../project/projectUtils');
jest.mock('../../utils/json');
jest.mock('../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(Browse, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
function createCommand(argv: string[]): Browse {
const command = new Browse(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(getOwnerAccountForProjectIdAsync)
.mockResolvedValue({ name: 'testuser' } as AccountFragment);
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
});
it('opens the project dashboard when no page is provided', async () => {
await createCommand([]).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://expo.dev/accounts/testuser/projects/testapp'
);
});
it('opens a subpage by its EAS command name', async () => {
await createCommand(['build']).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://expo.dev/accounts/testuser/projects/testapp/builds'
);
});
it('maps the website wording to the same subpage', async () => {
await createCommand(['submissions']).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://expo.dev/accounts/testuser/projects/testapp/submissions'
);
});
it('opens the observe page', async () => {
await createCommand(['observe']).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://expo.dev/accounts/testuser/projects/testapp/observe'
);
});
it('prints the URL without opening a browser when --no-browser is passed', async () => {
await createCommand(['hosting', '--no-browser']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(
'https://expo.dev/accounts/testuser/projects/testapp/hosting'
);
});
it('rejects an unknown page', async () => {
await expect(createCommand(['nope']).runAsync()).rejects.toThrow();
expect(openBrowserAsync).not.toHaveBeenCalled();
});
it('forwards non-interactive mode to the context so it can run without prompts', async () => {
const command = createCommand(['--non-interactive']);
await command.runAsync();
expect((command as any).getContextAsync).toHaveBeenCalledWith(
Browse,
expect.objectContaining({ nonInteractive: true })
);
});
it('prints the URL as JSON without opening a browser when --json is passed', async () => {
const command = createCommand(['build', '--json']);
await command.runAsync();
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
url: 'https://expo.dev/accounts/testuser/projects/testapp/builds',
});
expect(openBrowserAsync).not.toHaveBeenCalled();
expect((command as any).getContextAsync).toHaveBeenCalledWith(
Browse,
expect.objectContaining({ nonInteractive: true })
);
});
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false);
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await createCommand([]).runAsync();
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Args, Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import { getProjectPageUrl } from '../build/utils/url';
import EasCommand from '../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import Log from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
const PROJECT_PAGES: Record<string, string> = {
build: 'builds',
builds: 'builds',
submit: 'submissions',
submissions: 'submissions',
update: 'updates',
updates: 'updates',
workflow: 'workflows',
workflows: 'workflows',
cicd: 'workflows',
hosting: 'hosting',
deployments: 'hosting/deployments',
credentials: 'credentials',
env: 'environment-variables',
insights: 'insights',
observe: 'observe',
settings: 'settings',
};
export default class Browse extends EasCommand {
static override description =
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
static override args = {
page: Args.string({
description: 'Project subpage to open. Defaults to the project dashboard.',
required: false,
options: Object.keys(PROJECT_PAGES),
}),
};
static override flags = {
'no-browser': Flags.boolean({
char: 'n',
description: 'Print the URL instead of opening it in a web browser',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(Browse);
const page = args.page ? PROJECT_PAGES[args.page] : null;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(Browse, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const url = getProjectPageUrl(account.name, exp.slug, page);
if (jsonFlag) {
printJsonOnlyOutput({ url });
return;
}
if (flags['no-browser']) {
Log.log(url);
return;
}
const failedMessage = `Unable to open a web browser. Project page is available at: ${url}`;
const spinner = ora(`Opening ${url}`).start();
try {
const opened = await openBrowserAsync(url);
if (opened) {
spinner.succeed(`Opened ${url}`);
} else {
spinner.fail(failedMessage);
}
} catch (error) {
spinner.fail(failedMessage);
throw error;
}
}
}
+21 -1
View File
@@ -18,6 +18,7 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import * as WorkerAssets from '../../worker/assets';
import {
assertValidDevDomainName,
assignWorkerDeploymentAliasAsync,
assignWorkerDeploymentProductionAsync,
getSignedDeploymentUrlAsync,
@@ -30,6 +31,7 @@ import {
uploadAsync,
} from '../../worker/upload';
import {
EXPO_BASE_DOMAIN,
formatWorkerDeploymentJson,
formatWorkerDeploymentTable,
getDeploymentUrlFromFullName,
@@ -50,6 +52,7 @@ interface DeployFlags {
aliasName?: string;
environment?: string;
deploymentIdentifier?: string;
devDomainName?: string;
exportDir: string;
dryRun: boolean;
sourceMaps: boolean;
@@ -62,6 +65,7 @@ interface RawDeployFlags {
prod: boolean;
alias?: string;
id?: string;
'dev-domain'?: string;
'export-dir': string;
'dry-run': boolean;
'source-maps': boolean;
@@ -82,7 +86,11 @@ interface DeployInProgressParams {
export default class WorkerDeploy extends EasCommand {
static override description = 'deploy your Expo Router web build and API Routes';
static override aliases = ['worker:deploy'];
static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`];
static override usage = [
chalk`deploy {dim [options]}`,
`deploy --prod`,
`deploy --non-interactive --dev-domain my-app`,
];
static override state = 'preview';
static override flags = {
@@ -99,6 +107,10 @@ export default class WorkerDeploy extends EasCommand {
description: 'Custom unique identifier for the new deployment.',
helpValue: 'xyz123',
}),
'dev-domain': Flags.string({
description: `Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app" for my-app.${EXPO_BASE_DOMAIN}.app. Required with --non-interactive if you want to customize the preview URL.`,
helpValue: 'name',
}),
'export-dir': Flags.string({
description: 'Directory where the Expo project was exported.',
helpValue: 'dir',
@@ -322,6 +334,7 @@ export default class WorkerDeploy extends EasCommand {
const uploadUrl = await getSignedDeploymentUrlAsync(graphqlClient, {
appId: projectId,
deploymentIdentifier: flags.deploymentIdentifier,
devDomainName: flags.devDomainName,
// NOTE(cedric): this function might ask the user for a dev-domain name,
// when that happens, no ora spinner should be running.
onSetupDevDomain: () => progress.stop(),
@@ -467,12 +480,19 @@ export default class WorkerDeploy extends EasCommand {
private sanitizeFlags(flags: RawDeployFlags): DeployFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const devDomainName = flags['dev-domain']?.trim().toLowerCase();
if (devDomainName !== undefined) {
assertValidDevDomainName(devDomainName);
}
return {
nonInteractive,
json,
isProduction: !!flags.prod,
aliasName: flags.alias?.trim().toLowerCase(),
deploymentIdentifier: flags.id?.trim(),
devDomainName,
exportDir: flags['export-dir'],
environment: flags['environment'],
dryRun: flags['dry-run'],
+4 -38
View File
@@ -1,46 +1,12 @@
import openBrowserAsync from 'better-opn';
import { getProjectDashboardUrl } from '../build/utils/url';
import EasCommand from '../commandUtils/EasCommand';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import Browse from './browse';
export default class Open extends EasCommand {
static override description = 'open the project page in a web browser';
static override description = 'open the project page in a web browser (alias of `eas browse`)';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
static override hidden = true;
async runAsync(): Promise<void> {
// this command is interactive by nature (only really run by humans in a terminal)
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(Open, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const projectName = exp.slug;
const projectDashboardUrl = getProjectDashboardUrl(account.name, projectName);
const failedMessage = `Unable to open a web browser. Project page is available at: ${projectDashboardUrl}`;
const spinner = ora(`Opening ${projectDashboardUrl}`).start();
try {
const opened = await openBrowserAsync(projectDashboardUrl);
if (opened) {
spinner.succeed(`Opened ${projectDashboardUrl}`);
} else {
spinner.fail(failedMessage);
}
} catch (error) {
spinner.fail(failedMessage);
throw error;
}
await Browse.run([]);
}
}
@@ -0,0 +1,268 @@
import { getConfigFilePaths } from '@expo/config';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { findProjectRootAsync } from '../../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import {
testAppQueryByIdResponse,
testExperienceName,
testProjectId,
} from '../../../credentials/__tests__/fixtures-constants';
import {
BackgroundJobReceiptDataFragment,
BackgroundJobResultType,
BackgroundJobState,
} from '../../../graphql/generated';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log from '../../../log';
import { getPrivateExpoConfigAsync } from '../../../project/expoConfig';
import { promptAsync } from '../../../prompts';
import { isSudoModeRequiredError, promptForSudoModeUpgradeAsync } from '../../../user/sudo';
import { pollForBackgroundJobReceiptAsync } from '../../../utils/pollForBackgroundJobReceiptAsync';
import ProjectDelete from '../delete';
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../utils/pollForBackgroundJobReceiptAsync');
jest.mock('../../../prompts');
jest.mock('../../../user/sudo');
jest.mock('../../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync');
jest.mock('../../../project/expoConfig');
jest.mock('@expo/config');
jest.mock('../../../log');
jest.mock('../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(ProjectDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockReceipt: BackgroundJobReceiptDataFragment = {
id: 'receipt-1',
state: BackgroundJobState.Success,
tries: 1,
willRetry: false,
resultId: null,
resultType: BackgroundJobResultType.Void,
resultData: null,
errorCode: null,
errorMessage: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
function createCommand(argv: string[], { insideProjectDir = true } = {}): ProjectDelete {
if (insideProjectDir) {
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: '/app/app.json', dynamicConfigPath: null });
jest
.mocked(getPrivateExpoConfigAsync)
.mockResolvedValue({ extra: { eas: { projectId: testProjectId } } } as never);
} else {
jest
.mocked(findProjectRootAsync)
.mockRejectedValue(new Error('Run this command inside a project directory.'));
}
const command = new ProjectDelete(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
loggedIn: {
graphqlClient,
authenticationInfo: { accessToken: null, sessionSecret: 'session-secret' },
},
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
jest.mocked(AppQuery.byFullNameAsync).mockResolvedValue(testAppQueryByIdResponse);
jest.mocked(AppMutation.scheduleAppDeletionAsync).mockResolvedValue(mockReceipt);
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(mockReceipt);
jest.mocked(promptAsync).mockResolvedValue({ confirmedName: testExperienceName });
jest.mocked(isSudoModeRequiredError).mockReturnValue(false);
});
it('deletes the project when the typed name matches', async () => {
await createCommand([]).runAsync();
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(testExperienceName),
})
);
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(graphqlClient, mockReceipt);
});
it('cancels deletion when the typed name does not match', async () => {
jest.mocked(promptAsync).mockResolvedValue({ confirmedName: 'wrong-name' });
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
await expect(createCommand([]).runAsync()).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it('deletes without prompting when --dangerously-confirm-deletion matches the full name', async () => {
await createCommand(['--dangerously-confirm-deletion', testExperienceName]).runAsync();
expect(promptAsync).not.toHaveBeenCalled();
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
});
it('throws when --dangerously-confirm-deletion does not match the full name', async () => {
await expect(
createCommand(['--dangerously-confirm-deletion', 'wrong-name']).runAsync()
).rejects.toThrow(/did not match the project's full name/);
expect(promptAsync).not.toHaveBeenCalled();
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
});
it('deletes in non-interactive mode when --dangerously-confirm-deletion matches the full name', async () => {
await createCommand([
'--non-interactive',
'--dangerously-confirm-deletion',
testExperienceName,
]).runAsync();
expect(promptAsync).not.toHaveBeenCalled();
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
});
it('throws in non-interactive mode without --dangerously-confirm-deletion', async () => {
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
/requires passing --dangerously-confirm-deletion/
);
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
});
it('deletes a project by full name argument outside a project directory', async () => {
await createCommand(
[
testExperienceName,
'--non-interactive',
'--dangerously-confirm-deletion',
testExperienceName,
],
{ insideProjectDir: false }
).runAsync();
expect(AppQuery.byFullNameAsync).toHaveBeenCalledWith(graphqlClient, testExperienceName);
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
});
it('deletes a project by ID argument', async () => {
await createCommand([
'00000000-0000-0000-0000-000000000001',
'--non-interactive',
'--dangerously-confirm-deletion',
testExperienceName,
]).runAsync();
expect(AppQuery.byIdAsync).toHaveBeenCalledWith(
graphqlClient,
'00000000-0000-0000-0000-000000000001'
);
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
});
it('throws outside a project directory when no argument is passed', async () => {
await expect(createCommand([], { insideProjectDir: false }).runAsync()).rejects.toThrow(
/No EAS project found in the current directory/
);
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
});
it('throws when the current project has no EAS projectId configured, without offering to create one', async () => {
const command = createCommand([]);
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({} as never);
await expect(command.runAsync()).rejects.toThrow(
/No EAS project found in the current directory/
);
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
});
it('does not read or create an app config when the directory has none', async () => {
const command = createCommand([]);
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await expect(command.runAsync()).rejects.toThrow(
/No EAS project found in the current directory/
);
expect(getPrivateExpoConfigAsync).not.toHaveBeenCalled();
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
});
it('propagates app config errors instead of reporting no project found', async () => {
const command = createCommand([]);
jest
.mocked(getPrivateExpoConfigAsync)
.mockRejectedValue(new Error('Invalid app config.\n"name" is required'));
await expect(command.runAsync()).rejects.toThrow(/Invalid app config/);
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
});
it('upgrades the session to sudo mode and retries when the server requires it', async () => {
const sudoError = new Error('sudo required');
jest
.mocked(AppMutation.scheduleAppDeletionAsync)
.mockRejectedValueOnce(sudoError)
.mockResolvedValueOnce(mockReceipt);
jest.mocked(isSudoModeRequiredError).mockImplementation(e => e === sudoError);
await createCommand([]).runAsync();
expect(promptForSudoModeUpgradeAsync).toHaveBeenCalledWith({
accessToken: null,
sessionSecret: 'session-secret',
});
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledTimes(2);
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(graphqlClient, mockReceipt);
});
it('throws in non-interactive mode when sudo mode is required', async () => {
const sudoError = new Error('sudo required');
jest.mocked(AppMutation.scheduleAppDeletionAsync).mockRejectedValue(sudoError);
jest.mocked(isSudoModeRequiredError).mockImplementation(e => e === sudoError);
await expect(
createCommand([
'--non-interactive',
'--dangerously-confirm-deletion',
testExperienceName,
]).runAsync()
).rejects.toThrow(/requires a session in sudo mode/);
expect(promptForSudoModeUpgradeAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,154 @@
import { getConfigFilePaths } from '@expo/config';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { findProjectRootAsync } from '../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { AppFragment } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { promptAsync } from '../../prompts';
import { isSudoModeRequiredError, promptForSudoModeUpgradeAsync } from '../../user/sudo';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export default class ProjectDelete extends EasCommand {
static override description = 'delete a project';
static override args = {
name: Args.string({
required: false,
description:
'Full name (@account/slug) or ID of the project to delete. Defaults to the project in the current directory.',
}),
};
static override flags = {
'dangerously-confirm-deletion': Flags.string({
description:
"The project's full name (@account/slug), to confirm deletion. Required in non-interactive mode.",
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { name },
flags,
} = await this.parse(ProjectDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient, authenticationInfo },
} = await this.getContextAsync(ProjectDelete, { nonInteractive });
let app: AppFragment;
if (name) {
app = UUID_REGEX.test(name)
? await AppQuery.byIdAsync(graphqlClient, name)
: await AppQuery.byFullNameAsync(graphqlClient, name);
} else {
let projectDir: string | null = null;
try {
projectDir = await findProjectRootAsync();
} catch {
// Not inside a project directory — handled by the error below.
}
let projectId: string | undefined;
if (projectDir) {
// Only read the config when one already exists: getPrivateExpoConfigAsync creates a
// fresh app.json as a side effect when the directory has none. Config errors (broken
// app.config.js, invalid app.json) propagate so they are not masked by the error below.
const configPaths = getConfigFilePaths(projectDir);
if (configPaths.staticConfigPath ?? configPaths.dynamicConfigPath) {
const exp = await getPrivateExpoConfigAsync(projectDir);
projectId = exp.extra?.eas?.projectId;
}
}
if (!projectId) {
throw new Error(
"No EAS project found in the current directory. Pass the project's full name (@account/slug) or ID as an argument."
);
}
app = await AppQuery.byIdAsync(graphqlClient, projectId);
}
const confirmedFullName = flags['dangerously-confirm-deletion'];
if (confirmedFullName) {
if (confirmedFullName !== app.fullName) {
throw new Error(
`The value of --dangerously-confirm-deletion ("${confirmedFullName}") did not match the project's full name ("${app.fullName}"). Cancelled deletion.`
);
}
} else if (nonInteractive) {
throw new Error(
`Deleting a project in non-interactive mode requires passing --dangerously-confirm-deletion with the project's full name ("${app.fullName}").`
);
} else {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete project ${chalk.bold(app.fullName)}.` +
`\nThis will delete everything associated with it, including builds, submissions, update branches, published updates, and environment variables.` +
`\nThis action is irreversible.`
);
Log.newLine();
const { confirmedName } = await promptAsync({
type: 'text',
name: 'confirmedName',
message: `Type the project's full name (${app.fullName}) to confirm deletion:`,
});
if (confirmedName !== app.fullName) {
Log.error(`The input did not match the project's full name. Cancelled deletion.`);
process.exit(1);
}
}
const spinner = ora(`Deleting project ${app.fullName}`).start();
try {
let receipt;
try {
receipt = await AppMutation.scheduleAppDeletionAsync(graphqlClient, app.id);
} catch (error) {
if (!isSudoModeRequiredError(error)) {
throw error;
}
if (nonInteractive) {
throw new Error(
'Deleting a project requires a session in sudo mode. Run this command interactively to confirm your password, then retry in non-interactive mode while sudo mode is active.'
);
}
spinner.stop();
await promptForSudoModeUpgradeAsync(authenticationInfo);
spinner.start();
receipt = await AppMutation.scheduleAppDeletionAsync(graphqlClient, app.id);
}
await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
spinner.succeed(`Deleted project ${chalk.bold(app.fullName)}`);
} catch (error) {
spinner.fail(`Failed to delete project ${chalk.bold(app.fullName)}`);
throw error;
}
if (jsonFlag) {
printJsonOnlyOutput({ id: app.id, fullName: app.fullName });
}
}
}
@@ -49,6 +49,7 @@ export default class SimulatorStart extends EasCommand {
static override flags = {
platform: Flags.option({
char: 'p',
description: 'Device platform',
options: ['android', 'ios'] as const,
required: true,
+23 -12
View File
@@ -8217,8 +8217,9 @@ export type MeMutation = {
/** Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. */
scheduleSSOUserDeletionAsSSOAccountOwner: BackgroundJobReceipt;
/**
* Sets user preferences. This is a key-value store for user-specific settings. Provided values are
* key-level merged with existing values.
* 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 */
@@ -9387,6 +9388,7 @@ export type SsoUser = Actor & UserActor & {
lastName?: Maybe<Scalars['String']['output']>;
pinnedApps: Array<App>;
pinnedDashboardViews: Array<PinnedDashboardView>;
/** @deprecated No longer stored. Use userPreference instead. */
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
@@ -10739,6 +10741,7 @@ export type User = Actor & UserActor & {
pendingUserInvitations: Array<UserInvitation>;
pinnedApps: Array<App>;
pinnedDashboardViews: Array<PinnedDashboardView>;
/** @deprecated No longer stored. Use userPreference instead. */
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
@@ -10833,6 +10836,7 @@ export type UserActor = {
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
pinnedApps: Array<App>;
/** @deprecated No longer stored. Use userPreference instead. */
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
@@ -11276,9 +11280,8 @@ export type UserPreferenceQueryGetArgs = {
export type UserPreferences = {
__typename?: 'UserPreferences';
/** @deprecated No longer stored; always null. */
onboarding?: Maybe<UserPreferencesOnboarding>;
/** @deprecated Use userPreference.get(key: "selected-account-id") instead. */
selectedAccountName?: Maybe<Scalars['String']['output']>;
};
export type UserPreferencesInput = {
@@ -11292,18 +11295,10 @@ export type UserPreferencesInput = {
export type UserPreferencesOnboarding = {
__typename?: 'UserPreferencesOnboarding';
appId: Scalars['ID']['output'];
/** Can be null if the user has not selected one yet. */
deviceType?: Maybe<OnboardingDeviceType>;
/** Can be null if the user has not selected one yet. */
environment?: Maybe<OnboardingEnvironment>;
/**
* Set by CLI when the user has completed that phase. Used by the website to determine when
* the next step can be shown.
*/
isCLIDone?: Maybe<Scalars['Boolean']['output']>;
/** The last time when this object was updated. */
lastUsed: Scalars['String']['output'];
/** User selects a platform for which they want to build the app. CLI uses this information to start the build. */
platform?: Maybe<AppPlatform>;
};
@@ -12392,7 +12387,9 @@ export type WorkflowJobApproval = {
createdAt: Scalars['DateTime']['output'];
decision: WorkflowJobReviewDecision;
id: Scalars['ID']['output'];
reviewingActor?: Maybe<Actor>;
updatedAt: Scalars['DateTime']['output'];
/** @deprecated Use reviewingActor instead */
userActor?: Maybe<UserActor>;
workflowJob: WorkflowJob;
};
@@ -13330,6 +13327,13 @@ export type CreateAppMutationVariables = Exact<{
export type CreateAppMutation = { __typename?: 'RootMutation', app?: { __typename?: 'AppMutation', createApp: { __typename?: 'App', id: string } } | null };
export type ScheduleAppDeletionMutationVariables = Exact<{
appId: Scalars['ID']['input'];
}>;
export type ScheduleAppDeletionMutation = { __typename?: 'RootMutation', app?: { __typename?: 'AppMutation', scheduleAppDeletion: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } | null };
export type CreateAppVersionMutationVariables = Exact<{
appVersionInput: AppVersionInput;
}>;
@@ -14518,6 +14522,13 @@ export type SuggestedDevDomainNameQueryVariables = Exact<{
export type SuggestedDevDomainNameQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, suggestedDevDomainName: string } } };
export type DevDomainNameByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type DevDomainNameByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, devDomainName?: { __typename?: 'AppDevDomainName', id: string, name: any } | null } } };
export type PaginatedWorkerDeploymentAliasesQueryVariables = Exact<{
appId: Scalars['String']['input'];
first?: InputMaybe<Scalars['Int']['input']>;
@@ -3,7 +3,14 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { CreateAppMutation, CreateAppMutationVariables } from '../generated';
import {
BackgroundJobReceiptDataFragment,
CreateAppMutation,
CreateAppMutationVariables,
ScheduleAppDeletionMutation,
ScheduleAppDeletionMutationVariables,
} from '../generated';
import { BackgroundJobReceiptNode } from '../types/BackgroundJobReceipt';
export const AppMutation = {
async createAppAsync(
@@ -35,4 +42,30 @@ export const AppMutation = {
assert(appId, 'App ID must be defined');
return appId;
},
async scheduleAppDeletionAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleAppDeletionMutation, ScheduleAppDeletionMutationVariables>(
gql`
mutation ScheduleAppDeletion($appId: ID!) {
app {
scheduleAppDeletion(appId: $appId) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ appId }
)
.toPromise()
);
const receipt = result.app?.scheduleAppDeletion;
assert(receipt, 'Background job receipt must be defined');
return receipt;
},
};
@@ -0,0 +1,62 @@
import { promptForSudoModeUpgradeAsync } from '../sudo';
import { ApiV2Error } from '../../ApiV2Error';
import { ApiV2Client } from '../../api';
import { promptAsync } from '../../prompts';
jest.mock('../../api');
jest.mock('../../prompts');
jest.mock('../../log');
const authenticationInfo = { accessToken: null, sessionSecret: 'session-secret' } as const;
describe(promptForSudoModeUpgradeAsync, () => {
let postAsync: jest.Mock;
beforeEach(() => {
jest.resetAllMocks();
postAsync = jest.fn().mockResolvedValue({ data: { status: 'success' } });
jest.mocked(ApiV2Client).mockImplementation(() => ({ postAsync }) as unknown as ApiV2Client);
jest.mocked(promptAsync).mockResolvedValue({ password: 'hunter2', otp: '123456' });
});
it('upgrades the session with the password', async () => {
await promptForSudoModeUpgradeAsync(authenticationInfo);
expect(postAsync).toHaveBeenCalledWith('auth/upgradeSudo', {
body: { password: 'hunter2' },
});
});
it('retries with an OTP when one is required', async () => {
postAsync
.mockRejectedValueOnce(
new ApiV2Error({ message: 'otp required', code: 'ONE_TIME_PASSWORD_REQUIRED' })
)
.mockResolvedValueOnce({ data: { status: 'success' } });
await promptForSudoModeUpgradeAsync(authenticationInfo);
expect(postAsync).toHaveBeenCalledTimes(2);
expect(postAsync).toHaveBeenLastCalledWith('auth/upgradeSudo', {
body: { password: 'hunter2', otp: '123456' },
});
});
it('rethrows other API errors', async () => {
postAsync.mockRejectedValue(
new ApiV2Error({ message: 'The password you entered is invalid', code: 'VALIDATION_ERROR' })
);
await expect(promptForSudoModeUpgradeAsync(authenticationInfo)).rejects.toThrow(
/password you entered is invalid/
);
});
it('throws for access-token sessions', async () => {
await expect(
promptForSudoModeUpgradeAsync({ accessToken: 'token', sessionSecret: null })
).rejects.toThrow(/Access tokens/);
expect(postAsync).not.toHaveBeenCalled();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { LoggedInAuthenticationInfo } from './SessionManager';
import { ApiV2Error } from '../ApiV2Error';
import { ApiV2Client } from '../api';
import { GraphqlError } from '../graphql/client';
import Log from '../log';
import { promptAsync } from '../prompts';
export const SUDO_MODE_REQUIRED_ERROR_CODE = 'SUDO_MODE_REQUIRED';
/**
* Whether the error is the server signaling that the current session must be upgraded
* to sudo mode before performing this operation.
*/
export function isSudoModeRequiredError(error: unknown): boolean {
return (
error instanceof GraphqlError &&
error.graphQLErrors.some(e => e?.extensions?.errorCode === SUDO_MODE_REQUIRED_ERROR_CODE)
);
}
/**
* Upgrade the current session to sudo mode by re-confirming the user's password
* (and OTP if two-factor authentication is enabled). Sudo mode is required for
* destructive operations like project deletion, and expires server-side after a
* few minutes.
*/
export async function promptForSudoModeUpgradeAsync(
authenticationInfo: LoggedInAuthenticationInfo
): Promise<void> {
if (!authenticationInfo.sessionSecret) {
throw new Error(
'This action requires sudo mode, which is only available for user sessions. ' +
'Access tokens (EXPO_TOKEN) cannot be upgraded to sudo mode; log in with `eas login` instead.'
);
}
Log.log('This action requires sudo mode. Confirm your password to continue.');
const { password } = await promptAsync({
type: 'password',
name: 'password',
message: 'Password:',
});
if (!password) {
throw new Error('Password is required for sudo mode.');
}
const apiV2Client = new ApiV2Client(authenticationInfo);
try {
await apiV2Client.postAsync('auth/upgradeSudo', { body: { password } });
} catch (error) {
if (error instanceof ApiV2Error && error.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
Log.log('One-time password from authenticator required.');
const { otp } = await promptAsync({
type: 'text',
name: 'otp',
message: 'One-time password or backup code:',
});
if (!otp) {
throw new Error('Cancelled sudo mode upgrade.');
}
await apiV2Client.postAsync('auth/upgradeSudo', { body: { password, otp } });
} else {
throw error;
}
}
}
@@ -0,0 +1,250 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { promptAsync } from '../../prompts';
import {
assertValidDevDomainName,
assignDevDomainNameAsync,
getSignedDeploymentUrlAsync,
} from '../deployment';
import { DeploymentsMutation } from '../mutations';
import { DeploymentsQuery } from '../queries';
jest.mock('../mutations');
jest.mock('../queries');
jest.mock('../../prompts');
jest.mock('../../log');
function createDevDomainNameTakenError(): Error {
return Object.assign(new Error('Dev domain name is already taken'), {
graphQLErrors: [{ extensions: { errorCode: 'DEV_DOMAIN_NAME_TAKEN' } }],
});
}
function createMissingDevDomainNameError(): Error {
return Object.assign(new Error('App does not have a dev domain name'), {
graphQLErrors: [{ extensions: { errorCode: 'APP_NO_DEV_DOMAIN_NAME' } }],
});
}
describe(assertValidDevDomainName, () => {
it.each(['abc', 'my-app', 'app123', 'my-app-123'])('accepts %p', name => {
expect(() => {
assertValidDevDomainName(name);
}).not.toThrow();
});
it('rejects empty names', () => {
expect(() => {
assertValidDevDomainName('');
}).toThrow(/choose a preview URL/);
});
it('rejects names shorter than 3 characters', () => {
expect(() => {
assertValidDevDomainName('ab');
}).toThrow(/at least 3 characters/);
});
it('rejects names ending with a hyphen', () => {
expect(() => {
assertValidDevDomainName('my-app-');
}).toThrow(/cannot end with a hyphen/);
});
it.each(['-my-app', 'My-App', 'my_app', 'my--app', 'my.app'])(
'rejects names with invalid characters, like %p',
name => {
expect(() => {
assertValidDevDomainName(name);
}).toThrow(/lowercase letters, numbers, and non-consecutive hyphens/);
}
);
});
describe(assignDevDomainNameAsync, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('assigns the requested dev domain name without prompting', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true);
await assignDevDomainNameAsync({
graphqlClient,
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: true,
});
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'test-app-id',
name: 'my-app',
});
expect(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws when the requested dev domain name is taken', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest
.mocked(DeploymentsMutation.assignDevDomainNameAsync)
.mockRejectedValueOnce(createDevDomainNameTakenError());
await expect(
assignDevDomainNameAsync({
graphqlClient,
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: true,
})
).rejects.toThrow(/"my-app" is already taken.*--dev-domain/);
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledTimes(1);
});
it('assigns the suggested dev domain name in non-interactive mode', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest
.mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync)
.mockResolvedValueOnce('suggested-name');
jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true);
await assignDevDomainNameAsync({
graphqlClient,
appId: 'test-app-id',
nonInteractive: true,
});
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'test-app-id',
name: 'suggested-name',
});
expect(promptAsync).not.toHaveBeenCalled();
});
it('falls back to the prompt when the requested dev domain name is taken in interactive mode', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest
.mocked(DeploymentsMutation.assignDevDomainNameAsync)
.mockRejectedValueOnce(createDevDomainNameTakenError())
.mockResolvedValueOnce(true);
jest
.mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync)
.mockResolvedValueOnce('suggested-name');
jest.mocked(promptAsync).mockResolvedValueOnce({ name: 'prompted-name' });
await assignDevDomainNameAsync({
graphqlClient,
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: false,
});
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenLastCalledWith(graphqlClient, {
appId: 'test-app-id',
name: 'prompted-name',
});
});
it('throws when the suggested dev domain name is taken in non-interactive mode', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest
.mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync)
.mockResolvedValueOnce('suggested-name');
jest
.mocked(DeploymentsMutation.assignDevDomainNameAsync)
.mockRejectedValueOnce(createDevDomainNameTakenError());
await expect(
assignDevDomainNameAsync({
graphqlClient,
appId: 'test-app-id',
nonInteractive: true,
})
).rejects.toThrow(/"suggested-name" is already taken.*--dev-domain/);
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledTimes(1);
});
});
describe(getSignedDeploymentUrlAsync, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('throws when the requested dev domain name differs from the existing one', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce('other-name');
await expect(
getSignedDeploymentUrlAsync(graphqlClient, {
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: true,
})
).rejects.toThrow(/already set to "other-name\..*" and cannot be changed/);
expect(DeploymentsMutation.createSignedDeploymentUrlAsync).not.toHaveBeenCalled();
});
it('returns the deployment URL when the requested dev domain name matches the existing one', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce('my-app');
jest
.mocked(DeploymentsMutation.createSignedDeploymentUrlAsync)
.mockResolvedValueOnce('https://upload-url.example');
await expect(
getSignedDeploymentUrlAsync(graphqlClient, {
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: true,
})
).resolves.toBe('https://upload-url.example');
});
it('assigns the requested dev domain name when the project has none yet', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce(null);
jest
.mocked(DeploymentsMutation.createSignedDeploymentUrlAsync)
.mockRejectedValueOnce(createMissingDevDomainNameError())
.mockResolvedValueOnce('https://upload-url.example');
jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true);
await expect(
getSignedDeploymentUrlAsync(graphqlClient, {
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: true,
})
).resolves.toBe('https://upload-url.example');
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'test-app-id',
name: 'my-app',
});
// The retry after assignment must not re-run the dev domain pre-check query
expect(DeploymentsQuery.getDevDomainNameByAppIdAsync).toHaveBeenCalledTimes(1);
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws when the requested dev domain name was never assigned by the deployment', async () => {
const graphqlClient = {} as ExpoGraphqlClient;
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce(null);
jest
.mocked(DeploymentsMutation.createSignedDeploymentUrlAsync)
.mockResolvedValueOnce('https://upload-url.example');
await expect(
getSignedDeploymentUrlAsync(graphqlClient, {
appId: 'test-app-id',
devDomainName: 'my-app',
nonInteractive: true,
})
).rejects.toThrow(/preview URL was not assigned as part of this deployment/);
expect(DeploymentsMutation.assignDevDomainNameAsync).not.toHaveBeenCalled();
});
});
+88 -21
View File
@@ -16,14 +16,30 @@ export async function getSignedDeploymentUrlAsync(
options: {
appId: string;
deploymentIdentifier?: string | null;
/** Custom dev domain name (preview URL subdomain) requested through the `--dev-domain` flag */
devDomainName?: string;
/** Callback which is invoked when the project is going to setup the dev domain */
onSetupDevDomain?: () => any;
/** If the terminal is running in non interactive mode or not */
nonInteractive?: boolean;
}
): Promise<string> {
let currentDevDomainName: string | null = null;
if (options.devDomainName) {
currentDevDomainName = await DeploymentsQuery.getDevDomainNameByAppIdAsync(graphqlClient, {
appId: options.appId,
});
if (currentDevDomainName && currentDevDomainName !== options.devDomainName) {
throw new Error(
`The project's preview URL is already set to "${currentDevDomainName}.${EXPO_BASE_DOMAIN}.app" and cannot be changed to "${options.devDomainName}.${EXPO_BASE_DOMAIN}.app" through "eas deploy".\nRemove the --dev-domain flag to deploy to the existing preview URL.`
);
}
}
let signedUrl: string;
try {
return await DeploymentsMutation.createSignedDeploymentUrlAsync(graphqlClient, {
signedUrl = await DeploymentsMutation.createSignedDeploymentUrlAsync(graphqlClient, {
appId: options.appId,
deploymentIdentifier: options.deploymentIdentifier,
});
@@ -43,11 +59,27 @@ export async function getSignedDeploymentUrlAsync(
await assignDevDomainNameAsync({
graphqlClient,
appId: options.appId,
devDomainName: options.devDomainName,
nonInteractive: options.nonInteractive,
});
// Retry creating the signed URL
return await getSignedDeploymentUrlAsync(graphqlClient, options);
// Retry creating the signed URL. The dev domain name was just assigned, so drop it from
// the retry to skip the pre-checks (and their extra query) — it is known to be set now.
return await getSignedDeploymentUrlAsync(graphqlClient, {
...options,
devDomainName: undefined,
});
}
// The --dev-domain flag is applied when the server requires a preview URL to be assigned
// before the first deployment. If the deployment was accepted without that assignment,
// fail instead of silently deploying without the requested preview URL.
if (options.devDomainName && !currentDevDomainName) {
throw new Error(
`The --dev-domain flag was provided, but the project's preview URL was not assigned as part of this deployment.\nRemove the --dev-domain flag and set the preview URL from the project's hosting settings on the website instead.`
);
}
return signedUrl;
}
type PromptInstance = {
@@ -78,6 +110,40 @@ function formatDevDomainName(name = ''): string {
.trim();
}
/**
* Validate a dev domain name (preview URL subdomain), matching the rules of the interactive prompt.
* Returns `true` when valid, or an error message describing why the name is invalid.
*/
function validateDevDomainName(value: string): true | string {
if (!value) {
return 'You have to choose a preview URL for your project';
}
if (value.length < 3) {
return 'Preview URLs must be at least 3 characters long';
}
if (value.endsWith('-')) {
return 'Preview URLs cannot end with a hyphen (-)';
}
return true;
}
/**
* Assert that a user-provided dev domain name (preview URL subdomain) is valid.
* Unlike the interactive prompt, which reformats invalid input while typing,
* this throws an error when the name contains invalid characters.
*/
export function assertValidDevDomainName(name: string): void {
const validationResult = validateDevDomainName(name);
if (validationResult !== true) {
throw new Error(validationResult);
}
if (formatDevDomainName(name) !== name) {
throw new Error(
`Preview URLs can only contain lowercase letters, numbers, and non-consecutive hyphens (-), and cannot start or end with a hyphen: "${name}"`
);
}
}
async function promptDevDomainNameAsync(initialDevDomain: string): Promise<string> {
const rootDomain = `.${EXPO_BASE_DOMAIN}.app`;
const memoizedFormatDevDomainName = memoize(formatDevDomainName);
@@ -87,18 +153,7 @@ async function promptDevDomainNameAsync(initialDevDomain: string): Promise<strin
name: 'name',
message: 'Choose a preview URL for your project:',
initial: initialDevDomain,
validate: (value: string) => {
if (!value) {
return 'You have to choose a preview URL for your project';
}
if (value.length < 3) {
return 'Preview URLs must be at least 3 characters long';
}
if (value.endsWith('-')) {
return 'Preview URLs cannot end with a hyphen (-)';
}
return true;
},
validate: validateDevDomainName,
onState(this: PromptInstance, state: { value?: string }) {
const value = memoizedFormatDevDomainName(state.value);
if (value !== state.value) {
@@ -129,23 +184,28 @@ async function promptDevDomainNameAsync(initialDevDomain: string): Promise<strin
/**
* Assign a dev domain name to a project.
* - When a dev domain name is provided, it will assign that name without prompting.
* - When running in interactive mode, it will prompt the user with a suggested domain name.
* - When running in non interactive mode, it will auto-assign the suggested domain name.
*/
export async function assignDevDomainNameAsync({
graphqlClient,
appId,
devDomainName: requestedDevDomainName,
nonInteractive,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
devDomainName?: string;
nonInteractive?: boolean;
}): ReturnType<typeof DeploymentsMutation.assignDevDomainNameAsync> {
let devDomainName = await DeploymentsQuery.getSuggestedDevDomainByAppIdAsync(graphqlClient, {
appId,
});
let devDomainName =
requestedDevDomainName ??
(await DeploymentsQuery.getSuggestedDevDomainByAppIdAsync(graphqlClient, {
appId,
}));
if (!nonInteractive) {
if (!requestedDevDomainName && !nonInteractive) {
devDomainName = await promptDevDomainNameAsync(devDomainName);
}
@@ -164,10 +224,17 @@ export async function assignDevDomainNameAsync({
throw error;
}
if (!nonInteractive) {
Log.error(`The preview URL "${devDomainName}" is already taken, choose a different URL.`);
if (nonInteractive) {
throw new Error(
requestedDevDomainName
? `The preview URL "${requestedDevDomainName}" is already taken, choose a different URL with the --dev-domain flag.`
: `The suggested preview URL "${devDomainName}" is already taken, choose a different URL with the --dev-domain flag.`
);
}
// In interactive mode, fall back to the prompt so a taken name (requested or suggested)
// does not abort the deployment.
Log.error(`The preview URL "${devDomainName}" is already taken, choose a different URL.`);
return await assignDevDomainNameAsync({ graphqlClient, appId, nonInteractive });
}
}
+31
View File
@@ -6,6 +6,8 @@ import { WorkerDeploymentAliasFragmentNode } from './fragments/WorkerDeploymentA
import type { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
DevDomainNameByAppIdQuery,
DevDomainNameByAppIdQueryVariables,
type PaginatedWorkerDeploymentsQuery,
type PaginatedWorkerDeploymentsQueryVariables,
SuggestedDevDomainNameQuery,
@@ -89,6 +91,35 @@ export const DeploymentsQuery = {
return data.app.byId.suggestedDevDomainName;
},
async getDevDomainNameByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
{ appId }: DevDomainNameByAppIdQueryVariables
): Promise<string | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DevDomainNameByAppIdQuery, DevDomainNameByAppIdQueryVariables>(
gql`
query DevDomainNameByAppId($appId: String!) {
app {
byId(appId: $appId) {
id
devDomainName {
id
name
}
}
}
}
`,
{ appId },
{ additionalTypenames: ['AppDevDomainName'] }
)
.toPromise()
);
return data.app.byId.devDomainName?.name ?? null;
},
async getAllAliasesPaginatedAsync(
graphqlClient: ExpoGraphqlClient,
{
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "21.0.0",
"version": "21.0.1",
"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.0.0",
"@expo/eas-build-job": "21.0.1",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "21.0.0",
"version": "21.0.1",
"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.0.0",
"@expo/eas-build-job": "21.0.0",
"@expo/build-tools": "21.0.1",
"@expo/eas-build-job": "21.0.1",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "21.0.0",
@@ -39,7 +39,7 @@
"lodash": "^4.17.21",
"nullthrows": "^1.1.1",
"semver": "^7.6.2",
"tar": "7.5.7",
"tar": "7.5.19",
"uuid": "^9.0.1"
},
"devDependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "21.0.0",
"version": "21.0.1",
"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.0.0",
"@expo/eas-build-job": "21.0.1",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+8 -2
View File
@@ -5,6 +5,7 @@ import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildWorkflow } from './BuildWorkflow';
import { BuildWorkflowValidator } from './BuildWorkflowValidator';
import { BuildConfigError } from './errors';
import { AnchorHooks } from './hooks';
import { duplicates } from './utils/expodash/duplicates';
import { uniq } from './utils/expodash/uniq';
@@ -30,9 +31,13 @@ export abstract class AbstractConfigParser {
}
public async parseAsync(): Promise<BuildWorkflow> {
const { buildSteps, buildFunctionById } =
const { buildSteps, buildFunctionById, hooksByAnchorStep } =
await this.parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync();
const workflow = new BuildWorkflow(this.ctx, { buildSteps, buildFunctions: buildFunctionById });
const workflow = new BuildWorkflow(this.ctx, {
buildSteps,
buildFunctions: buildFunctionById,
hooksByAnchorStep,
});
await new BuildWorkflowValidator(workflow).validateAsync();
return workflow;
}
@@ -40,6 +45,7 @@ export abstract class AbstractConfigParser {
protected abstract parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
buildSteps: BuildStep[];
buildFunctionById: BuildFunctionById;
hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
}>;
private validateExternalFunctions(externalFunctions?: BuildFunction[]): void {
+5
View File
@@ -33,6 +33,7 @@ import {
} from './BuildStepInput';
import { BuildStepOutput, BuildStepOutputProvider } from './BuildStepOutput';
import { BuildConfigError } from './errors';
import { AnchorHooks } from './hooks';
export class BuildConfigParser extends AbstractConfigParser {
private readonly configPath: string;
@@ -59,6 +60,7 @@ export class BuildConfigParser extends AbstractConfigParser {
protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
buildSteps: BuildStep[];
buildFunctionById: BuildFunctionById;
hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
}> {
const config = await readAndValidateBuildConfigFromPathAsync(this.configPath, {
externalFunctionIds: this.getExternalFunctionFullIds(),
@@ -81,6 +83,9 @@ export class BuildConfigParser extends AbstractConfigParser {
return {
buildSteps,
buildFunctionById: buildFunctions,
// config.yml builds have no hooks — the payload `hooks` field never
// reaches this parser.
hooksByAnchorStep: new Map(),
};
}
+27
View File
@@ -1,3 +1,4 @@
import { HookAnchorId } from '@expo/eas-build-job';
import assert from 'assert';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
@@ -6,11 +7,28 @@ import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInputProvider } from './BuildStepInput';
import { BuildStepOutputProvider } from './BuildStepOutput';
import { BuildConfigError } from './errors';
import { createCustomFunctionCall } from './utils/customFunction';
export type BuildFunctionById = Record<string, BuildFunction>;
export type BuildFunctionCallInputs = Record<string, unknown>;
export function createBuildFunctionByIdMapping(
buildFunctions: readonly BuildFunction[]
): BuildFunctionById {
const buildFunctionById: BuildFunctionById = {};
for (const buildFunction of buildFunctions) {
const fullId = buildFunction.getFullId();
// Without the fence, last-write-wins would let the provider's array order
// silently decide which implementation a `uses:` step resolves to.
if (buildFunctionById[fullId] !== undefined) {
throw new BuildConfigError(`Build function with id ${fullId} is already defined.`);
}
buildFunctionById[fullId] = buildFunction;
}
return buildFunctionById;
}
export class BuildFunction {
public readonly namespace?: string;
public readonly id: string;
@@ -23,6 +41,11 @@ export class BuildFunction {
public readonly fn?: BuildStepFunction;
public readonly shell?: string;
public readonly __metricsId?: string;
// The hook anchor this function declares itself as (the `__metricsId`
// pattern). Registry-typed, so an unregistered anchor is a compile error;
// a step-level `__hook_id` stamp overrides it (see
// StepsConfigParser.resolveStepAnchor for the resolution rules).
public readonly __hookId?: HookAnchorId;
constructor({
namespace,
@@ -36,6 +59,7 @@ export class BuildFunction {
customFunctionModulePath,
shell,
__metricsId,
__hookId,
}: {
namespace?: string;
id: string;
@@ -48,6 +72,7 @@ export class BuildFunction {
fn?: BuildStepFunction;
shell?: string;
__metricsId?: string;
__hookId?: HookAnchorId;
}) {
assert(
command !== undefined || fn !== undefined || customFunctionModulePath !== undefined,
@@ -75,6 +100,7 @@ export class BuildFunction {
this.shell = shell;
this.customFunctionModulePath = customFunctionModulePath;
this.__metricsId = __metricsId;
this.__hookId = __hookId;
}
public getFullId(): string {
@@ -134,6 +160,7 @@ export class BuildFunction {
ifCondition,
timeoutMs,
__metricsId: this.__metricsId,
__hookId: this.__hookId,
});
}
}
+21 -20
View File
@@ -1,8 +1,9 @@
import { JobInterpolationContext } from '@expo/eas-build-job';
import { HookAnchorId, JobInterpolationContext } from '@expo/eas-build-job';
import assert from 'assert';
import { Buffer } from 'buffer';
import fs from 'fs/promises';
import path from 'path';
import util from 'util';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
@@ -22,7 +23,7 @@ import {
} from './BuildTemporaryFiles';
import { BuildStepRuntimeError } from './errors';
import { interpolateJobContext } from './interpolation';
import { jsepEval } from './utils/jsepEval';
import { evaluateIfCondition } from './utils/jsepEval';
import { BIN_PATH } from './utils/shell/bin';
import { getShellCommandAndArgs } from './utils/shell/command';
import { spawnAsync } from './utils/shell/spawn';
@@ -135,6 +136,7 @@ export class BuildStep extends BuildStepOutputAccessor {
public readonly ifCondition?: string;
public readonly timeoutMs?: number;
public readonly __metricsId?: string;
public readonly __hookId?: HookAnchorId;
public status: BuildStepStatus;
private readonly outputsDir: string;
private readonly envsDir: string;
@@ -162,6 +164,7 @@ export class BuildStep extends BuildStepOutputAccessor {
ifCondition,
timeoutMs,
__metricsId,
__hookId,
}: {
id: string;
displayName: string;
@@ -176,6 +179,7 @@ export class BuildStep extends BuildStepOutputAccessor {
ifCondition?: string;
timeoutMs?: number;
__metricsId?: string;
__hookId?: HookAnchorId;
}
) {
assert(command !== undefined || fn !== undefined, 'Either command or fn must be defined.');
@@ -195,6 +199,7 @@ export class BuildStep extends BuildStepOutputAccessor {
this.ifCondition = ifCondition;
this.timeoutMs = timeoutMs;
this.__metricsId = __metricsId;
this.__hookId = __hookId;
this.status = BuildStepStatus.NEW;
const logger = ctx.baseLogger.child({
@@ -270,13 +275,21 @@ export class BuildStep extends BuildStepOutputAccessor {
);
this.status = BuildStepStatus.SUCCESS;
} catch (err) {
this.ctx.logger.error({ err });
// Downstream error handling relies on real Errors; wrap non-Error
// throwables here, at the only step-execution boundary.
const error =
err instanceof Error
? err
: 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.status = BuildStepStatus.FAIL;
throw err;
throw error;
} finally {
this.executed = true;
@@ -311,16 +324,9 @@ export class BuildStep extends BuildStepOutputAccessor {
return !hasAnyPreviousStepFailed;
}
let ifCondition = this.ifCondition;
if (ifCondition.startsWith('${{') && ifCondition.endsWith('}}')) {
ifCondition = ifCondition.slice(3, -2);
} else if (ifCondition.startsWith('${') && ifCondition.endsWith('}')) {
ifCondition = ifCondition.slice(2, -1);
}
return Boolean(
jsepEval(ifCondition, {
return evaluateIfCondition(
this.ifCondition,
this.ctx.global.getIfConditionContext({
inputs:
this.inputs?.reduce(
(acc, input) => {
@@ -331,12 +337,7 @@ export class BuildStep extends BuildStepOutputAccessor {
},
{} as Record<string, unknown>
) ?? {},
eas: {
runtimePlatform: this.ctx.global.runtimePlatform,
...this.ctx.global.staticContext,
env: this.getScriptEnv(),
},
...this.getInterpolationContext(),
env: this.getScriptEnv(),
})
);
}
+48 -6
View File
@@ -8,7 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStep, BuildStepOutputAccessor, SerializedBuildStepOutputAccessor } from './BuildStep';
import { BuildStepEnv } from './BuildStepEnv';
import { StepMetric, StepMetricInput } from './StepMetrics';
import { StepMetric, StepMetricInput, StepMetricResult, WorkflowHookMetric } from './StepMetrics';
import { BuildStepRuntimeError } from './errors';
import { hashFiles } from './utils/hashFiles';
import {
@@ -41,6 +41,7 @@ export interface ExternalBuildContextProvider {
readonly env: BuildStepEnv;
updateEnv(env: BuildStepEnv): void;
reportStepMetric?(metric: StepMetric): void;
reportWorkflowHookMetric?(metric: WorkflowHookMetric): void;
}
export interface SerializedBuildStepGlobalContext {
@@ -147,17 +148,42 @@ export class BuildStepGlobalContext {
};
}
/**
* One builder for both step-level and hook-entry-level `if:` contexts so
* the two cannot drift; the real differences (step inputs, step-level env
* overrides) ride in as parameters.
*/
public getIfConditionContext({
inputs,
env,
}: {
inputs: Record<string, unknown>;
env: BuildStepEnv;
}): Record<string, unknown> {
return {
inputs,
eas: this.getEasContext(env),
...this.getInterpolationContext(),
env,
};
}
// The one definition of the user-visible `eas.*` namespace shape.
private getEasContext(env: BuildStepEnv): Record<string, unknown> {
return {
runtimePlatform: this.runtimePlatform,
...this.staticContext,
env,
};
}
public interpolate<InterpolableType extends string | object>(
value: InterpolableType
): InterpolableType {
return interpolateWithGlobalContext(value, path => {
return (
getObjectValueForInterpolation(path, {
eas: {
runtimePlatform: this.runtimePlatform,
...this.staticContext,
env: this.env,
},
eas: this.getEasContext(this.env),
})?.toString() ?? ''
);
});
@@ -187,6 +213,22 @@ export class BuildStepGlobalContext {
this.provider.reportStepMetric?.(stepMetric);
}
public collectStepMetric(step: BuildStep, result: StepMetricResult, durationMs: number): void {
if (!step.__metricsId) {
return;
}
this.addStepMetric({ metricsId: step.__metricsId, result, durationMs });
}
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
try {
this.provider.reportWorkflowHookMetric?.(metric);
} catch (err) {
// Telemetry must never fail the job or mask a step's error.
this.baseLogger.debug({ err }, 'Reporting the workflow hook metric failed');
}
}
public wasCheckedOut(): boolean {
return this.didCheckOut;
}
+255 -33
View File
@@ -1,68 +1,290 @@
import { HookAnchorId } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunctionById } from './BuildFunction';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStep } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { StepMetricResult } from './StepMetrics';
import { AnchorHooks, HookEntry } from './hooks';
import { evaluateIfCondition } from './utils/jsepEval';
export class BuildWorkflow {
public readonly buildSteps: BuildStep[];
public readonly buildFunctions: BuildFunctionById;
// Hook steps do NOT join buildSteps; they execute around their anchor step
// below, keyed by anchor BuildStep instance so each occurrence carries its
// own per-occurrence hook entries.
public readonly hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
constructor(
private readonly ctx: BuildStepGlobalContext,
{ buildSteps, buildFunctions }: { buildSteps: BuildStep[]; buildFunctions: BuildFunctionById }
{
buildSteps,
buildFunctions,
hooksByAnchorStep,
}: {
buildSteps: BuildStep[];
buildFunctions: BuildFunctionById;
hooksByAnchorStep?: ReadonlyMap<BuildStep, AnchorHooks>;
}
) {
this.buildSteps = buildSteps;
this.buildFunctions = buildFunctions;
this.hooksByAnchorStep = hooksByAnchorStep ?? new Map();
}
public get runtimePlatform(): BuildRuntimePlatform {
return this.ctx.runtimePlatform;
}
/**
* All steps in execution order: before-hooks → anchor → after-hooks per
* occurrence. This is the validation view; execution itself walks
* `buildSteps` and expands hooks around each anchor.
*/
public getExecutionOrderedSteps(): BuildStep[] {
const orderedSteps: BuildStep[] = [];
for (const step of this.buildSteps) {
const hooks = this.hooksByAnchorStep.get(step);
orderedSteps.push(
...(hooks?.before ?? []).flatMap(entry => entry.steps),
step,
...(hooks?.after ?? []).flatMap(entry => entry.steps)
);
}
return orderedSteps;
}
public async executeAsync(): Promise<void> {
let maybeError: Error | null = null;
for (const step of this.buildSteps) {
let shouldExecuteStep = false;
// Boolean, not error truthiness — gate-evaluation throws bypass
// BuildStep's non-Error normalization.
let hasFailed = false;
let firstError: unknown;
const recordFailure = (err: unknown): void => {
if (!hasFailed) {
hasFailed = true;
firstError = err;
}
this.ctx.markAsFailed();
};
for (const step of this.buildSteps) {
const hooks = this.hooksByAnchorStep.get(step);
// The anchor's gate is evaluated ONCE and decides for the whole
// occurrence; an anchor's `if:` cannot see its before-hooks' outputs.
let shouldExecuteStep = false;
try {
shouldExecuteStep = step.shouldExecuteStep();
} catch (err: any) {
step.ctx.logger.error({ err });
step.ctx.logger.error(
`Runner failed to evaluate if it should execute step "${step.displayName}", using step's if condition "${step.ifCondition}". This can be caused by trying to access non-existing object property. If you think this is a bug report it here: https://github.com/expo/eas-cli/issues.`
logConditionEvaluationError(
step.ctx.logger,
err,
`step "${step.displayName}"`,
step.ifCondition
);
maybeError = maybeError ?? err;
this.ctx.markAsFailed();
recordFailure(err);
}
if (shouldExecuteStep) {
const startTime = performance.now();
let stepResult: StepMetricResult;
try {
await step.executeAsync();
stepResult = 'success';
} catch (err: any) {
stepResult = 'failed';
maybeError = maybeError ?? err;
this.ctx.markAsFailed();
} finally {
this.collectStepMetrics(step, stepResult!, performance.now() - startTime);
}
} else {
if (!shouldExecuteStep) {
step.skip();
for (const entry of [...(hooks?.before ?? []), ...(hooks?.after ?? [])]) {
for (const hookStep of entry.steps) {
hookStep.skip();
}
}
continue;
}
const before = await this.executeHookSideAsync(hooks, 'before');
if (before.failedLocally) {
recordFailure(before.firstError);
step.skip();
for (const entry of hooks?.after ?? []) {
for (const hookStep of entry.steps) {
hookStep.skip();
}
}
continue;
}
// LOCAL outcome, not the global flag.
let anchorFailed = false;
const startTime = performance.now();
let stepResult: StepMetricResult = 'success';
try {
await step.executeAsync();
} catch (err: any) {
stepResult = 'failed';
anchorFailed = true;
recordFailure(err);
} finally {
this.ctx.collectStepMetric(step, stepResult, performance.now() - startTime);
}
const after = await this.executeHookSideAsync(
hooks,
'after',
anchorFailed ? 'failed' : 'success'
);
if (after.failedLocally) {
recordFailure(after.firstError);
}
}
if (maybeError) {
throw maybeError;
if (hasFailed) {
throw firstError;
}
}
private collectStepMetrics(step: BuildStep, result: StepMetricResult, durationMs: number): void {
if (!step.__metricsId) {
return;
private async executeHookSideAsync(
hooks: AnchorHooks | undefined,
timing: 'before' | 'after',
anchorResult?: StepMetricResult
): Promise<{ failedLocally: boolean; firstError: unknown }> {
if (hooks === undefined) {
return { failedLocally: false, firstError: undefined };
}
this.ctx.addStepMetric({
metricsId: step.__metricsId,
result,
durationMs,
return await executeHookStepsAsync(this.ctx, hooks[timing], {
anchor: hooks.anchor,
timing,
anchorResult,
});
}
}
/**
* Executes hook entries around an anchor: the engine-public execution
* primitive. Catches condition-evaluation errors AND execution errors, marks
* global failure, preserves the FIRST local error, and reports ONE
* `WorkflowHookMetric` per executed hook side.
*
* Default-run rules (a step or entry with no `if:`):
* - `before`: runs unless a failure occurred within THIS hook sequence;
* failures predating the call are ignored — "runs iff the anchor runs".
* - `after`: runs unconditionally — past the anchor's own failure AND past an
* earlier after-entry's failure.
* A user `if:` is always evaluated against the real global context, so
* `failure()` / `success()` keep their global meaning on both sides.
*/
export async function executeHookStepsAsync(
ctx: BuildStepGlobalContext,
entries: HookEntry[],
options: {
anchor: HookAnchorId;
timing: 'before' | 'after';
/** The anchor's LOCAL outcome; set by the caller on after-timing calls. */
anchorResult?: StepMetricResult;
}
): Promise<{ failedLocally: boolean; firstError: unknown }> {
let failedLocally = false;
let firstError: unknown;
const recordFailure = (err: unknown): void => {
if (!failedLocally) {
failedLocally = true;
firstError = err;
}
ctx.markAsFailed();
};
let anyStepExecuted = false;
for (const entry of entries) {
// Truthiness, not presence: an empty `if:` means "no condition", the same
// as BuildStep.shouldExecuteStep treats it.
const entryHasExplicitCondition = Boolean(entry.ifCondition);
if (entry.ifCondition) {
let entryEligible = false;
try {
entryEligible = evaluateIfCondition(
entry.ifCondition,
// A group entry has no step to hang the authored condition on, so
// its `if:` gets the shared context with no step inputs.
ctx.getIfConditionContext({ inputs: {}, env: ctx.env })
);
} catch (err) {
logConditionEvaluationError(
entry.steps[0]?.ctx.logger ?? ctx.baseLogger,
err,
'the hook group step',
entry.ifCondition
);
recordFailure(err);
}
if (!entryEligible) {
for (const step of entry.steps) {
step.skip();
}
continue;
}
}
let entryFailed = false;
for (const step of entry.steps) {
let shouldExecuteStep = false;
if (step.ifCondition) {
try {
shouldExecuteStep = step.shouldExecuteStep();
} catch (err) {
logConditionEvaluationError(
step.ctx.logger,
err,
`step "${step.displayName}"`,
step.ifCondition
);
recordFailure(err);
entryFailed = true;
}
} else {
// Before-side: a no-`if:` step runs unless this hook sequence has
// already failed. An entry whose explicit condition evaluated true
// behaves like a single step whose if: passed — the entry's no-`if:`
// steps ignore failures from EARLIER entries (only within-entry
// failures skip them).
shouldExecuteStep =
options.timing === 'after' || (entryHasExplicitCondition ? !entryFailed : !failedLocally);
}
if (!shouldExecuteStep) {
step.skip();
continue;
}
anyStepExecuted = true;
const startTime = performance.now();
let stepResult: StepMetricResult = 'success';
try {
await step.executeAsync();
} catch (err) {
stepResult = 'failed';
entryFailed = true;
recordFailure(err);
} finally {
ctx.collectStepMetric(step, stepResult, performance.now() - startTime);
}
}
}
if (anyStepExecuted) {
ctx.reportWorkflowHookMetric({
anchor: options.anchor,
timing: options.timing,
result: failedLocally ? 'failed' : 'success',
...(options.anchorResult !== undefined ? { anchorResult: options.anchorResult } : null),
});
}
return { failedLocally, firstError };
}
// The one wording for an `if:` that could not be evaluated — anchor steps,
// hook steps, and hook group entries all log through here.
function logConditionEvaluationError(
logger: bunyan,
err: unknown,
subject: string,
ifCondition: string | undefined
): void {
logger.error({ err });
logger.error(
`Runner failed to evaluate if it should execute ${subject}, using its if condition "${ifCondition}". This can be caused by trying to access non-existing object property. If you think this is a bug report it here: https://github.com/expo/eas-cli/issues.`
);
}
+149 -122
View File
@@ -1,9 +1,12 @@
import fs from 'fs-extra';
import path from 'path';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStep } from './BuildStep';
import { BuildStepInputValueTypeName } from './BuildStepInput';
import { BuildWorkflow } from './BuildWorkflow';
// 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';
import { BuildConfigError, BuildWorkflowError } from './errors';
import { duplicates } from './utils/expodash/duplicates';
import { nullthrows } from './utils/nullthrows';
@@ -13,134 +16,23 @@ export class BuildWorkflowValidator {
constructor(private readonly workflow: BuildWorkflow) {}
public async validateAsync(): Promise<void> {
// Hook steps don't join buildSteps, so validation is NOT free for them:
// every aggregate check runs over the execution-ordered view (before →
// anchor → after per occurrence), or hook steps would silently escape
// id-uniqueness, output-reference, and platform validation.
const orderedView = this.workflow.getExecutionOrderedSteps();
const errors: BuildConfigError[] = [];
errors.push(...this.validateUniqueStepIds());
errors.push(...this.validateInputs());
errors.push(...this.validateAllowedPlatforms());
errors.push(
...collectAggregateStepErrors(orderedView, {
runtimePlatform: this.workflow.runtimePlatform,
})
);
errors.push(...(await this.validateCustomFunctionModulesAsync()));
if (errors.length !== 0) {
throw new BuildWorkflowError('Build workflow is invalid.', errors);
}
}
private validateUniqueStepIds(): BuildConfigError[] {
const stepIds = this.workflow.buildSteps.map(({ id }) => id);
const duplicatedStepIds = duplicates(stepIds);
if (duplicatedStepIds.length === 0) {
return [];
} else {
const error = new BuildConfigError(
`Duplicated step IDs: ${duplicatedStepIds.map(i => `"${i}"`).join(', ')}`
);
return [error];
}
}
private validateInputs(): BuildConfigError[] {
const errors: BuildConfigError[] = [];
const allStepIds = new Set(this.workflow.buildSteps.map(s => s.id));
const visitedStepByStepId: Record<string, BuildStep> = {};
for (const currentStep of this.workflow.buildSteps) {
for (const currentStepInput of currentStep.inputs ?? []) {
if (currentStepInput.required && currentStepInput.rawValue === undefined) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" is required but it was not set.`
);
errors.push(error);
}
const currentType =
typeof currentStepInput.rawValue === 'object'
? BuildStepInputValueTypeName.JSON
: typeof currentStepInput.rawValue;
if (
currentStepInput.rawValue !== undefined &&
!currentStepInput.isRawValueStepOrContextReference() &&
currentType !== currentStepInput.allowedValueTypeName
) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${
currentStep.displayName
}" is set to "${
typeof currentStepInput.rawValue === 'object'
? JSON.stringify(currentStepInput.rawValue)
: currentStepInput.rawValue
}" which is not of type "${
currentStepInput.allowedValueTypeName
}" or is not step or context reference.`
);
errors.push(error);
}
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 paths =
typeof currentStepInput.defaultValue === 'string'
? findOutputPaths(currentStepInput.defaultValue)
: [];
for (const { stepId: referencedStepId, outputId: referencedStepOutputId } of paths) {
if (!(referencedStepId in visitedStepByStepId)) {
if (allStepIds.has(referencedStepId)) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from the future step "${referencedStepId}".`
);
errors.push(error);
} else {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from a non-existent step "${referencedStepId}".`
);
errors.push(error);
}
} else {
if (!visitedStepByStepId[referencedStepId].hasOutputParameter(referencedStepOutputId)) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an undefined output parameter "${referencedStepOutputId}" from step "${referencedStepId}".`
);
errors.push(error);
}
}
}
}
visitedStepByStepId[currentStep.id] = currentStep;
}
return errors;
}
private validateAllowedPlatforms(): BuildConfigError[] {
const errors: BuildConfigError[] = [];
for (const step of this.workflow.buildSteps) {
if (!step.canBeRunOnRuntimePlatform()) {
const error = new BuildConfigError(
`Step "${step.displayName}" is not allowed on platform "${
step.ctx.global.runtimePlatform
}". Allowed platforms for this step are: ${nullthrows(
step.supportedRuntimePlatforms,
`step.supportedRuntimePlatforms can't be falsy if canBeRunOnRuntimePlatform() is false`
)
.map(p => `"${p}"`)
.join(', ')}.`
);
errors.push(error);
}
}
return errors;
}
private async validateCustomFunctionModulesAsync(): Promise<BuildConfigError[]> {
const errors: BuildConfigError[] = [];
for (const buildFunction of Object.values(this.workflow.buildFunctions)) {
@@ -166,3 +58,138 @@ export class BuildWorkflowValidator {
return errors;
}
}
/**
* The aggregate step checks (unique ids, input/output references, runtime
* platform allowance) over an execution-ordered view of steps. Shared by the
* workflow validator and `validateHookStepsAsync`.
*/
export function collectAggregateStepErrors(
steps: readonly BuildStep[],
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
): BuildConfigError[] {
return [
...validateUniqueStepIds(steps),
...validateInputs(steps),
...validateAllowedPlatforms(steps, runtimePlatform),
];
}
function validateUniqueStepIds(steps: readonly BuildStep[]): BuildConfigError[] {
const stepIds = steps.map(({ id }) => id);
const duplicatedStepIds = duplicates(stepIds);
if (duplicatedStepIds.length === 0) {
return [];
} else {
const error = new BuildConfigError(
`Duplicated step IDs: ${duplicatedStepIds.map(i => `"${i}"`).join(', ')}`
);
return [error];
}
}
function validateInputs(steps: readonly BuildStep[]): BuildConfigError[] {
const errors: BuildConfigError[] = [];
const allStepIds = new Set(steps.map(s => s.id));
const visitedStepByStepId: Record<string, BuildStep> = {};
for (const currentStep of steps) {
for (const currentStepInput of currentStep.inputs ?? []) {
if (currentStepInput.required && currentStepInput.rawValue === undefined) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" is required but it was not set.`
);
errors.push(error);
}
const currentType =
typeof currentStepInput.rawValue === 'object'
? BuildStepInputValueTypeName.JSON
: typeof currentStepInput.rawValue;
if (
currentStepInput.rawValue !== undefined &&
!currentStepInput.isRawValueStepOrContextReference() &&
currentType !== currentStepInput.allowedValueTypeName
) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${
currentStep.displayName
}" is set to "${
typeof currentStepInput.rawValue === 'object'
? JSON.stringify(currentStepInput.rawValue)
: currentStepInput.rawValue
}" which is not of type "${
currentStepInput.allowedValueTypeName
}" or is not step or context reference.`
);
errors.push(error);
}
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 paths =
typeof currentStepInput.defaultValue === 'string'
? findOutputPaths(currentStepInput.defaultValue)
: [];
for (const { stepId: referencedStepId, outputId: referencedStepOutputId } of paths) {
if (!(referencedStepId in visitedStepByStepId)) {
if (allStepIds.has(referencedStepId)) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from the future step "${referencedStepId}".`
);
errors.push(error);
} else {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from a non-existent step "${referencedStepId}".`
);
errors.push(error);
}
} else {
if (!visitedStepByStepId[referencedStepId].hasOutputParameter(referencedStepOutputId)) {
const error = new BuildConfigError(
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an undefined output parameter "${referencedStepOutputId}" from step "${referencedStepId}".`
);
errors.push(error);
}
}
}
}
visitedStepByStepId[currentStep.id] = currentStep;
}
return errors;
}
function validateAllowedPlatforms(
steps: readonly BuildStep[],
runtimePlatform: BuildRuntimePlatform
): BuildConfigError[] {
const errors: BuildConfigError[] = [];
for (const step of steps) {
if (!step.canBeRunOnRuntimePlatform()) {
const error = new BuildConfigError(
`Step "${step.displayName}" is not allowed on platform "${runtimePlatform}". Allowed platforms for this step are: ${nullthrows(
step.supportedRuntimePlatforms,
`step.supportedRuntimePlatforms can't be falsy if canBeRunOnRuntimePlatform() is false`
)
.map(p => `"${p}"`)
.join(', ')}.`
);
errors.push(error);
}
}
return errors;
}
+15
View File
@@ -1,3 +1,5 @@
import { HookAnchorId } from '@expo/eas-build-job';
export type StepMetricResult = 'success' | 'failed';
export type StepMetricInput = {
@@ -9,3 +11,16 @@ export type StepMetricInput = {
export type StepMetric = StepMetricInput & {
platform: 'darwin' | 'linux';
};
/**
* One event per executed hook side (skipped hooks report nothing). `result` is
* the hook's own outcome, aggregated across the side's entries; `anchorResult`
* is the wrapped anchor step's outcome — after-timing only, and LOCAL: an
* anchor running green past an earlier failure via `always()` is 'success'.
*/
export type WorkflowHookMetric = {
anchor: HookAnchorId;
timing: 'before' | 'after';
result: StepMetricResult;
anchorResult?: StepMetricResult;
};
+190 -139
View File
@@ -1,15 +1,18 @@
import {
FunctionStep,
ShellStep,
HookAnchorId,
Hooks,
Step,
isHookAnchorId,
isStepFunctionStep,
isStepShellStep,
parseHookKey,
validateSteps,
} from '@expo/eas-build-job';
import assert from 'node:assert';
import { AbstractConfigParser } from './AbstractConfigParser';
import { BuildFunction, BuildFunctionById } from './BuildFunction';
import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction';
import {
BuildFunctionGroup,
BuildFunctionGroupById,
@@ -17,20 +20,34 @@ import {
} from './BuildFunctionGroup';
import { BuildStep } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepOutput } from './BuildStepOutput';
import { BuildConfigError } from './errors';
import {
AnchorHooks,
HookEntry,
constructHookEntriesFromValidatedSteps,
createBuildStepFromShellStep,
validateAllStepFunctionsExist,
} from './hooks';
export class StepsConfigParser extends AbstractConfigParser {
private readonly steps: Step[];
private readonly hooks: Hooks;
// Anchors present in the job's own steps, collected during construction.
// Used to warn about payload hook keys whose anchor never occurred.
private readonly encounteredHookAnchors = new Set<HookAnchorId>();
constructor(
ctx: BuildStepGlobalContext,
{
steps,
hooks,
externalFunctions,
externalFunctionGroups,
}: {
steps: Step[];
// Required (not `hooks?:`) so a call site cannot silently forget to pass
// the job's hooks — forgetting drops them without a trace.
hooks: Hooks | undefined;
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
}
@@ -41,70 +58,196 @@ export class StepsConfigParser extends AbstractConfigParser {
});
this.steps = steps;
this.hooks = hooks ?? {};
}
protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
buildSteps: BuildStep[];
buildFunctionById: BuildFunctionById;
hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
}> {
const validatedSteps = validateSteps(this.steps);
StepsConfigParser.validateAllFunctionsExist(validatedSteps, {
const validatedHooks = this.validateHooks();
// Hook steps are validated like job steps: a hook `uses:` naming an
// unknown function must be a BuildConfigError, not an assertion crash.
validateAllStepFunctionsExist([...validatedSteps, ...Object.values(validatedHooks).flat()], {
externalFunctionIds: this.getExternalFunctionFullIds(),
externalFunctionGroupIds: this.getExternalFunctionGroupFullIds(),
});
const buildFunctionById = this.createBuildFunctionByIdMappingForExternalFunctions();
const buildFunctionById = createBuildFunctionByIdMapping(this.externalFunctions ?? []);
const buildFunctionGroupById = createBuildFunctionGroupByIdMapping(
this.externalFunctionGroups ?? []
);
// Only the job's own steps are scanned — steps constructed from hooks are
// never treated as anchors (no nesting). Construction order (before →
// anchor → after per occurrence; groups expand first) keeps generated
// step ids identical across the splicing→engine rollout.
const buildSteps: BuildStep[] = [];
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
for (const stepConfig of validatedSteps) {
buildSteps.push(
...this.createBuildStepsFromStepConfig(stepConfig, {
buildFunctionById,
buildFunctionGroupById,
})
);
const maybeFunctionGroup = isStepFunctionStep(stepConfig)
? 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.
// TODO: allow to set id, name, working_directory, shell, env and if
// for function groups
const expandedSteps = maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx, {
callInputs: stepConfig.with,
});
buildSteps.push(...expandedSteps);
for (const expandedStep of expandedSteps) {
const anchorId = expandedStep.__hookId;
if (anchorId === undefined) {
continue;
}
this.encounteredHookAnchors.add(anchorId);
const anchorHooks = this.constructAnchorHooks(anchorId, validatedHooks, {
buildFunctionById,
buildFunctionGroupById,
});
if (anchorHooks !== undefined) {
hooksByAnchorStep.set(expandedStep, anchorHooks);
}
}
continue;
}
const anchorId = StepsConfigParser.resolveStepAnchor(stepConfig, buildFunctionById);
if (anchorId === undefined) {
buildSteps.push(this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById));
continue;
}
this.encounteredHookAnchors.add(anchorId);
const maps = { buildFunctionById, buildFunctionGroupById };
const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps);
const anchorStep = this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById);
buildSteps.push(anchorStep);
const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps);
if (before.length > 0 || after.length > 0) {
hooksByAnchorStep.set(anchorStep, { anchor: anchorId, before, after });
}
}
// After construction: group expansions also record encountered anchors.
this.warnAboutUnmatchedHookKeys();
return {
buildSteps,
buildFunctionById,
hooksByAnchorStep,
};
}
private createBuildFunctionByIdMappingForExternalFunctions(): BuildFunctionById {
const result: BuildFunctionById = {};
if (this.externalFunctions === undefined) {
return result;
private validateHooks(): Record<string, Step[]> {
const validatedHooks: Record<string, Step[]> = {};
for (const [hookKey, hookSteps] of Object.entries(this.hooks)) {
// Keys that don't name a registered anchor are fully inert — never
// validated, never constructed, reported by warnAboutUnmatchedHookKeys.
// A worker must not fail on hook keys newer than itself, even when their
// steps reference functions it doesn't have yet.
if (parseHookKey(hookKey) === null) {
continue;
}
// An empty array is a deliberate no-op (e.g. opting out of a default);
// any other non-step-array shape falls through to validateSteps below so
// it errors instead of being dropped silently.
if (Array.isArray(hookSteps) && hookSteps.length === 0) {
continue;
}
try {
validatedHooks[hookKey] = validateSteps(hookSteps);
} catch (err) {
throw new BuildConfigError(
`Invalid steps in "hooks.${hookKey}": ${err instanceof Error ? err.message : String(err)}`
);
}
}
for (const buildFunction of this.externalFunctions) {
const fullId = buildFunction.getFullId();
result[fullId] = buildFunction;
}
return result;
return validatedHooks;
}
private createBuildStepsFromStepConfig(
stepConfig: Step,
{
buildFunctionById,
buildFunctionGroupById,
}: {
/**
* Resolves a non-group step's anchor. Stamp PRESENCE wins, not stamp
* registration: an unregistered stamp value makes the step inert — it never
* falls through to the invoked function's declaration (a newer server
* stamping a function step with a future anchor must not silently rebind it
* to the function's older anchor on this worker). Only when the field is
* absent does a function step resolve via its function's own declaration.
*/
private static resolveStepAnchor(
step: Step,
buildFunctionById: BuildFunctionById
): HookAnchorId | undefined {
if (step.__hook_id !== undefined) {
return isHookAnchorId(step.__hook_id) ? step.__hook_id : undefined;
}
if (isStepFunctionStep(step)) {
return buildFunctionById[step.uses]?.__hookId;
}
return undefined;
}
private constructAnchorHooks(
anchorId: HookAnchorId,
validatedHooks: Record<string, Step[]>,
maps: {
buildFunctionById: BuildFunctionById;
buildFunctionGroupById: BuildFunctionGroupById;
}
): BuildStep[] {
): AnchorHooks | undefined {
const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps);
const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps);
if (before.length === 0 && after.length === 0) {
return undefined;
}
return { anchor: anchorId, before, after };
}
private constructHookSideEntries(
anchorId: HookAnchorId,
side: 'before' | 'after',
validatedHooks: Record<string, Step[]>,
maps: {
buildFunctionById: BuildFunctionById;
buildFunctionGroupById: BuildFunctionGroupById;
}
): HookEntry[] {
const hookSteps = validatedHooks[`${side}_${anchorId}`];
if (hookSteps === undefined) {
return [];
}
return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, maps);
}
private warnAboutUnmatchedHookKeys(): void {
for (const hookKey of Object.keys(this.hooks)) {
const parsedHookKey = parseHookKey(hookKey);
if (parsedHookKey === null) {
this.ctx.baseLogger.warn(
{ hookKey },
`Unknown hook key "${hookKey}" in the job payload; its steps did not run.`
);
} else if (!this.encounteredHookAnchors.has(parsedHookKey.anchorId)) {
this.ctx.baseLogger.warn(
{ hookKey, hookAnchor: parsedHookKey.anchorId },
`Hook key "${hookKey}" did not match any step in this job (anchor "${parsedHookKey.anchorId}" never occurred); its steps did not run.`
);
}
}
}
private createBuildStepFromNonGroupStepConfig(
stepConfig: Step,
buildFunctionById: BuildFunctionById
): BuildStep {
if (isStepShellStep(stepConfig)) {
return [this.createBuildStepFromShellStepConfig(stepConfig)];
return createBuildStepFromShellStep(this.ctx, stepConfig);
} else if (isStepFunctionStep(stepConfig)) {
return this.createBuildStepsFromFunctionStepConfig(stepConfig, {
buildFunctionById,
buildFunctionGroupById,
});
return this.createBuildStepFromFunctionStepConfig(stepConfig, buildFunctionById);
} else {
throw new BuildConfigError(
'Invalid job step configuration detected. Step must be shell or function step'
@@ -112,113 +255,21 @@ export class StepsConfigParser extends AbstractConfigParser {
}
}
private createBuildStepFromShellStepConfig(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 && this.createBuildStepOutputsFromDefinition(step.outputs, displayName);
return new BuildStep(this.ctx, {
id,
displayName,
outputs,
workingDirectory: step.working_directory,
shell: step.shell,
command: step.run,
env: step.env,
ifCondition: step.if,
__metricsId: step.__metrics_id,
});
}
private createBuildStepsFromFunctionStepConfig(
private createBuildStepFromFunctionStepConfig(
step: FunctionStep,
{
buildFunctionById,
buildFunctionGroupById,
}: {
buildFunctionById: BuildFunctionById;
buildFunctionGroupById: BuildFunctionGroupById;
}
): BuildStep[] {
const functionId = step.uses;
const maybeFunctionGroup = buildFunctionGroupById[functionId];
if (maybeFunctionGroup) {
// TODO: allow to set id, name, working_directory, shell, env and if for function groups
return maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx, {
callInputs: step.with,
});
}
const buildFunction = buildFunctionById[functionId];
buildFunctionById: BuildFunctionById
): BuildStep {
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,
}),
];
}
private createBuildStepOutputsFromDefinition(
stepOutputs: Required<ShellStep>['outputs'],
stepDisplayName: string
): BuildStepOutput[] {
return stepOutputs.map(
entry =>
new BuildStepOutput(this.ctx, {
id: entry.name,
stepDisplayName,
required: entry.required ?? true,
})
);
}
private static validateAllFunctionsExist(
steps: Step[],
{
externalFunctionIds,
externalFunctionGroupIds,
}: {
externalFunctionIds: string[];
externalFunctionGroupIds: string[];
}
): void {
const calledFunctionsOrFunctionGroupsSet = new Set<string>();
for (const step of steps) {
if (step.uses) {
calledFunctionsOrFunctionGroupsSet.add(step.uses);
}
}
const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet);
const externalFunctionIdsSet = new Set(externalFunctionIds);
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupIds);
const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter(
calledFunctionOrFunctionGroup => {
return (
!externalFunctionIdsSet.has(calledFunctionOrFunctionGroup) &&
!externalFunctionGroupsIdsSet.has(calledFunctionOrFunctionGroup)
);
}
);
if (nonExistentFunctionsOrFunctionGroups.length > 0) {
throw new BuildConfigError(
`Calling non-existent functions: ${nonExistentFunctionsOrFunctionGroups
.map(f => `"${f}"`)
.join(', ')}.`
);
}
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,
});
}
}
@@ -33,6 +33,7 @@ describe('Publish Update job', () => {
}),
];
const stepsParser = new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
uses: 'eas/checkout',
@@ -105,6 +105,31 @@ describe(BuildFunction, () => {
expect(step.displayName).toBe('Test function');
expect(step.fn).toBe(fn);
});
it('stamps the declared hook anchor on created build steps', () => {
const ctx = createGlobalContextMock();
const buildFunction = new BuildFunction({
namespace: 'eas',
id: 'install_node_modules',
command: 'npm install',
__hookId: 'install_node_modules',
});
const step = buildFunction.createBuildStepFromFunctionCall(ctx, {
workingDirectory: ctx.defaultWorkingDirectory,
});
expect(step.__hookId).toBe('install_node_modules');
});
it('passes the function metrics id to created build steps', () => {
const ctx = createGlobalContextMock();
const buildFunction = new BuildFunction({
id: 'test1',
command: 'echo 123',
__metricsId: 'eas/test1',
});
const step = buildFunction.createBuildStepFromFunctionCall(ctx, {
workingDirectory: ctx.defaultWorkingDirectory,
});
expect(step.__metricsId).toBe('eas/test1');
});
it('works with custom JS/TS function', () => {
const ctx = createGlobalContextMock();
const buildFunction = new BuildFunction({
@@ -0,0 +1,638 @@
import fs from 'fs/promises';
import { Hooks, Step } from '@expo/eas-build-job';
import { createGlobalContextMock } from './utils/context';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepStatus } from '../BuildStep';
import { BuildStepGlobalContext } from '../BuildStepContext';
import { BuildWorkflow } from '../BuildWorkflow';
import { StepsConfigParser } from '../StepsConfigParser';
import { WorkflowHookMetric } from '../StepMetrics';
import { BuildStepRuntimeError } from '../errors';
describe('BuildWorkflow hook execution', () => {
let ctx: BuildStepGlobalContext;
let executionLog: string[];
let metrics: WorkflowHookMetric[];
beforeEach(async () => {
executionLog = [];
metrics = [];
ctx = createGlobalContextMock({
reportWorkflowHookMetric: metric => metrics.push(metric),
});
await fs.mkdir(ctx.defaultWorkingDirectory, { recursive: true });
await fs.mkdir(ctx.stepsInternalBuildDirectory, { recursive: true });
});
afterEach(async () => {
await fs.rm(ctx.stepsInternalBuildDirectory, { recursive: true, force: true });
await fs.rm(ctx.defaultWorkingDirectory, { recursive: true, force: true });
});
function recordingFunction(id: string, { failWith }: { failWith?: unknown } = {}): BuildFunction {
return new BuildFunction({
namespace: 'test',
id,
fn: () => {
executionLog.push(id);
if (failWith !== undefined) {
throw failWith;
}
},
});
}
function anchorFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_node_modules',
name: 'Install node modules',
fn: () => {
executionLog.push('anchor');
},
__hookId: 'install_node_modules',
});
}
function failingAnchorFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_node_modules',
name: 'Install node modules',
fn: () => {
executionLog.push('anchor');
throw new Error('anchor failed');
},
__hookId: 'install_node_modules',
});
}
async function parseAsync({
steps,
hooks,
externalFunctions,
externalFunctionGroups,
}: {
steps: Step[];
hooks: Hooks | undefined;
externalFunctions: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
}): Promise<BuildWorkflow> {
const parser = new StepsConfigParser(ctx, {
steps,
hooks,
externalFunctions,
externalFunctionGroups,
});
return await parser.parseAsync();
}
function hookStepStatuses(workflow: BuildWorkflow): Record<string, BuildStepStatus> {
const statuses: Record<string, BuildStepStatus> = {};
for (const anchorHooks of workflow.hooksByAnchorStep.values()) {
for (const entry of [...anchorHooks.before, ...anchorHooks.after]) {
for (const step of entry.steps) {
statuses[step.id] = step.status;
}
}
}
return statuses;
}
describe('hooks run iff the anchor runs', () => {
it('skips before AND after hooks (displayed as skipped) when the anchor if: is false', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules', if: '${{ false }}' }],
hooks: {
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('before'),
recordingFunction('after'),
],
});
await workflow.executeAsync();
expect(executionLog).toEqual([]);
expect(hookStepStatuses(workflow)).toEqual({
'before-hook': BuildStepStatus.SKIPPED,
'after-hook': BuildStepStatus.SKIPPED,
});
});
it('never runs hooks of a skipped anchor, even with if: always() on the hook step', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules', if: '${{ false }}' }],
hooks: {
after_install_node_modules: [
{ uses: 'test/after', id: 'after-hook', if: '${{ always() }}' },
],
},
externalFunctions: [anchorFunction(), recordingFunction('after')],
});
await workflow.executeAsync();
expect(executionLog).toEqual([]);
expect(hookStepStatuses(workflow)['after-hook']).toBe(BuildStepStatus.SKIPPED);
});
});
describe('the always() matrix (baseline failure is ignored, in-sequence failure is not)', () => {
it('runs no-if before hooks when the anchor runs past a prior failure via always()', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'test/boom' }, { uses: 'eas/install_node_modules', if: '${{ always() }}' }],
hooks: {
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('boom', { failWith: new Error('boom failed') }),
recordingFunction('before'),
recordingFunction('after'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('boom failed');
expect(executionLog).toEqual(['boom', 'before', 'anchor', 'after']);
});
it('a before hook failing IN the sequence skips subsequent no-if before entries, the anchor, and after hooks', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [
{ uses: 'test/failing-before', id: 'failing-before' },
{ uses: 'test/second-before', id: 'second-before' },
],
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('failing-before', { failWith: new Error('before failed') }),
recordingFunction('second-before'),
recordingFunction('after'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('before failed');
expect(executionLog).toEqual(['failing-before']);
const statuses = hookStepStatuses(workflow);
expect(statuses['second-before']).toBe(BuildStepStatus.SKIPPED);
expect(statuses['after-hook']).toBe(BuildStepStatus.SKIPPED);
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
});
it('failure() and success() on hook steps keep their global meaning', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'test/boom' }, { uses: 'eas/install_node_modules', if: '${{ always() }}' }],
hooks: {
before_install_node_modules: [
{ uses: 'test/on-failure', id: 'on-failure', if: '${{ failure() }}' },
{ uses: 'test/on-success', id: 'on-success', if: '${{ success() }}' },
],
},
externalFunctions: [
anchorFunction(),
recordingFunction('boom', { failWith: new Error('boom failed') }),
recordingFunction('on-failure'),
recordingFunction('on-success'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('boom failed');
expect(executionLog).toEqual(['boom', 'on-failure', 'anchor']);
expect(hookStepStatuses(workflow)['on-success']).toBe(BuildStepStatus.SKIPPED);
});
});
describe('after hooks on anchor failure', () => {
it('runs no-if and failure() after hooks past a failed anchor; success() skips', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
after_install_node_modules: [
{ uses: 'test/no-if', id: 'no-if' },
{ uses: 'test/on-success', id: 'on-success', if: '${{ success() }}' },
{ uses: 'test/on-failure', id: 'on-failure', if: '${{ failure() }}' },
],
},
externalFunctions: [
failingAnchorFunction(),
recordingFunction('no-if'),
recordingFunction('on-success'),
recordingFunction('on-failure'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('anchor failed');
expect(executionLog).toEqual(['anchor', 'no-if', 'on-failure']);
expect(hookStepStatuses(workflow)['on-success']).toBe(BuildStepStatus.SKIPPED);
});
it("the anchor's error outranks a failed after hook's error", async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
after_install_node_modules: [{ uses: 'test/failing-after', id: 'failing-after' }],
},
externalFunctions: [
failingAnchorFunction(),
recordingFunction('failing-after', { failWith: new Error('after failed') }),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('anchor failed');
expect(executionLog).toEqual(['anchor', 'failing-after']);
});
it('an earlier failed after entry does not stop later no-if after entries (after-chain continuation)', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
after_install_node_modules: [
{ uses: 'test/failing-after', id: 'failing-after' },
{ uses: 'test/second-after', id: 'second-after' },
],
},
externalFunctions: [
anchorFunction(),
recordingFunction('failing-after', { failWith: new Error('after failed') }),
recordingFunction('second-after'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('after failed');
expect(executionLog).toEqual(['anchor', 'failing-after', 'second-after']);
});
it('a green anchor with a failed after hook fails the job with the after hook error', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
after_install_node_modules: [{ uses: 'test/failing-after', id: 'failing-after' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('failing-after', { failWith: new Error('after failed') }),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('after failed');
expect(executionLog).toEqual(['anchor', 'failing-after']);
});
});
describe('before hook shell semantics', () => {
it('a hook step failure suppressed with || true keeps the anchor running', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ run: 'false || true', id: 'suppressed' }],
},
externalFunctions: [anchorFunction()],
});
await workflow.executeAsync();
expect(executionLog).toEqual(['anchor']);
});
});
describe('anchor gate errors', () => {
it('a throwing anchor if: marks the job failed, skips the anchor and its hooks, and later failure() steps still run', async () => {
const workflow = await parseAsync({
steps: [
{ uses: 'eas/install_node_modules', if: '${{ nonexistent.object.property }}' },
{ uses: 'test/cleanup', id: 'cleanup', if: '${{ failure() }}' },
],
hooks: {
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('before'),
recordingFunction('cleanup'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow();
expect(executionLog).toEqual(['cleanup']);
expect(hookStepStatuses(workflow)['before-hook']).toBe(BuildStepStatus.SKIPPED);
});
});
describe('dormancy and the falsy-throw bugfix', () => {
it('a hookless workflow executes as before', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'test/one' }, { uses: 'test/two' }],
hooks: undefined,
externalFunctions: [recordingFunction('one'), recordingFunction('two')],
});
await workflow.executeAsync();
expect(executionLog).toEqual(['one', 'two']);
expect(metrics).toEqual([]);
});
it('a step throwing a falsy value fails the job with a descriptive wrapped error', async () => {
const workflow = await parseAsync({
steps: [
{ uses: 'test/falsy-boom' },
{ uses: 'test/skipped-after', id: 'skipped-after' },
{ uses: 'test/cleanup', if: '${{ failure() }}' },
],
hooks: undefined,
externalFunctions: [
new BuildFunction({
namespace: 'test',
id: 'falsy-boom',
fn: () => {
executionLog.push('falsy-boom');
return Promise.reject(undefined);
},
}),
recordingFunction('skipped-after'),
recordingFunction('cleanup'),
],
});
const execution = workflow.executeAsync();
await expect(execution).rejects.toBeInstanceOf(BuildStepRuntimeError);
await expect(execution).rejects.toThrow('threw a non-Error value: undefined');
expect(executionLog).toEqual(['falsy-boom', 'cleanup']);
});
it('the first failure wins even when it was a falsy throw', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'test/falsy-boom' }, { uses: 'test/late-boom', if: '${{ always() }}' }],
hooks: undefined,
externalFunctions: [
new BuildFunction({
namespace: 'test',
id: 'falsy-boom',
fn: () => Promise.reject(undefined),
}),
new BuildFunction({
namespace: 'test',
id: 'late-boom',
fn: () => {
throw new Error('later');
},
}),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('threw a non-Error value');
});
});
describe('group hook entries', () => {
function createGroup(
id: string,
childIds: string[]
): {
group: BuildFunctionGroup;
functions: BuildFunction[];
} {
const functions = childIds.map(childId => recordingFunction(childId));
return {
group: new BuildFunctionGroup({
namespace: 'test',
id,
createBuildStepsFromFunctionGroupCall: globalCtx =>
functions.map(fn => fn.createBuildStepFromFunctionCall(globalCtx)),
}),
functions,
};
}
it('executes a function group in an after hook', async () => {
const { group, functions } = createGroup('group', ['child-one', 'child-two']);
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { after_install_node_modules: [{ uses: 'test/group' }] },
externalFunctions: [anchorFunction(), ...functions],
externalFunctionGroups: [group],
});
await workflow.executeAsync();
expect(executionLog).toEqual(['anchor', 'child-one', 'child-two']);
});
it('a group hook entry with if: false executes NOTHING (entry-level ifCondition)', async () => {
const { group, functions } = createGroup('group', ['child-one', 'child-two']);
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ uses: 'test/group', if: '${{ false }}' }] },
externalFunctions: [anchorFunction(), ...functions],
externalFunctionGroups: [group],
});
await workflow.executeAsync();
expect(executionLog).toEqual(['anchor']);
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
for (const step of anchorHooks.before[0].steps) {
expect(step.status).toBe(BuildStepStatus.SKIPPED);
}
});
it('a group entry whose if: passes runs its steps past an earlier entry failure', async () => {
const { group, functions } = createGroup('group', ['child-one', 'child-two']);
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [
{ uses: 'test/failing-before', id: 'failing-before' },
{ uses: 'test/group', if: '${{ always() }}' },
],
},
externalFunctions: [
anchorFunction(),
recordingFunction('failing-before', { failWith: new Error('before failed') }),
...functions,
],
externalFunctionGroups: [group],
});
await expect(workflow.executeAsync()).rejects.toThrow('before failed');
expect(executionLog).toEqual(['failing-before', 'child-one', 'child-two']);
// ONE event for the whole before side, with the side's aggregated
// result (the first entry failed); the skipped anchor reports nothing.
expect(metrics).toEqual([
{
anchor: 'install_node_modules',
timing: 'before',
result: 'failed',
},
]);
});
it('an entry-level condition evaluation error fails the hook (and the job), not silently ignored', async () => {
const { group, functions } = createGroup('group', ['child-one']);
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [
{ uses: 'test/group', if: '${{ nonexistent.object.property }}' },
],
},
externalFunctions: [anchorFunction(), ...functions],
externalFunctionGroups: [group],
});
await expect(workflow.executeAsync()).rejects.toThrow();
expect(executionLog).toEqual([]);
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
});
});
describe('the eas.workflow.hook metric callback', () => {
it('reports one event per executed hook side with anchor, timing, and result', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ run: 'true', id: 'run-before' }],
after_install_node_modules: [{ uses: 'test/after', id: 'uses-after' }],
},
externalFunctions: [anchorFunction(), recordingFunction('after')],
});
await workflow.executeAsync();
expect(metrics).toEqual([
{
anchor: 'install_node_modules',
timing: 'before',
result: 'success',
},
{
anchor: 'install_node_modules',
timing: 'after',
result: 'success',
anchorResult: 'success',
},
]);
});
it('reports nothing for skipped hooks', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules', if: '${{ false }}' }],
hooks: {
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('before'),
recordingFunction('after'),
],
});
await workflow.executeAsync();
expect(metrics).toEqual([]);
});
it('tags after events with the anchor LOCAL outcome: failed anchor', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }] },
externalFunctions: [failingAnchorFunction(), recordingFunction('after')],
});
await expect(workflow.executeAsync()).rejects.toThrow('anchor failed');
expect(metrics).toEqual([
{
anchor: 'install_node_modules',
timing: 'after',
result: 'success',
anchorResult: 'failed',
},
]);
});
it('tags anchorResult success for an always() anchor executing green past an unrelated failure', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'test/boom' }, { uses: 'eas/install_node_modules', if: '${{ always() }}' }],
hooks: { after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }] },
externalFunctions: [
anchorFunction(),
recordingFunction('boom', { failWith: new Error('boom failed') }),
recordingFunction('after'),
],
});
await expect(workflow.executeAsync()).rejects.toThrow('boom failed');
expect(metrics).toEqual([
{
anchor: 'install_node_modules',
timing: 'after',
result: 'success',
anchorResult: 'success',
},
]);
});
it('a multi-child group hook reports exactly ONE event with the aggregated result', async () => {
const failingChild = new BuildFunction({
namespace: 'test',
id: 'failing-child',
fn: () => {
executionLog.push('failing-child');
throw new Error('child failed');
},
});
const okChild = recordingFunction('ok-child');
const group = new BuildFunctionGroup({
namespace: 'test',
id: 'group',
// The failing child comes FIRST: after-side no-`if:` steps continue
// past sibling failures, so ok-child must still execute.
createBuildStepsFromFunctionGroupCall: globalCtx => [
failingChild.createBuildStepFromFunctionCall(globalCtx),
okChild.createBuildStepFromFunctionCall(globalCtx),
],
});
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { after_install_node_modules: [{ uses: 'test/group' }] },
externalFunctions: [anchorFunction(), okChild, failingChild],
externalFunctionGroups: [group],
});
await expect(workflow.executeAsync()).rejects.toThrow('child failed');
expect(executionLog).toEqual(['anchor', 'failing-child', 'ok-child']);
expect(metrics).toEqual([
{
anchor: 'install_node_modules',
timing: 'after',
result: 'failed',
anchorResult: 'success',
},
]);
});
});
describe('execution order', () => {
it('executes multi-occurrence wraps in order, each occurrence with its own hooks', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }, { uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ uses: 'test/before' }],
after_install_node_modules: [{ uses: 'test/after' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('before'),
recordingFunction('after'),
],
});
await workflow.executeAsync();
expect(executionLog).toEqual(['before', 'anchor', 'after', 'before', 'anchor', 'after']);
});
it('executes hooks around an anchored function inside a group expansion', async () => {
const anchor = anchorFunction();
const plain = recordingFunction('plain');
const group = new BuildFunctionGroup({
namespace: 'test',
id: 'group',
createBuildStepsFromFunctionGroupCall: globalCtx => [
plain.createBuildStepFromFunctionCall(globalCtx),
anchor.createBuildStepFromFunctionCall(globalCtx),
],
});
const workflow = await parseAsync({
steps: [{ uses: 'test/group' }],
hooks: {
before_install_node_modules: [{ uses: 'test/before' }],
after_install_node_modules: [{ uses: 'test/after' }],
},
externalFunctions: [anchor, plain, recordingFunction('before'), recordingFunction('after')],
externalFunctionGroups: [group],
});
await workflow.executeAsync();
expect(executionLog).toEqual(['plain', 'before', 'anchor', 'after']);
});
});
});
@@ -0,0 +1,586 @@
import { Hooks, Step } from '@expo/eas-build-job';
import { createGlobalContextMock } from './utils/context';
import { getErrorAsync } from './utils/error';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildRuntimePlatform } from '../BuildRuntimePlatform';
import { BuildStepGlobalContext } from '../BuildStepContext';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildWorkflow } from '../BuildWorkflow';
import { StepsConfigParser } from '../StepsConfigParser';
import { BuildConfigError, BuildWorkflowError } from '../errors';
import { constructHookEntriesAsync, validateHookStepsAsync } from '../hooks';
function createInstallNodeModulesFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_node_modules',
name: 'Install node modules',
command: 'npm install',
__hookId: 'install_node_modules',
});
}
function createCheckoutFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'checkout',
name: 'Checkout',
command: 'echo checkout',
__hookId: 'checkout',
});
}
async function parseWorkflowAsync({
ctx,
steps,
hooks,
externalFunctions,
externalFunctionGroups,
}: {
ctx: BuildStepGlobalContext;
steps: Step[];
hooks: Hooks | undefined;
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
}): Promise<BuildWorkflow> {
const parser = new StepsConfigParser(ctx, {
steps,
hooks,
externalFunctions: externalFunctions ?? [
createInstallNodeModulesFunction(),
createCheckoutFunction(),
],
externalFunctionGroups,
});
return await parser.parseAsync();
}
function orderedDisplayNames(workflow: BuildWorkflow): string[] {
return workflow.getExecutionOrderedSteps().map(step => step.displayName);
}
describe('StepsConfigParser hook construction', () => {
let ctx: BuildStepGlobalContext;
beforeEach(() => {
ctx = createGlobalContextMock();
});
it('parses identically when hooks are undefined or empty (dormancy)', async () => {
for (const hooks of [undefined, {}]) {
const workflow = await parseWorkflowAsync({
ctx: createGlobalContextMock(),
steps: [{ uses: 'eas/checkout' }, { run: 'echo hi', id: 'user-step' }],
hooks,
});
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Checkout', 'user-step']);
expect(workflow.hooksByAnchorStep.size).toBe(0);
}
});
it('attaches before and after hook entries to a function anchor without splicing them into buildSteps', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/checkout' }, { 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' }],
},
});
// Hook steps never join buildSteps; the engine executes them around the anchor.
expect(workflow.buildSteps.map(step => step.displayName)).toEqual([
'Checkout',
'Install node modules',
]);
const installStep = workflow.buildSteps[1];
const anchorHooks = workflow.hooksByAnchorStep.get(installStep);
expect(anchorHooks?.anchor).toBe('install_node_modules');
expect(anchorHooks?.before.map(entry => entry.steps.map(step => step.displayName))).toEqual([
['before-hook'],
]);
expect(anchorHooks?.after.map(entry => entry.steps.map(step => step.displayName))).toEqual([
['after-hook'],
]);
expect(orderedDisplayNames(workflow)).toEqual([
'Checkout',
'before-hook',
'Install node modules',
'after-hook',
]);
});
it('generates step ids in the spliced order (before → anchor → after) for id parity', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ run: 'echo before' }],
after_install_node_modules: [{ run: 'echo after' }],
},
});
const orderedIds = workflow.getExecutionOrderedSteps().map(step => step.id);
// Generated ids are sequential (step-NNN); execution order must equal
// generation order with no ids consumed and discarded in between,
// exactly as splicing produced it.
const numbers = orderedIds.map(id => Number(id.replace('step-', '')));
for (let i = 1; i < numbers.length; i++) {
expect(numbers[i]).toBe(numbers[i - 1] + 1);
}
});
it('resolves a stamped shell step (merged maestro_cloud) to its anchor', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ run: 'echo maestro cloud', id: 'maestro_cloud', __hook_id: 'maestro_cloud' }],
hooks: {
before_maestro_cloud: [{ run: 'echo before', id: 'before-hook' }],
after_maestro_cloud: [{ run: 'echo after', id: 'after-hook' }],
},
});
expect(orderedDisplayNames(workflow)).toEqual(['before-hook', 'maestro_cloud', 'after-hook']);
});
it('wraps every occurrence of an anchored function independently (per-occurrence entries)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }, { uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ run: 'echo before' }],
after_install_node_modules: [{ run: 'echo after' }],
},
});
expect(orderedDisplayNames(workflow)).toEqual([
'echo before',
'Install node modules',
'echo after',
'echo before',
'Install node modules',
'echo after',
]);
expect(workflow.hooksByAnchorStep.size).toBe(2);
const [firstHooks, secondHooks] = [...workflow.hooksByAnchorStep.values()];
expect(firstHooks.before[0].steps[0]).not.toBe(secondHooks.before[0].steps[0]);
});
it('never treats hook-constructed steps as anchors (no nesting, direct anchored function)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ uses: 'eas/install_node_modules', id: 'hook-install' }],
},
});
expect(workflow.hooksByAnchorStep.size).toBe(1);
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
expect(anchorHooks.before[0].steps[0].id).toBe('hook-install');
// The hook step invoking the anchored function got no hooks of its own.
expect(workflow.hooksByAnchorStep.get(anchorHooks.before[0].steps[0])).toBeUndefined();
});
it('ignores unknown hook keys without erroring (worker skew constraint)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_some_future_anchor: [{ run: 'echo never' }],
not_a_hook_key: [{ run: 'echo never' }],
},
});
expect(orderedDisplayNames(workflow)).toEqual(['Install node modules']);
});
it('keeps unknown hook keys fully inert even when their steps reference unknown functions', async () => {
// A newer server may send hooks for anchors (and functions) this worker
// does not know yet — they must never fail the parse.
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_some_future_anchor: [{ uses: 'eas/some_future_function' }],
also_not_a_hook_key: ['not even steps' as unknown as Step],
},
});
expect(orderedDisplayNames(workflow)).toEqual(['Install node modules']);
});
it('ignores hook keys whose anchor is not present in the steps', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/checkout' }],
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
});
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
});
it('treats an explicit empty hook array as a deliberate no-op', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [] },
});
expect(workflow.hooksByAnchorStep.size).toBe(0);
});
it('validates hook step arrays like job steps (BuildConfigError, not a crash)', async () => {
const error = await getErrorAsync<BuildConfigError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
// Simulates a malformed payload arriving over the wire (no intake validation).
hooks: { before_install_node_modules: [{ id: 'neither-run-nor-uses' } as unknown as Step] },
});
});
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/before_install_node_modules/);
});
it('rejects a non-array hook value with BuildConfigError instead of dropping it silently', async () => {
const error = await getErrorAsync<BuildConfigError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
// Simulates a malformed payload arriving over the wire.
hooks: { before_install_node_modules: 'echo hi' as unknown as Step[] },
});
});
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/before_install_node_modules/);
});
it('rejects a hook function step naming an unknown function with BuildConfigError', async () => {
const error = await getErrorAsync<BuildConfigError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ uses: 'eas/nonexistent_function' }] },
});
});
expect(error).toBeInstanceOf(BuildConfigError);
expect(error.message).toMatch(/nonexistent_function/);
});
});
describe('constructHookEntriesAsync (public API)', () => {
it('treats an empty hook step array as a valid no-op', async () => {
const ctx = createGlobalContextMock();
await expect(
constructHookEntriesAsync(ctx, [], {
externalFunctions: [createInstallNodeModulesFunction()],
})
).resolves.toEqual([]);
});
it('rejects duplicate function ids instead of letting array order pick the implementation', async () => {
const ctx = createGlobalContextMock();
await expect(
constructHookEntriesAsync(ctx, [{ uses: 'eas/install_node_modules' }], {
externalFunctions: [createInstallNodeModulesFunction(), createInstallNodeModulesFunction()],
})
).rejects.toThrow('already defined');
});
it('validateHookStepsAsync rejects duplicate ids across an ordered view (public API)', async () => {
const ctx = createGlobalContextMock();
const entries = await constructHookEntriesAsync(
ctx,
[
{ run: 'echo a', id: 'dup' },
{ run: 'echo b', id: 'dup' },
],
{}
);
await expect(
validateHookStepsAsync(
ctx,
entries.flatMap(entry => entry.steps)
)
).rejects.toThrow('Hook steps are invalid.');
});
});
describe('StepsConfigParser stamp semantics', () => {
let ctx: BuildStepGlobalContext;
beforeEach(() => {
ctx = createGlobalContextMock();
});
it('a stamp matching the invoked function declaration anchors the step', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules', __hook_id: 'install_node_modules' }],
hooks: { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }] },
});
expect(orderedDisplayNames(workflow)).toEqual(['before-hook', 'Install node modules']);
});
it('a stamp CONFLICTING with the function declaration wins over it', async () => {
const workflow = await parseWorkflowAsync({
ctx,
// install_node_modules function, stamped as the submit anchor.
steps: [{ uses: 'eas/install_node_modules', __hook_id: 'submit' }],
hooks: {
before_submit: [{ run: 'echo before submit', id: 'submit-hook' }],
before_install_node_modules: [{ run: 'echo never' }],
},
});
expect(orderedDisplayNames(workflow)).toEqual(['submit-hook', 'Install node modules']);
});
it('an UNREGISTERED stamp on a declaring function step is inert — no fallback to the declaration', async () => {
// A newer server stamping a future anchor must render the step inert on
// this worker, never silently rebind it to the function's older anchor.
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules', __hook_id: 'some_future_anchor' }],
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
});
expect(orderedDisplayNames(workflow)).toEqual(['Install node modules']);
expect(workflow.hooksByAnchorStep.size).toBe(0);
});
it('an unstamped step invoking a declaring function resolves via the declaration', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }] },
});
expect(orderedDisplayNames(workflow)).toEqual(['before-hook', 'Install node modules']);
});
});
describe('StepsConfigParser hooks with function groups', () => {
let ctx: BuildStepGlobalContext;
let checkoutFunction: BuildFunction;
let installFunction: BuildFunction;
beforeEach(() => {
ctx = createGlobalContextMock();
checkoutFunction = createCheckoutFunction();
installFunction = createInstallNodeModulesFunction();
});
function createGroup(id = 'group'): BuildFunctionGroup {
return new BuildFunctionGroup({
namespace: 'test',
id,
createBuildStepsFromFunctionGroupCall: globalCtx => [
checkoutFunction.createBuildStepFromFunctionCall(globalCtx),
installFunction.createBuildStepFromFunctionCall(globalCtx),
],
});
}
it('attaches hooks to anchored functions inside a group expansion (late construction)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'test/group' }],
hooks: {
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
},
externalFunctions: [checkoutFunction, installFunction],
externalFunctionGroups: [createGroup()],
});
expect(orderedDisplayNames(workflow)).toEqual([
'Checkout',
'before-hook',
'Install node modules',
'after-hook',
]);
});
it('leaves group expansions without anchored functions untouched', async () => {
const group = new BuildFunctionGroup({
namespace: 'test',
id: 'group',
createBuildStepsFromFunctionGroupCall: globalCtx => [
new BuildFunction({
id: 'not_an_anchor',
command: 'echo x',
}).createBuildStepFromFunctionCall(globalCtx),
],
});
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'test/group' }],
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
externalFunctions: [],
externalFunctionGroups: [group],
});
expect(workflow.buildSteps).toHaveLength(1);
expect(workflow.hooksByAnchorStep.size).toBe(0);
});
it('parses a function group inside an after hook as ONE entry with the expansion inside', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { after_install_node_modules: [{ uses: 'test/group' }] },
externalFunctions: [installFunction, checkoutFunction],
externalFunctionGroups: [createGroup()],
});
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
expect(anchorHooks.after).toHaveLength(1);
expect(anchorHooks.after[0].steps.map(step => step.displayName)).toEqual([
'Checkout',
'Install node modules',
]);
});
it('carries the authored if: of a group hook step on the entry (expansion drops step-level if:)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ uses: 'test/group', if: '${{ failure() }}' }] },
externalFunctions: [installFunction, checkoutFunction],
externalFunctionGroups: [createGroup()],
});
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
expect(anchorHooks.before[0].ifCondition).toBe('${{ failure() }}');
});
it('does not attach hooks inside a group expansion that is itself a hook step (no nesting)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ uses: 'test/group' }] },
externalFunctions: [installFunction, checkoutFunction],
externalFunctionGroups: [createGroup()],
});
// Exactly one anchor: the job's own install step. The install step inside
// the hook's group expansion got nothing.
expect(workflow.hooksByAnchorStep.size).toBe(1);
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
for (const hookStep of anchorHooks.before.flatMap(entry => entry.steps)) {
expect(workflow.hooksByAnchorStep.get(hookStep)).toBeUndefined();
}
});
it('a REGISTERED stamp on a group call is inert like any other stamp on a group call', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'test/group', __hook_id: 'submit' }],
hooks: { before_submit: [{ run: 'echo never' }] },
externalFunctions: [installFunction, checkoutFunction],
externalFunctionGroups: [createGroup()],
});
// The group expands normally; the stamp never matches an anchor, so the
// submit hook stays unmatched (warned, not run).
expect(workflow.buildSteps).toHaveLength(2);
expect(workflow.hooksByAnchorStep.size).toBe(0);
});
it('treats an UNREGISTERED-stamped group call as an inert ordinary step (skew outranks the group fence)', async () => {
const workflow = await parseWorkflowAsync({
ctx,
steps: [{ uses: 'test/group', __hook_id: 'some_future_anchor' }],
hooks: { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }] },
externalFunctions: [installFunction, checkoutFunction],
externalFunctionGroups: [createGroup()],
});
// The group expands normally; its inner anchored functions still anchor.
expect(workflow.buildSteps).toHaveLength(2);
expect(workflow.hooksByAnchorStep.size).toBe(1);
});
});
describe('StepsConfigParser hook validation view', () => {
let ctx: BuildStepGlobalContext;
beforeEach(() => {
ctx = createGlobalContextMock();
});
it('fails parseAsync when a hook step id collides with a job step id', async () => {
const error = await getErrorAsync<BuildWorkflowError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }, { run: 'echo hi', id: 'my-step' }],
hooks: { before_install_node_modules: [{ run: 'echo hook', id: 'my-step' }] },
});
});
expect(error).toBeInstanceOf(BuildWorkflowError);
});
it('fails parseAsync when two hooks of different keys collide on step id', async () => {
const error = await getErrorAsync<BuildWorkflowError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [{ run: 'echo a', id: 'dup' }],
after_install_node_modules: [{ run: 'echo b', id: 'dup' }],
},
});
});
expect(error).toBeInstanceOf(BuildWorkflowError);
});
it('validates output references through the ordered view: a step before the anchor cannot reference an after-hook output', async () => {
const consumer = new BuildFunction({
namespace: 'test',
id: 'consumer',
command: 'echo consume',
inputProviders: [
BuildStepInput.createProvider({
id: 'value',
required: true,
defaultValue: '${ steps.late-hook.value }',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
});
const error = await getErrorAsync<BuildWorkflowError>(async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'test/consumer' }, { uses: 'eas/install_node_modules' }],
hooks: {
after_install_node_modules: [
{ run: 'echo hook', id: 'late-hook', outputs: [{ name: 'value' }] },
],
},
externalFunctions: [createInstallNodeModulesFunction(), consumer],
});
});
expect(error).toBeInstanceOf(BuildWorkflowError);
expect((error as BuildWorkflowError).errors[0].message).toMatch(/future step "late-hook"/);
// The mirror case is valid: a step AFTER the anchor sees the after-hook's
// output, because the ordered view places the hook before it.
await expect(
parseWorkflowAsync({
ctx: createGlobalContextMock(),
steps: [{ uses: 'eas/install_node_modules' }, { uses: 'test/consumer' }],
hooks: {
after_install_node_modules: [
{ run: 'echo hook', id: 'late-hook', outputs: [{ name: 'value' }] },
],
},
externalFunctions: [createInstallNodeModulesFunction(), consumer],
})
).resolves.toBeInstanceOf(BuildWorkflow);
});
it('validates runtime-platform allowance for hook steps through the same aggregate view', async () => {
const darwinOnly = new BuildFunction({
namespace: 'test',
id: 'darwin_only',
command: 'echo darwin',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
});
const error = await getErrorAsync<BuildWorkflowError>(async () => {
await parseWorkflowAsync({
// The mock context runs on LINUX.
ctx,
steps: [{ uses: 'eas/install_node_modules' }],
hooks: { before_install_node_modules: [{ uses: 'test/darwin_only' }] },
externalFunctions: [createInstallNodeModulesFunction(), darwinOnly],
});
});
expect(error).toBeInstanceOf(BuildWorkflowError);
expect((error as BuildWorkflowError).errors[0].message).toMatch(/not allowed on platform/);
});
});
@@ -17,6 +17,7 @@ describe(StepsConfigParser, () => {
const error = getError<BuildStepRuntimeError>(() => {
// eslint-disable-next-line no-new
new StepsConfigParser(ctx, {
hooks: undefined,
steps: [],
externalFunctions: [
new BuildFunction({ id: 'abc', command: 'echo 123' }),
@@ -33,6 +34,7 @@ describe(StepsConfigParser, () => {
const error = getError<BuildStepRuntimeError>(() => {
// eslint-disable-next-line no-new
new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
run: 'test',
@@ -64,6 +66,7 @@ describe(StepsConfigParser, () => {
expect(() => {
// eslint-disable-next-line no-new
new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
run: 'test',
@@ -85,6 +88,7 @@ describe(StepsConfigParser, () => {
expect(() => {
// eslint-disable-next-line no-new
new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
run: 'test',
@@ -114,6 +118,7 @@ describe(StepsConfigParser, () => {
it('throws an error when calling non-existent function', async () => {
const ctx = createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
run: 'test',
@@ -131,6 +136,7 @@ describe(StepsConfigParser, () => {
it('throws an error if steps are empty array', async () => {
const ctx = createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
hooks: undefined,
steps: [],
});
await expect(parser.parseAsync()).rejects.toThrow(
@@ -141,6 +147,7 @@ describe(StepsConfigParser, () => {
it('returns a BuildWorkflow object', async () => {
const ctx = createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
run: 'test',
@@ -164,6 +171,7 @@ describe(StepsConfigParser, () => {
it('parses steps and external functions into build workflow with steps', async () => {
const ctx = createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
hooks: undefined,
steps: [
{
run: 'command1',
@@ -12,11 +12,12 @@ import {
ExternalBuildContextProvider,
} from '../../BuildStepContext';
import { BuildStepEnv } from '../../BuildStepEnv';
import { StepMetric } from '../../StepMetrics';
import { StepMetric, WorkflowHookMetric } from '../../StepMetrics';
export class MockContextProvider implements ExternalBuildContextProvider {
private _env: BuildStepEnv = {};
public reportStepMetric?: (metric: StepMetric) => void;
public reportWorkflowHookMetric?: (metric: WorkflowHookMetric) => void;
constructor(
public readonly logger: bunyan,
@@ -48,6 +49,7 @@ interface BuildContextParams {
relativeWorkingDirectory?: string;
staticContextContent?: JobInterpolationContext;
reportStepMetric?: (metric: StepMetric) => void;
reportWorkflowHookMetric?: (metric: WorkflowHookMetric) => void;
}
export function createStepContextMock({
@@ -85,6 +87,7 @@ export function createGlobalContextMock({
relativeWorkingDirectory,
staticContextContent,
reportStepMetric,
reportWorkflowHookMetric,
}: BuildContextParams = {}): BuildStepGlobalContext {
const resolvedProjectTargetDirectory =
projectTargetDirectory ?? path.join(os.tmpdir(), 'eas-build', uuidv4());
@@ -102,5 +105,8 @@ export function createGlobalContextMock({
if (reportStepMetric) {
provider.reportStepMetric = reportStepMetric;
}
if (reportWorkflowHookMetric) {
provider.reportWorkflowHookMetric = reportWorkflowHookMetric;
}
return new BuildStepGlobalContext(provider, skipCleanup ?? false);
}
+225
View File
@@ -0,0 +1,225 @@
import {
HookAnchorId,
ShellStep,
Step,
isStepFunctionStep,
validateSteps,
} from '@expo/eas-build-job';
import assert from 'node:assert';
import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction';
import {
BuildFunctionGroup,
BuildFunctionGroupById,
createBuildFunctionGroupByIdMapping,
} from './BuildFunctionGroup';
import { BuildStep } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepOutput } from './BuildStepOutput';
import { collectAggregateStepErrors } from './BuildWorkflowValidator';
import { BuildConfigError, BuildWorkflowError } from './errors';
/**
* One entry per AUTHORED hook step — the unit the user wrote. The wrapper
* earns its place twice. (1) A function-group call vanishes at expansion, so
* the entry is the authored `if:`'s only home, evaluated once for the whole
* group; `run:` and single-function entries keep the `if:` on the step itself
* — the condition never exists in two places. Entry conditions see the global
* context only; a group call's `with:` inputs are not visible to them.
* (2) An entry whose explicit `if:` passed behaves like a single step whose
* `if:` passed: its no-`if:` steps run past earlier entries' failures, while
* within-entry failures still skip later siblings.
*/
export interface HookEntry {
steps: BuildStep[];
ifCondition?: string;
}
/**
* The hook entries attached to one anchor step occurrence. Carries the anchor
* id because a stamped anchor is not reverse-derivable from a `BuildStep`.
*/
export interface AnchorHooks {
anchor: HookAnchorId;
before: HookEntry[];
after: HookEntry[];
}
/**
* Constructs hook entries from authored hook steps: ONE authored step → ONE
* entry, anchor discovery disabled (a hook step invoking an anchored function
* or a function group never becomes an anchor itself — no nesting). Validates
* step shapes and function existence; the aggregate (cross-key) validation is
* `validateHookStepsAsync`.
*
* Promise-returning by contract (like `validateHookStepsAsync`) even though
* the body is synchronous: this API is pre-published for the native hook
* runner (phase 3, not yet landed), and widening sync → async later would
* break its callers.
*/
export async function constructHookEntriesAsync(
ctx: BuildStepGlobalContext,
steps: Step[],
{
externalFunctions,
externalFunctionGroups,
}: {
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
}
): Promise<HookEntry[]> {
// An empty array is a valid no-op (e.g. opting out of a default hook);
// validateSteps requires at least one step, so short-circuit before it —
// but ONLY for a real array (malformed wire input must still reach
// validateSteps and be rejected, not silently disable the hook).
if (Array.isArray(steps) && steps.length === 0) {
return [];
}
const validatedSteps = validateSteps(steps);
const buildFunctionById = createBuildFunctionByIdMapping(externalFunctions ?? []);
const buildFunctionGroupById = createBuildFunctionGroupByIdMapping(externalFunctionGroups ?? []);
validateAllStepFunctionsExist(validatedSteps, {
externalFunctionIds: Object.keys(buildFunctionById),
externalFunctionGroupIds: Object.keys(buildFunctionGroupById),
});
return constructHookEntriesFromValidatedSteps(ctx, validatedSteps, {
buildFunctionById,
buildFunctionGroupById,
});
}
/**
* Aggregate validation over an execution-ordered view of steps (unique ids,
* output references, runtime-platform allowance). The parser runs it over
* `job steps + hooks` (through `BuildWorkflowValidator`).
*/
export async function validateHookStepsAsync(
ctx: BuildStepGlobalContext,
orderedView: readonly BuildStep[]
): Promise<void> {
const errors = collectAggregateStepErrors(orderedView, {
runtimePlatform: ctx.runtimePlatform,
});
if (errors.length !== 0) {
throw new BuildWorkflowError('Hook steps are invalid.', errors);
}
}
/** Package-internal: the parser path — steps already validated, function maps already built. */
export function constructHookEntriesFromValidatedSteps(
ctx: BuildStepGlobalContext,
validatedSteps: Step[],
{
buildFunctionById,
buildFunctionGroupById,
}: {
buildFunctionById: BuildFunctionById;
buildFunctionGroupById: BuildFunctionGroupById;
}
): HookEntry[] {
const entries: HookEntry[] = [];
for (const step of validatedSteps) {
if (!isStepFunctionStep(step)) {
entries.push({
steps: [createBuildStepFromShellStep(ctx, step)],
});
continue;
}
const maybeFunctionGroup = buildFunctionGroupById[step.uses];
if (maybeFunctionGroup !== undefined) {
entries.push({
steps: maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(ctx, {
callInputs: step.with,
}),
...(step.if ? { ifCondition: step.if } : null),
});
continue;
}
const buildFunction = buildFunctionById[step.uses];
assert(buildFunction, 'function ID must be ID of function or function group');
entries.push({
steps: [
buildFunction.createBuildStepFromFunctionCall(ctx, {
id: step.id,
name: step.name,
callInputs: step.with,
workingDirectory: step.working_directory,
shell: step.shell,
env: step.env,
ifCondition: step.if,
}),
],
});
}
return entries;
}
/** Package-internal: shared shell-step construction (job steps and hook steps). */
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,
})
);
return new BuildStep(ctx, {
id,
displayName,
outputs,
workingDirectory: step.working_directory,
shell: step.shell,
command: step.run,
env: step.env,
ifCondition: step.if,
__metricsId: step.__metrics_id,
});
}
/** Package-internal. */
export function validateAllStepFunctionsExist(
steps: Step[],
{
externalFunctionIds,
externalFunctionGroupIds,
}: {
externalFunctionIds: string[];
externalFunctionGroupIds: string[];
}
): void {
const calledFunctionsOrFunctionGroupsSet = new Set<string>();
for (const step of steps) {
if (step.uses) {
calledFunctionsOrFunctionGroupsSet.add(step.uses);
}
}
const externalFunctionIdsSet = new Set(externalFunctionIds);
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupIds);
const nonExistentFunctionsOrFunctionGroups = Array.from(
calledFunctionsOrFunctionGroupsSet
).filter(
calledFunctionOrFunctionGroup =>
!externalFunctionIdsSet.has(calledFunctionOrFunctionGroup) &&
!externalFunctionGroupsIdsSet.has(calledFunctionOrFunctionGroup)
);
if (nonExistentFunctionsOrFunctionGroups.length > 0) {
throw new BuildConfigError(
`Calling non-existent functions: ${nonExistentFunctionsOrFunctionGroups
.map(f => `"${f}"`)
.join(', ')}.`
);
}
}
+3 -2
View File
@@ -2,12 +2,13 @@ export { BuildStepContext } from './BuildStepContext';
export { readAndValidateBuildConfigFromPathAsync } from './BuildConfig';
export { BuildConfigParser } from './BuildConfigParser';
export { StepsConfigParser } from './StepsConfigParser';
export { AnchorHooks, HookEntry, constructHookEntriesAsync, validateHookStepsAsync } from './hooks';
export { BuildFunction } from './BuildFunction';
export { BuildRuntimePlatform } from './BuildRuntimePlatform';
export { BuildStepInput, BuildStepInputValueTypeName } from './BuildStepInput';
export { BuildStepOutput } from './BuildStepOutput';
export { BuildStepGlobalContext, ExternalBuildContextProvider } from './BuildStepContext';
export { BuildWorkflow } from './BuildWorkflow';
export { BuildWorkflow, executeHookStepsAsync } from './BuildWorkflow';
export { BuildStepEnv } from './BuildStepEnv';
export { BuildFunctionGroup } from './BuildFunctionGroup';
export { BuildStep } from './BuildStep';
@@ -16,4 +17,4 @@ export * from './interpolation';
export * from './utils/shell/spawn';
export * from './utils/jsepEval';
export * from './utils/hashFiles';
export { StepMetric, StepMetricResult } from './StepMetrics';
export { StepMetric, StepMetricResult, WorkflowHookMetric } from './StepMetrics';
+19
View File
@@ -194,3 +194,22 @@ export function jsepEval(expression: string, context?: Record<string, unknown>):
const tree = jsep(expression);
return evaluateExpressionNode(tree, context ?? {});
}
/**
* Evaluates a step-level or hook-entry-level `if:` condition: strips the
* optional `${{ }}` / `${ }` delimiters and coerces the result to boolean.
* The evaluation context is the caller's — step conditions include the step's
* inputs and env, entry conditions the global context only.
*/
export function evaluateIfCondition(
ifCondition: string,
context: Record<string, unknown>
): boolean {
let condition = ifCondition;
if (condition.startsWith('${{') && condition.endsWith('}}')) {
condition = condition.slice(3, -2);
} else if (condition.startsWith('${') && condition.endsWith('}')) {
condition = condition.slice(2, -1);
}
return Boolean(jsepEval(condition, context));
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "21.0.0",
"version": "21.0.1",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "21.0.0",
"@expo/build-tools": "21.0.1",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.0",
"@expo/eas-build-job": "21.0.1",
"@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.0.0",
"@expo/steps": "21.0.1",
"@expo/template-file": "21.0.0",
"@expo/turtle-spawn": "21.0.0",
"@hapi/boom": "10.0.1",
@@ -45,7 +45,7 @@
"node-os-utils": "1.3.7",
"nullthrows": "1.1.1",
"semver": "7.6.3",
"tar": "7.5.7",
"tar": "7.5.19",
"uuid": "3.4.0",
"uuidv7": "1.1.0",
"ws": "8.18.0",
Generated
+25 -64
View File
@@ -2856,14 +2856,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:21.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.0.1, @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.0.0"
"@expo/eas-build-job": "npm:21.0.1"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:21.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2871,7 +2871,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.0.0"
"@expo/steps": "npm:21.0.1"
"@expo/template-file": "npm:21.0.0"
"@expo/turtle-spawn": "npm:21.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2913,7 +2913,7 @@ __metadata:
retry: "npm:^0.13.1"
rimraf: "npm:3.0.2"
semver: "npm:^7.6.2"
tar: "npm:7.5.7"
tar: "npm:7.5.19"
ts-jest: "npm:^29.1.4"
ts-mockito: "npm:^2.6.1"
tslib: "npm:^2.6.3"
@@ -3113,7 +3113,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:21.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:21.0.1, @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:
@@ -3132,12 +3132,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:21.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:21.0.1, @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.0.0"
"@expo/eas-build-job": "npm:21.0.1"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3485,11 +3485,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:21.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:21.0.1, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.1"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -3558,16 +3558,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:21.0.0"
"@expo/build-tools": "npm:21.0.1"
"@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.0.0"
"@expo/eas-build-job": "npm:21.0.1"
"@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.0.0"
"@expo/steps": "npm:21.0.1"
"@expo/template-file": "npm:21.0.0"
"@expo/turtle-spawn": "npm:21.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -3602,7 +3602,7 @@ __metadata:
rimraf: "npm:3.0.2"
semver: "npm:7.6.3"
supertest: "npm:7.2.2"
tar: "npm:7.5.7"
tar: "npm:7.5.19"
ts-jest: "npm:29.1.4"
typescript: "npm:5.5.4"
uuid: "npm:3.4.0"
@@ -10503,7 +10503,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.0.0"
"@expo/steps": "npm:21.0.1"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -11116,8 +11116,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.0.0"
"@expo/eas-build-job": "npm:21.0.0"
"@expo/build-tools": "npm:21.0.1"
"@expo/eas-build-job": "npm:21.0.1"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:21.0.0"
@@ -11137,7 +11137,7 @@ __metadata:
nullthrows: "npm:^1.1.1"
rimraf: "npm:3.0.2"
semver: "npm:^7.6.2"
tar: "npm:7.5.7"
tar: "npm:7.5.19"
ts-jest: "npm:^29.1.4"
typescript: "npm:^5.5.4"
uuid: "npm:^9.0.1"
@@ -11174,8 +11174,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.0.0"
"@expo/eas-json": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.1"
"@expo/eas-json": "npm:21.0.1"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:21.0.0"
@@ -11190,7 +11190,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.0.0"
"@expo/steps": "npm:21.0.1"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"
@@ -11281,7 +11281,7 @@ __metadata:
semver: "npm:7.5.4"
set-interval-async: "npm:3.0.3"
slash: "npm:3.0.0"
tar: "npm:7.5.7"
tar: "npm:7.5.19"
tar-stream: "npm:3.1.7"
terminal-link: "npm:2.1.1"
ts-deepmerge: "npm:6.2.0"
@@ -19785,55 +19785,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:7.5.7":
version: 7.5.7
resolution: "tar@npm:7.5.7"
"tar@npm:7.5.19, tar@npm:^7.4.3, tar@npm:^7.5.2, tar@npm:^7.5.4":
version: 7.5.19
resolution: "tar@npm:7.5.19"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c
languageName: node
linkType: hard
"tar@npm:^7.4.3":
version: 7.5.2
resolution: "tar@npm:7.5.2"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/a7d8b801139b52f93a7e34830db0de54c5aa45487c7cb551f6f3d44a112c67f1cb8ffdae856b05fd4f17b1749911f1c26f1e3a23bbe0279e17fd96077f13f467
languageName: node
linkType: hard
"tar@npm:^7.5.2":
version: 7.5.6
resolution: "tar@npm:7.5.6"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/08af3807035957650ad5f2a300c49ca4fe0566ac0ea5a23741a5b5103c6da42891a9eeaed39bc1fbcf21c5cac4dc846828a004727fb08b9d946322d3144d1fd2
languageName: node
linkType: hard
"tar@npm:^7.5.4":
version: 7.5.9
resolution: "tar@npm:7.5.9"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/e870beb1b2477135ca2abe86b2d18f7b35d0a4e3a37bbc523d3b8f7adca268dfab543f26528a431d569897f8c53a7cac745cdfbc4411c2f89aeeacc652b81b0a
checksum: 10c0/7022e8cb04a8ceccc0689f2c731743fa2aab2e3c3f559f7dbc37b65ef7d5913049b427284eded2ec0765c5db5ff72dd7939fe2ae15785ff422cef2116c95d798
languageName: node
linkType: hard