mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62289b988 | ||
|
|
78c0584331 | ||
|
|
c4a7b7c9f1 | ||
|
|
f448bc75b5 | ||
|
|
0dda7ba880 | ||
|
|
9679af9670 | ||
|
|
3e0e823e56 | ||
|
|
fbb8af6fca |
No files matched your search
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
Generated
+29
-29
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(', ')}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user