Compare commits

..
114 changed files with 626 additions and 1630 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Regenerate graphql code
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Keep the launched app open when an Appium iOS session starts.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Run Appium through the prepared Full Disk Access helper when available on macOS.
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`.
@@ -0,0 +1,5 @@
---
'@expo/eas-json': minor
---
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry transient failures when reporting that a device run session has started.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': major
---
Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Add `code` to the `--json` error: `EAS_PROJECT_NOT_CONFIGURED` when the project is not linked to EAS, and `EAS_LOGIN_REQUIRED` when a non-interactive run is not logged in.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Print `{"error": {"message", "requestId"?}}` on stdout when a command run with `--json` fails, and keep all other output off stdout from the start of the command.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show account roles and credential permission limits in `eas login` when you are already logged in.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Identify the build or application archive when a download fails, and explain when the archive is no longer available.
+6
View File
@@ -0,0 +1,6 @@
---
'@expo/build-tools': patch
'@expo/steps': patch
---
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
+6
View File
@@ -0,0 +1,6 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Run apsd setup in the background without delaying iOS app launch or preview readiness.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Fix simulator control action help routing and show help when a flag is incomplete.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Handle a device session stopped during startup as normal cleanup instead of reporting a startup error.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
+6
View File
@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': major
'@expo/build-tools': major
---
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/eas-build-job': patch
---
Add types and validation for documented GitHub workflow context fields.
+1
View File
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
-32
View File
@@ -2,38 +2,6 @@
This is the log of notable changes to EAS CLI and related packages.
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "26.0.0",
"PRODUCTION": "26.0.0"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "26.0.0",
"version": "25.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-19
View File
@@ -1,24 +1,5 @@
# @expo/build-tools
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "26.0.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "26.0.0",
"@expo/eas-build-job": "26.0.0",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "26.0.0",
"@expo/logger": "25.3.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "26.0.0",
"@expo/template-file": "26.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -98,7 +98,7 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
@@ -162,59 +162,6 @@ afterEach(() => {
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
@@ -176,16 +176,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
@@ -103,18 +103,6 @@ async function runAsync(
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
beforeEach(async () => {
jest.clearAllMocks();
@@ -159,7 +147,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -1,7 +1,6 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
@@ -203,78 +202,6 @@ describe(installAppiumAsync, () => {
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, true],
[BuildRuntimePlatform.LINUX, false],
])('starts Appium on %s with helper present=%s', async (runtimePlatform, helperPresent) => {
const helper = '/usr/local/libexec/turtle-tcc-fda';
if (helperPresent) {
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, 'prepared helper');
}
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
// Stop at the launch boundary; no readiness requests or remote session are needed.
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const env = {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
PATH: '/configured/node/bin:/usr/bin',
DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
};
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { ...env, NGROK_AUTHTOKEN: 'token' },
} as never
)
).rejects.toBe(launched);
expect(spawnDetached).toHaveBeenCalledTimes(1);
const launch = jest.mocked(spawnDetached).mock.calls[0][0];
const appiumBinPath = path.join(launch.env.APPIUM_HOME!, 'node_modules', '.bin', 'appium');
const useHelper = runtimePlatform === BuildRuntimePlatform.DARWIN && helperPresent;
expect(launch).toEqual({
command: useHelper ? helper : appiumBinPath,
args: [
...(useHelper ? [appiumBinPath] : []),
'--address',
'127.0.0.1',
'--port',
'4723',
'--base-path',
'/',
'--log-level',
'error',
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
runtimePlatform === BuildRuntimePlatform.DARWIN
? '{"appium:eventTimings":true,"appium:isHeadless":true}'
: '{"appium:eventTimings":true}',
],
env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME },
logger,
});
});
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
@@ -434,7 +361,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
@@ -521,7 +447,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
@@ -128,7 +128,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
@@ -84,52 +84,11 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -309,7 +268,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -312,22 +312,8 @@ export async function downloadBuildAsync(
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
const archiveUrl = new URL(downloadUrl);
const source = params.buildId
? `application archive for build ${params.buildId}`
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
if (response.status === 404 || response.status === 410) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
`Failed to download the ${source} (HTTP ${response.status}). ` +
'The archive is unavailable. ' +
(params.buildId
? 'Check that the archive still exists and try again.'
: 'Check that the URL is correct and the archive still exists, then try again.')
);
}
throw new Error(
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
`Unexpected response from server (${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -239,9 +239,6 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
@@ -65,7 +65,6 @@ const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
const AppiumInstalledDriversSchema = z.record(
z.string(),
@@ -226,12 +225,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
command: appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
@@ -246,13 +242,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
logger,
@@ -288,7 +278,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
authtoken: ngrokAuthtoken,
});
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -306,9 +296,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -360,7 +360,7 @@ export function createStartArgentRemoteSessionBuildFunction(
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -374,9 +374,6 @@ export function createStartArgentRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1,4 +1,3 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -58,11 +57,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -74,20 +68,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
@@ -146,7 +129,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -164,9 +147,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -174,14 +154,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
@@ -170,53 +170,6 @@ describe('with a device run session token', () => {
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('handles the structured stopped code without matching the message', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
],
},
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).resolves.toBe('stopped');
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
});
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).rejects.toBeInstanceOf(SystemError);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
@@ -411,9 +364,7 @@ describe('with a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
).rejects.toThrow('Unexpected response from server (500): stop here');
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -638,9 +589,7 @@ describe('without a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
).rejects.toThrow('Unexpected response from server (500)');
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -1413,7 +1413,7 @@ it('does not read the iOS egress handoff for an Android host', async () => {
});
it.each([true, false])(
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
'waits for setup before launch or boot-only readiness (app: %s)',
async hasApp => {
const setup = deferred<void>();
const prepared = deferred<ServeSimApplicationOptions | void>();
@@ -1468,78 +1468,70 @@ it.each([true, false])(
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
: undefined
);
if (hasApp) {
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
if (!hasApp) {
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
} else {
expect(reported).toBe(false);
expect(runServeSimActionAsync).not.toHaveBeenCalled();
setup.resolve();
const host = await starting;
expect(runServeSimActionAsync).not.toHaveBeenCalled();
await host.finishAsync();
return;
}
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(1);
setup.resolve();
while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) {
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(
jest
.mocked(runServeSimActionAsync)
.mock.calls.map(([options]) => [options.action, options.params])
).toEqual(
hasApp
? [
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]
: []
).toEqual([
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]);
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
if (hasApp) {
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
}
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
try {
expect(reported).toBe(true);
const host = await starting;
let finished = false;
const finishing = host.finishAsync().then(() => {
finished = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
setup.resolve();
await finishing;
} finally {
setup.resolve();
await (await starting).finishAsync();
}
if (hasApp) {
await expect(access(directory)).rejects.toThrow();
}
const host = await starting;
await host.finishAsync();
await expect(access(directory)).rejects.toThrow();
}
);
@@ -1684,8 +1676,7 @@ it('warns and continues launching when disabling push fails', async () => {
await host.finishAsync();
});
it('cancels pending app preparation and observes late setup failure', async () => {
const application = deferred<ServeSimApplicationOptions>();
it('cancels pending setup without launching and observes its late failure', async () => {
const controller = new AbortController();
let rejectSetup!: (error: Error) => void;
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
@@ -1699,7 +1690,7 @@ it('cancels pending app preparation and observes late setup failure', async () =
logger,
timeoutMs: 2_000,
iosSimulatorUdid: 'emulator-5554',
application: application.promise,
launchAppIdentifier: 'dev.example.app',
signal: controller.signal,
});
const rejected = expect(starting).rejects.toThrow('session canceled');
@@ -91,8 +91,7 @@ export class DeviceRunSessionWorkerApiError extends Error {
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions,
readonly code?: string
options?: ErrorOptions
) {
super(message, options);
this.name = 'DeviceRunSessionWorkerApiError';
@@ -268,9 +267,7 @@ export class DeviceRunSessionWorkerApi {
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status,
undefined,
apiError?.success ? apiError.data.errors[0]?.code : undefined
response.status
);
}
if (!bodyResult.ok) {
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
}
let stagedAppDirectory: string | undefined;
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
@@ -670,7 +670,7 @@ async function startDeviceSessionHostInternalAsync(
onStopped: successful => onFinished(successful && !previewFailed),
})
.finally(async () => {
await simulatorSetup;
await beforeLaunch;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
@@ -711,8 +711,7 @@ async function startDeviceSessionHostInternalAsync(
);
}
signal?.throwIfAborted();
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
simulatorSetup = asyncResult(
beforeLaunch = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
@@ -750,6 +749,7 @@ async function startDeviceSessionHostInternalAsync(
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
@@ -31,17 +31,14 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<'started' | 'stopped'> {
}): Promise<void> {
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
const localEgress = await readLocalEgressHandoffAsync();
signal?.throwIfAborted();
const sessionStart = await uploadRemoteSessionConfigAsync({
await uploadRemoteSessionConfigAsync({
...options,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (sessionStart === 'stopped') {
return sessionStart;
}
if (localEgress && !signal?.aborted) {
// Guard refusals from here on show up under this step in the job log,
// alongside the monitor's reports, instead of under the boot step.
@@ -60,5 +57,4 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
});
}
return sessionStart;
}
@@ -31,7 +31,6 @@ import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
type DeviceRunSessionStatus,
DeviceRunSessionWorkerApiError,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
@@ -555,7 +554,7 @@ export async function uploadRemoteSessionConfigAsync({
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<'started' | 'stopped'> {
}): Promise<void> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
@@ -565,16 +564,7 @@ export async function uploadRemoteSessionConfigAsync({
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
return 'started';
} catch (err) {
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
) {
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
return 'stopped';
}
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
@@ -1,9 +1,5 @@
# create-eas-build-function
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "26.0.0",
"version": "25.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "26.0.0"
"@expo/steps": "25.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
-4
View File
@@ -1,9 +1,5 @@
# @expo/downloader
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "26.0.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,9 +1,5 @@
# eas-build-cache-provider
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "26.0.0",
"version": "25.3.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
-10
View File
@@ -1,15 +1,5 @@
# @expo/eas-build-job
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "26.0.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "26.0.0",
"@expo/logger": "25.3.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/turtle-spawn": "25.3.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -871,7 +871,7 @@ describe('StaticWorkflowInterpolationContextZ', () => {
});
describe('GitHub context event payload passthrough', () => {
it('accepts arbitrary fields at all event levels', () => {
it('preserves webhook fields and validates documented pull request details', () => {
const context = {
after: {},
needs: {},
@@ -895,7 +895,19 @@ describe('GitHub context event payload passthrough', () => {
},
pull_request: {
number: 123,
title: 'Fix the build',
body: null,
state: 'open',
draft: true,
merged: false,
html_url: 'https://github.com/expo/expo/pull/123',
user: { login: 'octocat', id: 1 },
labels: [{ name: 'bug', color: 'red' }],
head: { ref: 'fix-build', sha: 'abc123', repo: { name: 'expo' } },
base: { ref: 'main', sha: 'def456' },
created_at: '2026-10-01T00:00:00Z',
updated_at: '2026-10-02T00:00:00Z',
merged_at: null,
},
number: 123,
schedule: '0 0 * * *',
@@ -921,6 +933,34 @@ describe('GitHub context event payload passthrough', () => {
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
expect(parsed.github?.event).toMatchObject(context.github.event);
expect(() =>
StaticWorkflowInterpolationContextZ.parse({
...context,
github: {
...context.github,
event: {
...context.github.event,
pull_request: { ...context.github.event.pull_request, title: 123 },
},
},
})
).toThrow();
});
it.each([
{ number: 123 },
{ number: 123, body: null, user: null, merged: null, merged_at: null },
])('accepts partial pull request payloads: %j', pullRequest => {
const github = {
event_name: 'pull_request',
sha: 'abc123',
ref: 'refs/heads/main',
ref_name: 'main',
ref_type: 'branch',
event: { pull_request: pullRequest },
};
expect(StaticWorkflowInterpolationContextZ.shape.github.parse(github)).toEqual(github);
});
});
+14
View File
@@ -207,6 +207,7 @@ const GitHubContextZ = z.object({
.record(z.string(), z.unknown())
.and(
z.object({
action: z.string().optional(),
label: z
.looseObject({
name: z.string(),
@@ -221,6 +222,19 @@ const GitHubContextZ = z.object({
pull_request: z
.looseObject({
number: z.number(),
title: z.string().optional(),
body: z.string().nullable().optional(),
state: z.enum(['open', 'closed']).optional(),
draft: z.boolean().optional(),
merged: z.boolean().nullable().optional(),
html_url: z.string().optional(),
user: z.looseObject({ login: z.string() }).nullable().optional(),
labels: z.array(z.looseObject({ name: z.string() })).optional(),
head: z.looseObject({ ref: z.string(), sha: z.string() }).optional(),
base: z.looseObject({ ref: z.string(), sha: z.string() }).optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
merged_at: z.string().nullable().optional(),
})
.optional(),
number: z.number().optional(),
-10
View File
@@ -1,15 +1,5 @@
# eas-cli
## 26.0.0
### Patch Changes
- Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
### Minor Changes
+198 -198
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "26.0.0",
"version": "25.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "26.0.0",
"@expo/eas-json": "26.0.0",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "26.0.0",
"@expo/logger": "25.3.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "26.0.0",
"@expo/steps": "25.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -1,83 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createRequire } = require('node:module');
const root = fs.realpathSync(process.argv[2]);
const cli = path.join(root, 'packages/eas-cli');
const cliRequire = createRequire(path.join(cli, 'package.json'));
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
const { Config } = cliRequire('@oclif/core');
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
const EasHelp = require(path.join(cli, 'src/help')).default;
const cases = JSON.parse(process.argv[3]);
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
const events = [];
globalThis.__helpLifecycleEvents = events;
// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
EasCommand.prototype.run = async function () {
events.push({ event: 'run', id: this.id });
};
EasCommand.prototype.finally = async function () {};
(async () => {
try {
fs.writeFileSync(
path.join(directory, 'package.json'),
JSON.stringify({
name: 'eas-help-lifecycle-fixture',
version: '1.0.0',
oclif: {
bin: 'eas',
commands: { strategy: 'explicit', target: './commands.cjs' },
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
},
})
);
fs.writeFileSync(
path.join(directory, 'commands.cjs'),
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
);
for (const event of ['prerun', 'postrun']) {
fs.writeFileSync(
path.join(directory, `${event}.cjs`),
`module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };`
);
}
process.env.XDG_DATA_HOME = directory;
process.env.XDG_CACHE_HOME = directory;
const config = await Config.load({ root: directory });
const results = [];
for (const args of cases) {
// Published manifests discover metadata without loading the command class.
// Clear eager discovery's assignment to exercise that cold-start boundary.
delete Control.id;
const start = events.length;
const write = process.stdout.write;
let stdout = '',
error;
process.stdout.write = (chunk, encoding, callback) => {
stdout += String(chunk);
const done = typeof encoding === 'function' ? encoding : callback;
done?.();
return true;
};
try {
await new EasHelp(config).showHelp(args);
} catch (err) {
error = err.message;
} finally {
process.stdout.write = write;
}
results.push({ stdout, events: events.slice(start), error });
}
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
})().catch(err => {
console.error(err);
process.exitCode = 1;
});
@@ -1,51 +0,0 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const root = path.resolve(__dirname, '../../../..');
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
const actionHelp = [
['simulator:control', 'rotate', '--help'],
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
];
const incompleteFlags = [
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
];
let results: { stdout: string; events: unknown[]; error?: string }[];
beforeAll(async () => {
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
const { stdout } = await execFileAsync(
process.execPath,
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
{ timeout: 20_000 }
);
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
}, 30_000);
describe('simulator action help through real oclif lookup and hooks', () => {
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
'keeps canonical command identity and executes lifecycle hooks: $argv',
({ index }) => {
expect(results[index].error).toBeUndefined();
expect(results[index].events).toEqual([
{ event: 'prerun', id: 'simulator:control' },
{ event: 'run', id: 'simulator:control' },
{ event: 'postrun', id: 'simulator:control' },
]);
}
);
it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
'shows ordinary help without starting the command for an incomplete flag: $argv',
({ index }) => {
const { stdout, events, error } = results[index + actionHelp.length];
expect(error).toBeUndefined();
expect(stdout).toContain('USAGE');
expect(events).toEqual([]);
}
);
});
+3 -4
View File
@@ -1,5 +1,6 @@
import { Config, Help } from '@oclif/core';
import SimulatorControl from '../commands/simulator/control';
import EasHelp from '../help';
describe(EasHelp, () => {
@@ -8,7 +9,7 @@ describe(EasHelp, () => {
let showHelp: jest.SpyInstance;
beforeEach(() => {
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
});
@@ -20,14 +21,12 @@ describe(EasHelp, () => {
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
])('routes action help through the command: %j', async (...argv) => {
await new EasHelp(config).showHelp(argv);
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
expect(showHelp).not.toHaveBeenCalled();
});
it.each([
['simulator:control', '--help'],
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
['sim:control', '-h'],
['build', '--help'],
['simulator:control', 'clipboard.write', '--', '--help'],
@@ -1,6 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
@@ -22,7 +21,6 @@ import {
EasBuildFreeTierLimitExceededError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
EasBuildUnauthorizedError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
@@ -760,38 +758,6 @@ describe(Build.name, () => {
});
});
});
describe('for UNAUTHORIZED', () => {
it('points to the permission grant and permissions.account.role in eas.json', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError(
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role',
'UNAUTHORIZED'
);
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildUnauthorizedError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildUnauthorizedError,
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role\n' +
`Request ID: ${mockRequestId}\n\n` +
`Run ${chalk.bold(
'eas whoami'
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
'eas login'
)} to change the user or permission grant.\n\n` +
`If your build profile in eas.json uses ${chalk.bold(
'permissions.account.role'
)}, it may mean your credentials do not grant the requested role.`
);
});
});
});
describe('error is unexpected graphQL-related error', () => {
-14
View File
@@ -25,7 +25,6 @@ import {
EasBuildProjectArchiveUploadError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
EasBuildUnauthorizedError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
@@ -48,7 +47,6 @@ import {
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { resolveJobExpoTokenRole } from './utils/expoTokenRole';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
LocalFile,
@@ -132,7 +130,6 @@ function resolveBuildParamsInput<T extends Platform>(
resourceClass: ctx.resourceClass,
sdkVersion: metadata.sdkVersion,
reactNativeVersion: metadata.reactNativeVersion,
expoTokenRole: resolveJobExpoTokenRole(ctx.buildProfile),
};
}
@@ -303,17 +300,6 @@ export async function handleBuildRequestErrorAsync(
throw new EasBuildTooManyPendingBuildsError(
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
} else if (graphQLErrorCode === 'UNAUTHORIZED') {
const requestId = graphQLErrors[0]?.extensions?.requestId;
throw new EasBuildUnauthorizedError(
`${graphQLErrors[0]?.message}${requestId ? `\nRequest ID: ${requestId}` : ''}\n\nRun ${chalk.bold(
'eas whoami'
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
'eas login'
)} to change the user or permission grant.\n\nIf your build profile in eas.json uses ${chalk.bold(
'permissions.account.role'
)}, it may mean your credentials do not grant the requested role.`
);
} else if (Array.isArray(error?.graphQLErrors)) {
const errorDetails = formatBuildRequestErrorDetails(error, graphQLErrors);
throw new Error(
-2
View File
@@ -22,6 +22,4 @@ export class EasBuildDownForMaintenanceError extends EasCommandError {}
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
export class EasBuildUnauthorizedError extends EasCommandError {}
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
@@ -1,33 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import {
BuildProfile,
CredentialsSource,
JobExpoTokenRole as EasJsonJobExpoTokenRole,
} from '@expo/eas-json';
import { JobExpoTokenRole } from '../../../graphql/generated';
import { resolveJobExpoTokenRole } from '../expoTokenRole';
function createBuildProfile(
permissions?: BuildProfile<Platform.ANDROID>['permissions']
): BuildProfile<Platform.ANDROID> {
return {
credentialsSource: CredentialsSource.REMOTE,
distribution: 'store',
permissions,
};
}
describe(resolveJobExpoTokenRole, () => {
it('returns undefined when the build profile does not request a role', () => {
expect(resolveJobExpoTokenRole(createBuildProfile())).toBeUndefined();
});
it.each([
[EasJsonJobExpoTokenRole.RUNNER, JobExpoTokenRole.Runner],
[EasJsonJobExpoTokenRole.DEVELOPER, JobExpoTokenRole.Developer],
[EasJsonJobExpoTokenRole.RELEASE_MANAGER, JobExpoTokenRole.ReleaseManager],
])('maps the %s role to %s', (role, expected) => {
expect(resolveJobExpoTokenRole(createBuildProfile({ account: { role } }))).toBe(expected);
});
});
@@ -1,20 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, JobExpoTokenRole as EasJsonJobExpoTokenRole } from '@expo/eas-json';
import { JobExpoTokenRole } from '../../graphql/generated';
const easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping: Record<
EasJsonJobExpoTokenRole,
JobExpoTokenRole
> = {
[EasJsonJobExpoTokenRole.RUNNER]: JobExpoTokenRole.Runner,
[EasJsonJobExpoTokenRole.DEVELOPER]: JobExpoTokenRole.Developer,
[EasJsonJobExpoTokenRole.RELEASE_MANAGER]: JobExpoTokenRole.ReleaseManager,
};
export function resolveJobExpoTokenRole<T extends Platform>(
profile: BuildProfile<T>
): JobExpoTokenRole | undefined {
const role = profile.permissions?.account.role;
return role ? easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping[role] : undefined;
}
@@ -1,4 +1,4 @@
import { Command, Errors } from '@oclif/core';
import { Command } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { GraphQLError } from 'graphql/error';
@@ -31,7 +31,6 @@ import Log, { link } from '../log';
import Sentry from '../sentry';
import SessionManager from '../user/SessionManager';
import { getActorDisplayName } from '../user/User';
import { enableJsonOutput, printJsonErrorOutput } from '../utils/json';
import { Client } from '../vcs/vcs';
export type ContextInput<
@@ -258,24 +257,9 @@ export default abstract class EasCommand extends Command {
action: `eas ${this.id}`,
});
// Before the context is resolved, so its messages and errors stay off stdout too.
if (this.jsonOutputRequested()) {
enableJsonOutput();
}
return await this.runAsync();
}
/** Mirrors oclif's `jsonEnabled`, for commands that declare their own `json` flag. */
private jsonOutputRequested(): boolean {
if (!this.ctor.flags?.json) {
return false;
}
const jsonIndex = this.argv.indexOf('--json');
const passThroughIndex = this.argv.indexOf('--');
return jsonIndex !== -1 && (passThroughIndex === -1 || jsonIndex < passThroughIndex);
}
// eslint-disable-next-line async-protect/async-suffix
override async finally(err: Error): Promise<any> {
await this.analytics.flushAsync();
@@ -286,19 +270,10 @@ export default abstract class EasCommand extends Command {
protected override catch(err: Error): Promise<any> {
const commandId = this.id ?? 'unknown';
let baseMessage = `${commandId} command failed.`;
let message = err.message;
let code: string | undefined;
let requestId: string | undefined;
if (err instanceof Errors.ExitError) {
// Errors.exit(1) carries no reason; the code that called it printed the reason already.
Log.errorToStderr(err.message);
message = baseMessage;
} else if (err instanceof EasCommandError || err instanceof Errors.CLIError) {
if (!(err instanceof EasCommandError && err.messageAlreadyPrinted)) {
Log.errorToStderr(err.message);
if (err instanceof EasCommandError) {
if (!err.messageAlreadyPrinted) {
Log.error(err.message);
}
// Only codes that eas-cli sets. GraphQL error codes belong to the server and stay out.
code = err.code;
} else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanGQLErrorsMessage = err?.graphQLErrors
.map((graphQLError: GraphQLError) => {
@@ -346,15 +321,10 @@ export default abstract class EasCommand extends Command {
const cleanMessage = err.networkError
? `${cleanGQLErrorsMessage}\n${err.networkError.message}`
: cleanGQLErrorsMessage;
Log.errorToStderr(cleanMessage);
Log.error(cleanMessage);
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
message = cleanMessage;
requestId = err.graphQLErrors[0]?.extensions?.requestId as string | undefined;
} else {
Log.errorToStderr(err.message);
}
if (this.jsonOutputRequested()) {
printJsonErrorOutput({ message, code, requestId });
Log.error(err.message);
}
Log.debug(err);
Sentry.withScope(scope => {
@@ -24,7 +24,6 @@ jest.mock('../../analytics/AnalyticsManager', () => {
};
});
jest.mock('../../log');
jest.mock('../../utils/json');
const mockRequestId = uuidv4();
@@ -201,7 +200,7 @@ describe('EasCommand', () => {
it('logs the message', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
@@ -219,7 +218,7 @@ describe('EasCommand', () => {
it('skips the message when the command already printed it', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const { EasCommandError } = require('../errors');
const Sentry = jest.requireMock('../../sentry').default;
@@ -243,7 +242,7 @@ describe('EasCommand', () => {
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -263,7 +262,7 @@ describe('EasCommand', () => {
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -298,7 +297,7 @@ describe('EasCommand', () => {
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -336,146 +335,6 @@ describe('EasCommand', () => {
expect(logDebugSpy).toBeCalledWith(error);
});
describe('with --json', () => {
const createJsonTestEasCommand = (): any => {
const TestEasCommand = createTestEasCommand();
const { Flags } = require('@oclif/core');
TestEasCommand.flags = { json: Flags.boolean() };
return TestEasCommand;
};
it('enables JSON output before the command runs', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { enableJsonOutput } = jest.requireMock('../../utils/json');
let enabledBeforeRun = false;
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(async () => {
enabledBeforeRun = enableJsonOutput.mock.calls.length > 0;
});
await TestEasCommand.run(['--json'], mockConfig);
expect(enabledBeforeRun).toBe(true);
});
it('prints the error message', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { EasCommandError } = require('../errors');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new EasCommandError('EAS project not configured.');
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith({
message: 'EAS project not configured.',
code: undefined,
requestId: undefined,
});
});
it('prints the code of an EasCommandError', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { EasCommandError } = require('../errors');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new EasCommandError('EAS project not configured.', {
code: 'EAS_PROJECT_NOT_CONFIGURED',
});
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith(
expect.objectContaining({ code: 'EAS_PROJECT_NOT_CONFIGURED' })
);
});
it('prints the code of an oclif error', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { Errors } = require('@oclif/core');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
Errors.error('Log in first.', { code: 'EAS_LOGIN_REQUIRED' });
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Log in first.', code: 'EAS_LOGIN_REQUIRED' })
);
});
it('prints the command failure, not EEXIT, for Errors.exit', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { Errors } = require('@oclif/core');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
Errors.exit(1);
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith({
message: `${TestEasCommand.id} command failed.`,
code: undefined,
requestId: undefined,
});
});
it('prints the GraphQL request ID, not the server error code', async () => {
const TestEasCommand = createJsonTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new CombinedError({
graphQLErrors: [
new GraphQLError('Entity not authorized', null, null, null, null, null, {
errorCode: 'UNAUTHORIZED_ERROR',
requestId: mockRequestId,
}),
],
});
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).toHaveBeenCalledWith(
expect.objectContaining({ requestId: mockRequestId })
);
expect(printJsonErrorOutput.mock.calls[0][0].code).toBeUndefined();
});
it.each([
['without --json', []],
['with --json after --', ['--', '--json']],
])('prints no JSON error %s', async (_, argv) => {
const TestEasCommand = createJsonTestEasCommand();
const { enableJsonOutput, printJsonErrorOutput } = jest.requireMock('../../utils/json');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new Error('foo');
});
await expect(TestEasCommand.run(argv, mockConfig)).rejects.toThrow();
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonErrorOutput).not.toHaveBeenCalled();
});
it('prints no JSON error when the command has no json flag', async () => {
const TestEasCommand = createTestEasCommand();
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
throw new Error('foo');
});
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
expect(printJsonErrorOutput).not.toHaveBeenCalled();
});
});
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
@@ -11,16 +11,8 @@ jest.mock('fs');
jest.mock('../../../../prompts');
const originalStderrIsTTY = process.stderr.isTTY;
beforeEach(() => {
jest.resetAllMocks();
// The dependency notice prints once per process and only to a terminal, so keep it unspent.
process.stderr.isTTY = false;
});
afterAll(() => {
process.stderr.isTTY = originalStderrIsTTY;
});
describe(findProjectRootAsync, () => {
@@ -123,32 +115,3 @@ describe(findProjectDirAndVerifyProjectSetupAsync, () => {
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
});
describe('eas-cli dependency notice', () => {
beforeEach(() => {
vol.reset();
vol.fromJSON(
{
'./eas.json': JSON.stringify({}),
'./package.json': JSON.stringify({ devDependencies: { 'eas-cli': '*' } }),
},
'/app'
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('prints the notice only when stderr is a terminal', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
process.stderr.isTTY = false;
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
expect(warn).not.toHaveBeenCalled();
process.stderr.isTTY = true;
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('in your project dependencies'));
});
});
@@ -513,16 +513,6 @@ describe(getProjectIdAsync, () => {
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
it('gives the non-interactive error the EAS_PROJECT_NOT_CONFIGURED code', async () => {
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).rejects.toMatchObject({ code: 'EAS_PROJECT_NOT_CONFIGURED' });
});
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
@@ -127,8 +127,7 @@ export async function findProjectDirAndVerifyProjectSetupAsync({
} = {}): Promise<string> {
const projectDir = await findProjectRootAsync({ cwd });
await applyCliConfigAsync(projectDir);
// The notice is advice for a person. Programs that run eas-cli read stderr for its errors.
if (!ranEnsureEasCliIsNotInDependencies && process.stderr.isTTY) {
if (!ranEnsureEasCliIsNotInDependencies) {
ranEnsureEasCliIsNotInDependencies = true;
await ensureEasCliIsNotInDependenciesAsync(projectDir);
}
+1 -4
View File
@@ -1,15 +1,12 @@
export class EasCommandError extends Error {
/** True when the command already printed the message, so `EasCommand.catch` must not print it again. */
public readonly messageAlreadyPrinted: boolean;
/** A stable identifier, printed in the `--json` error output so programs need not match the message. */
public readonly code?: string;
constructor(
message: string,
{ messageAlreadyPrinted = false, code }: { messageAlreadyPrinted?: boolean; code?: string } = {}
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
) {
super(message);
this.messageAlreadyPrinted = messageAlreadyPrinted;
this.code = code;
}
}
+2 -3
View File
@@ -8,7 +8,7 @@ export function isNonInteractiveByDefault(): boolean {
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description:
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}. Implies --non-interactive.',
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
@@ -87,8 +87,7 @@ export const EASNonInteractiveFlag = {
export const EasJsonOnlyFlag = {
json: Flags.boolean({
description:
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}.',
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
}),
};
@@ -1,12 +1,7 @@
import chalk from 'chalk';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { getMockViewerUserPermission } from '../../../__tests__/viewerUserPermission';
import { Permission, Role } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor } from '../../../user/User';
import { resumeDeviceLoginAsync, startDeviceLoginAsync } from '../../../user/deviceLogin';
import AccountLogin from '../login';
@@ -38,12 +33,12 @@ const sessionManager = new SessionManager({
const originalIsTTY = process.stdin.isTTY;
const originalCI = process.env.CI;
function command(args: string[], actor: Actor | null = null): AccountLogin {
function command(args: string[]): AccountLogin {
const instance = new AccountLogin(['--device', ...args], getMockOclifConfig());
// @ts-expect-error Mock the protected context without initializing a real session.
jest.spyOn(instance, 'getContextAsync').mockResolvedValue({
sessionManager,
maybeLoggedIn: { actor },
maybeLoggedIn: { actor: null },
});
return instance;
}
@@ -184,48 +179,3 @@ it('requires device login for non-interactive sign-in', async () => {
'Use eas login --device --non-interactive to log in without prompts.'
);
});
describe('when already logged in', () => {
const ownerPermissions = getMockViewerUserPermission(Role.Owner).permissions;
const limitedMessage =
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.';
function userActor(
effectivePermissions: Actor['accounts'][number]['viewerUserPermission2']['effectivePermissions']
): Actor {
return {
__typename: 'User',
id: 'user-id',
email: '[email protected]',
username: 'jane',
accounts: [
{
id: 'jane-id',
name: 'jane',
viewerUserPermission2: { permissions: ownerPermissions, effectivePermissions },
},
],
isExpoAdmin: false,
featureGates: {},
};
}
beforeEach(() => {
jest.mocked(startDeviceLoginAsync).mockResolvedValue(pending);
});
it('shows the roles that the current credentials limit', async () => {
await command(['--non-interactive'], userActor([Permission.View])).runAsync();
expect(Log.warn).toHaveBeenCalledWith(`You are already logged in as ${chalk.bold('jane')}.`);
expect(Log.log).toHaveBeenCalledWith(
`• jane (Role: Owner, ${chalk.yellow('limited to Viewer by the current credentials')})`
);
expect(Log.log).toHaveBeenCalledWith(limitedMessage);
});
it('does not list accounts when the credentials do not limit them', async () => {
await command(['--non-interactive'], userActor(ownerPermissions)).runAsync();
expect(Log.log).not.toHaveBeenCalledWith('Accounts:');
expect(Log.log).not.toHaveBeenCalledWith(limitedMessage);
});
});
@@ -7,7 +7,6 @@ import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { getActorDisplayName } from '../../user/User';
import { logAccountPermissions } from '../../user/accountPermissions';
import {
isDeviceLoginFailure,
resumeDeviceLoginAsync,
@@ -78,13 +77,6 @@ export default class AccountLogin extends EasCommand {
if (actor && !resume) {
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
const { hasLimitedAccount } = logAccountPermissions(actor);
if (hasLimitedAccount) {
Log.newLine();
Log.log(
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.'
);
}
const shouldContinue =
nonInteractive ||
+87 -9
View File
@@ -1,9 +1,10 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { Permission, Role } from '../../graphql/generated';
import Log from '../../log';
import { getActorDisplayName } from '../../user/User';
import { logAccountPermissions } from '../../user/accountPermissions';
import { Actor, getActorDisplayName } from '../../user/User';
import { getRoleForPermissions } from '../../user/roles';
export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
@@ -27,18 +28,95 @@ export default class AccountView extends EasCommand {
Log.log(actor.email);
}
const { hasLimitedAccount } = logAccountPermissions(actor);
if (hasLimitedAccount) {
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more, or if the credentials used for
// this command limit the permissions on it
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
const hasLimitedAccount = actor.accounts.some(AccountView.isLimitedByCredentials);
if (accountExcludingPersonalAccount.length > 0 || hasLimitedAccount) {
Log.newLine();
Log.log(
`The permissions granted during authentication limit your access to some accounts. Run ${chalk.bold(
'eas login'
)} to change the permission grant.`
);
Log.log('Accounts:');
actor.accounts.forEach(account => {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
const roleLabel = AccountView.getLabelForPermissions(permissions);
if (AccountView.isLimitedByCredentials(account)) {
const effectiveRoleLabel = AccountView.getLabelForPermissions(effectivePermissions);
Log.log(
`• ${account.name} (Role: ${roleLabel}, ${chalk.yellow(
`limited to ${effectiveRoleLabel} by the current credentials`
)})`
);
} else {
Log.log(`• ${account.name} (Role: ${roleLabel})`);
}
});
if (hasLimitedAccount) {
Log.newLine();
Log.log(
`The permissions granted during authentication limit your access to some accounts. Run ${chalk.bold(
'eas login'
)} to change the permission grant.`
);
}
}
} else {
Log.warn('Not logged in');
process.exit(1);
}
}
/**
* Whether the scopes of the credential used for this command remove any of the permissions that
* the viewer's role grants on the account.
*/
private static isLimitedByCredentials(account: Actor['accounts'][0]): boolean {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
return permissions.some(permission => !effectivePermissions.includes(permission));
}
private static getLabelForPermissions(permissions: readonly Permission[]): string {
const role = getRoleForPermissions(permissions);
if (role !== Role.Custom) {
return AccountView.getLabelForRole(role);
}
if (permissions.length === 0) {
return 'No permissions';
}
return `Custom: ${permissions
.map(permission => permission.toLowerCase().replaceAll('_', ' '))
.join(', ')}`;
}
private static getLabelForRole(role: Role): string {
switch (role) {
case Role.Owner:
return 'Owner';
case Role.Admin:
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ReleaseManager:
return 'Release Manager';
case Role.Runner:
return 'Runner';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
// A role added to the API after this version of the CLI was published. TypeScript narrows
// `role` to `never` here, but the server can still send one, so render its name rather than
// returning undefined.
default: {
const unknownRole: string = role;
return unknownRole
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
}
}
}
+6 -12
View File
@@ -6,19 +6,13 @@ export default class EasHelp extends Help {
const [command, ...args] = argv;
if (command === 'simulator:control' || command === 'sim:control') {
const { default: SimulatorControl } = await import('./commands/simulator/control');
let parsed;
try {
parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
} catch {
await super.showHelp(argv);
return;
}
const parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
if (parsed.args.action && parsed.flags.help) {
await this.config.runCommand('simulator:control', args);
await SimulatorControl.run(args, this.config);
return;
}
}
-6
View File
@@ -39,12 +39,6 @@ export default class Log {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
public static errorToStderr(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.error(...Log.withTextColor(args, chalk.stderr.red));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
@@ -1,4 +1,3 @@
import { EasCommandError } from '../commandUtils/errors';
import { Actor, getCreatableAccountNames } from '../user/User';
/**
@@ -21,8 +20,8 @@ export function getUnconfiguredProjectError({
accountName?: string;
reason?: string;
additionalFix?: string;
}): EasCommandError {
return new EasCommandError(
}): Error {
return new Error(
`EAS project not configured. ${reason} ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
@@ -30,7 +29,6 @@ export function getUnconfiguredProjectError({
`To create a new project:\n\n` +
` eas init --account ${accountName ?? '<account-name>'} --non-interactive\n\n` +
`Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` +
(additionalFix ? `\n\n${additionalFix}` : ''),
{ code: 'EAS_PROJECT_NOT_CONFIGURED' }
(additionalFix ? `\n\n${additionalFix}` : '')
);
}
+1 -2
View File
@@ -185,8 +185,7 @@ export default class SessionManager {
)} environment variable if you're using EAS CLI on CI (${learnMore(
'https://docs.expo.dev/accounts/programmatic-access/',
{ dim: false }
)})`,
{ code: 'EAS_LOGIN_REQUIRED' }
)})`
);
}
if (printNewLine) {
@@ -313,15 +313,6 @@ describe(SessionManager, () => {
});
describe('showLoginPromptAsync', () => {
it('fails with the EAS_LOGIN_REQUIRED code in non-interactive mode', async () => {
const sessionManager = new SessionManager(analytics);
await expect(
sessionManager.showLoginPromptAsync({ nonInteractive: true })
).rejects.toMatchObject({ code: 'EAS_LOGIN_REQUIRED' });
expect(promptAsync).not.toHaveBeenCalled();
});
it('prompts for OTP when 2FA is enabled', async () => {
jest
.mocked(promptAsync)
@@ -1,97 +0,0 @@
import chalk from 'chalk';
import { Permission, Role } from '../graphql/generated';
import Log from '../log';
import { Actor } from './User';
import { getRoleForPermissions } from './roles';
/**
* Logs the actor's role on each account, and whether the credentials used for this command limit
* it. The list is skipped when it only contains the personal account and the credentials do not
* limit it.
*
* @returns whether the credentials limit the permissions on any account
*/
export function logAccountPermissions(actor: Actor): { hasLimitedAccount: boolean } {
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more, or if the credentials used for
// this command limit the permissions on it
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
const hasLimitedAccount = actor.accounts.some(isLimitedByCredentials);
if (accountExcludingPersonalAccount.length === 0 && !hasLimitedAccount) {
return { hasLimitedAccount };
}
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
const roleLabel = getLabelForPermissions(permissions);
if (isLimitedByCredentials(account)) {
const effectiveRoleLabel = getLabelForPermissions(effectivePermissions);
Log.log(
`• ${account.name} (Role: ${roleLabel}, ${chalk.yellow(
`limited to ${effectiveRoleLabel} by the current credentials`
)})`
);
} else {
Log.log(`• ${account.name} (Role: ${roleLabel})`);
}
});
return { hasLimitedAccount };
}
/**
* Whether the scopes of the credential used for this command remove any of the permissions that
* the viewer's role grants on the account.
*/
function isLimitedByCredentials(account: Actor['accounts'][0]): boolean {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
return permissions.some(permission => !effectivePermissions.includes(permission));
}
function getLabelForPermissions(permissions: readonly Permission[]): string {
const role = getRoleForPermissions(permissions);
if (role !== Role.Custom) {
return getLabelForRole(role);
}
if (permissions.length === 0) {
return 'No permissions';
}
return `Custom: ${permissions
.map(permission => permission.toLowerCase().replaceAll('_', ' '))
.join(', ')}`;
}
function getLabelForRole(role: Role): string {
switch (role) {
case Role.Owner:
return 'Owner';
case Role.Admin:
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ReleaseManager:
return 'Release Manager';
case Role.Runner:
return 'Runner';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
// A role added to the API after this version of the CLI was published. TypeScript narrows
// `role` to `never` here, but the server can still send one, so render its name rather than
// returning undefined.
default: {
const unknownRole: string = role;
return unknownRole
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
}
}
@@ -1,36 +0,0 @@
describe('printJsonErrorOutput', () => {
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
afterEach(() => {
jest.restoreAllMocks();
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
});
it('prints one error object on stdout, without ANSI codes, once per process', () => {
const stdout = jest.fn(() => true);
const stderr = jest.fn(() => true);
process.stdout.write = stdout as any;
process.stderr.write = stderr as any;
// Jest's console does not write through process.stdout, so record which stream was live.
const printed: { text: string; toStdout: boolean }[] = [];
jest.spyOn(console, 'log').mockImplementation((text: string) => {
printed.push({ text, toStdout: process.stdout.write === stdout });
});
jest.isolateModules(() => {
const { printJsonErrorOutput } = require('../json');
printJsonErrorOutput({
message: '\u001b[31mEAS project not configured.\u001b[39m',
});
printJsonErrorOutput({ message: 'update:republish command failed.' });
});
expect(printed).toHaveLength(1);
expect(printed[0].toStdout).toBe(true);
expect(JSON.parse(printed[0].text)).toEqual({
error: { message: 'EAS project not configured.' },
});
});
});
-25
View File
@@ -1,5 +1,4 @@
import assert from 'assert';
import { stripVTControlCharacters } from 'util';
import Log from '../log';
@@ -23,30 +22,6 @@ export function printJsonOnlyOutput(value: object): void {
}
}
export interface JsonErrorOutput {
message: string;
code?: string;
requestId?: string;
}
let jsonErrorPrinted = false;
/**
* Print `{ "error": { message, code?, requestId? } }` on stdout for a command that failed with --json.
*
* Once per process: `update:rollback` runs other commands in-process, and the innermost error is the one
* that says what went wrong.
*/
export function printJsonErrorOutput(error: JsonErrorOutput): void {
if (jsonErrorPrinted) {
return;
}
jsonErrorPrinted = true;
// The command may have failed before it could enable JSON output.
enableJsonOutput();
printJsonOnlyOutput({ error: { ...error, message: stripVTControlCharacters(error.message) } });
}
function sanitizeValue(value: any): unknown {
if (Array.isArray(value)) {
return value.map(val => sanitizeValue(val));
-6
View File
@@ -1,11 +1,5 @@
# @expo/eas-json
## 26.0.0
### Minor Changes
- Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
## 25.3.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "26.0.0",
"version": "25.3.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "26.0.0",
"@expo/eas-build-job": "25.3.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
@@ -17,7 +17,6 @@ module Pod
%r{https?://(api\.)?github\.com/},
%r{https?://sourceforge\.net/},
%r{https?://repo1\.maven\.org/},
%r{https?://central\.sonatype\.com/},
%r{https?://boostorg\.jfrog\.io/},
%r{https?://www\.sqlite\.org/},
%r{https?://download\.videolan\.org/},
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more