Compare commits

...
13 Commits
Author SHA1 Message Date
Expo CI 0658d58efd v23.1.0 2026-08-31 11:24:34 +00:00
Phil Pluckthun 76a700c45c Remove verify step (#4310) 2026-08-29 18:07:45 +01:00
Szymon Dziedzic 5ded193ccf [eas-cli] Fix v23.0.0 changelog (#4309) 2026-08-29 11:24:10 +02:00
Brent Vatne 93420d1fa0 [eas-cli] Add prebuild fallback regression tests (#4285)
* [eas-cli] Add prebuild fallback regression tests

* Update changelog for prebuild fallback tests
2026-08-28 15:45:29 -07:00
Brent Vatne add787f625 [local-build-plugin] Replace bunyan with @expo/bunyan (#4284)
* [local-build-plugin] Replace bunyan with @expo/bunyan

* Update changelog for @expo/bunyan
2026-08-28 15:45:28 -07:00
Brent Vatne ec2c2aca5e [steps] Replace lodash.get with a local helper (#4283)
* [steps] Replace lodash.get with a local helper

* Update changelog for lodash.get removal
2026-08-28 15:45:28 -07:00
Brent Vatne 1e86c48f48 [eas-cli][steps] Bump uuid to v11 (#4282)
* [eas-cli][steps] Bump uuid to v11

* Update changelog for uuid v11
2026-08-28 15:45:27 -07:00
Brent Vatne 545c2dbf9b [eas-cli] Bump minizlib to 3.1.0 (#4281) 2026-08-28 15:45:27 -07:00
Brent Vatne 3cb029147e [eas-cli] Prompt for Expo SDK version in eas new (#4301) 2026-08-28 12:05:38 -07:00
expo-tuft[bot]andabbanmustafa d48cfbba69 [build-tools] Normalize extracted project permissions (#4287)
* [build-tools] Normalize extracted project permissions

Co-authored-by: abbanmustafa <[email protected]>

* [build-tools] Add project permission fix changelog

Co-authored-by: abbanmustafa <[email protected]>

* [build-tools] Log project permission normalization

Co-authored-by: abbanmustafa <[email protected]>

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: abbanmustafa <[email protected]>
2026-08-28 12:51:33 -04:00
Robin Gullo 07b5c0daa3 [build-tools] Support pnpm install --filter to install scoped dependencies (#4302)
# Why

`EAS_YARN_FOCUS_WORKSPACE` ([expo/eas-build#598](https://github.com/expo/eas-build/pull/598)) and `EAS_BUN_FILTER_WORKSPACE` ([expo/eas-cli#4292](https://github.com/expo/eas-cli/pull/4292)) let monorepos install only the dependencies of the app being built, so `node_modules` stays slim, the install is faster, and the fingerprint matches what you get locally. pnpm can do the same thing with [`pnpm install --filter <workspace>`](https://pnpm.io/filtering), but there is no way to reach it from EAS today — the pnpm branch always installs every workspace.

# How

Set it in the build profile's `env` in `eas.json`:

```json
{
  "build": {
    "production": {
      "env": {
        "EAS_PNPM_FILTER_WORKSPACE": "my-app"
      }
    }
  }
}
```

The install step then runs `pnpm install --filter my-app`. This is the pnpm counterpart of `EAS_YARN_FOCUS_WORKSPACE`, introduced in expo/eas-build#598, and `EAS_BUN_FILTER_WORKSPACE`, introduced in expo/eas-cli#4292.

# Test Plan

Added two unit tests for the flag set/unset — `yarn jest-unit src/common/__tests__/installDependencies.test.ts --runInBand` in `packages/build-tools`:

```
Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
```

Also ran the resulting commands by hand with pnpm 10.32.1, in a workspace with `pkg-a` (depends on `is-odd`), `pkg-b` (depends on `lodash`) and an in-sync `pnpm-lock.yaml`:

| command | installed |
| --- | --- |
| `pnpm install --frozen-lockfile` | both `packages/a/node_modules/is-odd` and `packages/b/node_modules/lodash` |
| `pnpm install --filter pkg-a --frozen-lockfile` | only `packages/a/node_modules/is-odd`, `pnpm-lock.yaml` unchanged |
2026-08-28 18:32:10 +02:00
David Mokos b78c84af2c updates agent cli detector to 0.1.7 (#4272) 2026-08-28 17:31:20 +02:00
Phil Pluckthun dfaa2df90d [eas-cli] Add dynamic retries for deploy command (#4299) 2026-08-28 16:12:40 +01:00
40 changed files with 2213 additions and 574 deletions

No files matched your search

-14
View File
@@ -37,20 +37,6 @@ jobs:
run: yarn build
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Verify eas-cli was published as latest on npm
run: |
expected="$(jq -r .version lerna.json)"
for attempt in $(seq 1 10); do
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
if [ "$published" = "$expected" ]; then
echo "Verified eas-cli@$expected is latest on npm."
exit 0
fi
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
sleep 15
done
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
exit 1
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
+23
View File
@@ -6,6 +6,29 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
### 🐛 Bug fixes
- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [eas-cli] Add regression coverage for the bundled iOS entitlement introspection fallback. ([#4285](https://github.com/expo/eas-cli/pull/4285) by [@brentvatne](https://github.com/brentvatne))
- [local-build-plugin] Replace `bunyan` with `@expo/bunyan` while preserving the Steps formatter binary. ([#4284](https://github.com/expo/eas-cli/pull/4284) by [@brentvatne](https://github.com/brentvatne))
- [steps] Replace deprecated `lodash.get` with a local helper. ([#4283](https://github.com/expo/eas-cli/pull/4283) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][steps] Bump `uuid` to v11 and remove the redundant `@types/uuid` dependency. ([#4282](https://github.com/expo/eas-cli/pull/4282) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Bump `minizlib` to 3.1.0 to drop its deprecated `rimraf@5` transitive dependency. ([#4281](https://github.com/expo/eas-cli/pull/4281) by [@brentvatne](https://github.com/brentvatne))
## [23.0.0](https://github.com/expo/eas-cli/releases/tag/v23.0.0) - 2026-08-28
### 🛠 Breaking changes
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "23.0.0",
"version": "23.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "23.0.0",
"version": "23.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -40,16 +40,16 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "23.0.0",
"@expo/eas-build-job": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "23.0.0",
"@expo/logger": "23.1.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": "23.0.0",
"@expo/steps": "23.1.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.0.0",
"@expo/turtle-spawn": "23.1.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -92,7 +92,6 @@
"@types/retry": "^0.12.5",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"bunyan": "^1.8.15",
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
@@ -1,4 +1,4 @@
import bunyan from 'bunyan';
import type { bunyan } from '@expo/logger';
export function createLogger(): bunyan {
const logger = {
@@ -245,6 +245,42 @@ describe(installDependenciesAsync, () => {
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
});
it('installs only the selected workspace when EAS_PNPM_FILTER_WORKSPACE is set', async () => {
const logger = createMockLogger();
await installDependenciesAsync({
packageManager: PackageManager.PNPM,
env: { EAS_PNPM_FILTER_WORKSPACE: 'my-app' },
logger,
cwd: '/tmp/build',
useFrozenLockfile: true,
});
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['install', '--filter', 'my-app', '--frozen-lockfile'],
expect.objectContaining({ cwd: '/tmp/build' })
);
});
it('installs all workspaces when EAS_PNPM_FILTER_WORKSPACE is not set', async () => {
const logger = createMockLogger();
await installDependenciesAsync({
packageManager: PackageManager.PNPM,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: true,
});
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['install', '--frozen-lockfile'],
expect.objectContaining({ cwd: '/tmp/build' })
);
});
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
const logger = createMockLogger();
@@ -6,6 +6,7 @@ import {
Platform,
Workflow,
} from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
@@ -14,7 +15,7 @@ import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { shallowCloneRepositoryAsync } from '../git';
import { prepareProjectSourcesAsync } from '../projectSources';
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
jest.mock('@expo/turtle-spawn');
jest.mock('node-fetch');
@@ -23,6 +24,27 @@ jest.mock('@expo/downloader');
jest.mock('@urql/core');
describe('projectSources', () => {
it('makes extracted project sources readable and writable by the worker', async () => {
const logger = createMockLogger();
await unpackTarGzAsync({
source: '/workingdir/project.tar.gz',
destination: '/workingdir/build',
logger,
});
expect(spawn).toHaveBeenNthCalledWith(
1,
'tar',
['-C', '/workingdir/build', '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
{ logger }
);
expect(spawn).toHaveBeenNthCalledWith(2, 'chmod', ['-R', 'u+rwX', '/workingdir/build'], {
logger,
});
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
});
it('should use the refreshed repository URL', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
@@ -32,7 +32,11 @@ export async function installDependenciesAsync({
break;
}
case PackageManager.PNPM: {
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
args = [
'install',
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
];
break;
}
case PackageManager.YARN: {
@@ -110,7 +110,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
});
}
async function unpackTarGzAsync({
export async function unpackTarGzAsync({
logger,
source,
destination,
@@ -122,6 +122,8 @@ async function unpackTarGzAsync({
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
logger,
});
logger.info('Normalizing project source permissions');
await spawn('chmod', ['-R', 'u+rwX', destination], { logger });
}
function uploadProjectMetadataAsFireAndForget(
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "23.0.0",
"version": "23.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "23.0.0"
"@expo/steps": "23.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "23.0.0",
"version": "23.1.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": "23.0.0",
"@expo/logger": "23.1.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "23.0.0",
"@expo/turtle-spawn": "23.1.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+130 -128
View File
File diff suppressed because it is too large. Load diff
+8 -9
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "23.0.0",
"version": "23.1.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "23.0.0",
"@expo/eas-json": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/eas-json": "23.1.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "23.0.0",
"@expo/logger": "23.1.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.0.0",
"@expo/steps": "23.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -72,7 +72,7 @@
"@sentry/node": "7.77.0",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"agent-cli-detector": "0.1.6",
"agent-cli-detector": "0.1.7",
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
@@ -104,7 +104,7 @@
"log-symbols": "4.1.0",
"mime": "3.0.0",
"minimatch": "5.1.2",
"minizlib": "3.0.1",
"minizlib": "3.1.0",
"nanoid": "3.3.8",
"node-fetch": "2.6.7",
"node-forge": "1.4.0",
@@ -130,7 +130,7 @@
"tslib": "2.6.2",
"turndown": "7.1.2",
"untildify": "4.0.0",
"uuid": "9.0.1",
"uuid": "11.1.1",
"wrap-ansi": "7.0.0",
"yaml": "2.6.0",
"zod": "^4.1.3"
@@ -159,7 +159,6 @@
"@types/tar": "6.1.10",
"@types/tar-stream": "3.1.3",
"@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.7",
"@types/wrap-ansi": "3.0.0",
"axios": "1.18.1",
"eslint-plugin-graphql": "4.0.0",
@@ -1,68 +1,109 @@
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import glob from 'fast-glob';
import fs from 'fs-extra';
import { pipeline } from 'stream/promises';
import { extract } from 'tar';
import fetch from '../../../fetch';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { expoCommandAsync } from '../../../utils/expoCli';
import {
cloneTemplateAsync,
downloadTemplateAsync,
fetchTemplatePackumentAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../commands';
jest.mock('../../../utils/expoCli');
jest.mock('../../../onboarding/git');
jest.mock('../../../fetch');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
jest.mock('../../../ora');
jest.mock('fs-extra');
jest.mock('tar');
jest.mock('stream/promises');
jest.mock('fast-glob');
describe('commands', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('cloneTemplateAsync', () => {
it('should clone the template project with ssh', async () => {
describe('fetchTemplatePackumentAsync', () => {
it('should fetch the packument from the npm registry', async () => {
const packument = { 'dist-tags': { latest: '57.0.19' } };
jest.mocked(fetch).mockResolvedValueOnce({ json: async () => packument } as any);
expect(await fetchTemplatePackumentAsync()).toEqual(packument);
expect(fetch).toHaveBeenCalledWith(
'https://registry.npmjs.org/expo-template-default',
expect.objectContaining({
headers: { accept: 'application/vnd.npm.install-v1+json' },
})
);
});
});
describe('downloadTemplateAsync', () => {
const template = {
npmTag: 'sdk-56',
version: '56.0.34',
tarballUrl: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz',
};
it('should download and extract the template', async () => {
const targetProjectDir = '/test/target-project';
const finalTargetProjectDir = '/test/final-target-project';
const tarballBody = 'tarball-stream';
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir: finalTargetProjectDir,
});
jest.mocked(fs.pathExists).mockResolvedValueOnce(false as never);
jest.mocked(fetch).mockResolvedValueOnce({ body: tarballBody } as any);
jest.mocked(glob).mockResolvedValueOnce(['gitignore', '_vscode'] as any);
const result = await cloneTemplateAsync(targetProjectDir);
const result = await downloadTemplateAsync(targetProjectDir, template);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'ssh',
showOutput: false,
});
expect(result).toBe(finalTargetProjectDir);
expect(fetch).toHaveBeenCalledWith(
'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz'
);
expect(extract).toHaveBeenCalledWith({ cwd: targetProjectDir, strip: 1 });
expect(pipeline).toHaveBeenCalledWith(tarballBody, extract({} as any));
expect(fs.move).toHaveBeenCalledWith(
'/test/target-project/gitignore',
'/test/target-project/.gitignore',
{ overwrite: true }
);
expect(fs.move).toHaveBeenCalledWith(
'/test/target-project/_vscode',
'/test/target-project/.vscode',
{ overwrite: true }
);
expect(result).toBe(targetProjectDir);
});
it('should clone the template project with https', async () => {
const targetProjectDir = '/test/target-project';
const finalTargetProjectDir = '/test/final-target-project';
it('should throw when the target directory exists and is not empty', async () => {
jest.mocked(fs.pathExists).mockResolvedValueOnce(true as never);
jest.mocked(fs.readdir).mockResolvedValueOnce(['existing-file'] as never);
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir: finalTargetProjectDir,
});
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow(
'already exists and is not empty'
);
});
const result = await cloneTemplateAsync(targetProjectDir);
it('should not remove a pre-existing directory on failure', async () => {
jest.mocked(fs.pathExists).mockResolvedValueOnce(true as never);
jest.mocked(fs.readdir).mockResolvedValueOnce(['existing-file'] as never);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'https',
showOutput: false,
});
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow();
expect(fs.remove).not.toHaveBeenCalled();
});
expect(result).toBe(finalTargetProjectDir);
it('should clean up a partially extracted directory on failure', async () => {
jest.mocked(fs.pathExists).mockResolvedValueOnce(false as never);
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
await expect(downloadTemplateAsync('/test/target-project', template)).rejects.toThrow(
'network error'
);
expect(fs.remove).toHaveBeenCalledWith('/test/target-project');
});
});
@@ -9,6 +9,7 @@ import {
copyProjectTemplatesAsync,
generateAppConfigAsync,
generateEasConfigAsync,
removeTemplateFilesAsync,
updatePackageJsonAsync,
updateReadmeAsync,
} from '../projectFiles';
@@ -239,17 +240,24 @@ describe('projectFiles', () => {
});
describe('updatePackageJsonAsync', () => {
it('should update the package.json', async () => {
it('should replace template metadata and add workflow scripts', async () => {
jest.mocked(fs.readJson).mockResolvedValue({
name: 'test-app',
version: '1.0.0',
name: 'expo-template-default',
version: '57.0.19',
license: '0BSD',
description: 'A starter template',
repository: 'https://github.com/expo/expo-template-default',
tags: ['expo'],
main: 'expo-router/entry',
});
const projectDir = '/test/project-dir';
await updatePackageJsonAsync(projectDir);
await updatePackageJsonAsync(projectDir, 'My App');
const expectedPackageJson = {
name: 'test-app',
name: 'myapp',
version: '1.0.0',
private: true,
main: 'expo-router/entry',
scripts: {
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
@@ -261,6 +269,28 @@ describe('projectFiles', () => {
spaces: 2,
});
});
it('should keep a non-template license', async () => {
jest.mocked(fs.readJson).mockResolvedValue({
name: 'some-template',
license: 'MIT',
});
await updatePackageJsonAsync('/test/project-dir', 'my-app');
expect(fs.writeJson).toHaveBeenCalledWith(
'/test/project-dir/package.json',
expect.objectContaining({ license: 'MIT' }),
{ spaces: 2 }
);
});
});
describe('removeTemplateFilesAsync', () => {
it('should remove the template LICENSE file', async () => {
await removeTemplateFilesAsync('/test/project-dir');
expect(fs.remove).toHaveBeenCalledWith('/test/project-dir/LICENSE');
});
});
describe('copyProjectTemplatesAsync', () => {
@@ -0,0 +1,219 @@
import fetch from '../../../fetch';
import { promptAsync } from '../../../prompts';
import { fetchTemplatePackumentAsync } from '../commands';
import {
fetchSdkVersionsAsync,
promptForSdkVersionAsync,
resolveTemplateAsync,
} from '../sdkVersion';
jest.mock('../../../fetch');
jest.mock('../../../prompts');
jest.mock('../../../api', () => ({
getExpoApiBaseUrl: () => 'https://api.expo.dev',
}));
jest.mock('../commands');
const packument = {
'dist-tags': { latest: '57.0.19', 'sdk-57': '57.0.19', 'sdk-56': '56.0.34' },
versions: {
'57.0.19': {
dist: { tarball: 'https://registry.npmjs.org/expo-template-default/-/57.0.19.tgz' },
},
'56.0.34': {
dist: { tarball: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz' },
},
},
};
function mockVersionsEndpoint(json: object): void {
jest.mocked(fetch).mockResolvedValueOnce({ json: async () => json } as any);
}
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(fetchTemplatePackumentAsync).mockResolvedValue(packument);
});
describe('fetchSdkVersionsAsync', () => {
it('returns released SDK versions sorted descending', async () => {
mockVersionsEndpoint({
sdkVersions: {
'55.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-55' },
'57.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-57' },
'56.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-56' },
'58.0.0': {}, // canary, no release notes
'54.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-54', isDeprecated: true },
},
expoGoSdkVersion: '56.0.0',
});
const versions = await fetchSdkVersionsAsync();
expect(versions).toEqual({
latest: 57,
expoGoCompatible: 56,
available: [57, 56, 55],
});
});
it('returns null when the endpoint is unreachable', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
expect(await fetchSdkVersionsAsync()).toBeNull();
});
it('returns null when no released versions are available', async () => {
mockVersionsEndpoint({ sdkVersions: {} });
expect(await fetchSdkVersionsAsync()).toBeNull();
});
});
describe('promptForSdkVersionAsync', () => {
it('offers latest and Expo Go compatible choices', async () => {
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 56 });
const result = await promptForSdkVersionAsync({
latest: 57,
expoGoCompatible: 56,
available: [57, 56, 55],
});
expect(result).toBe(56);
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Select an Expo SDK version:',
choices: [
expect.objectContaining({ title: 'Latest (SDK 57)', value: 57 }),
expect.objectContaining({
title: 'For learning with Expo Go (SDK 56)',
value: 56,
}),
expect.objectContaining({ title: 'Other SDK version…', value: 'other' }),
],
})
);
});
it('hides the Expo Go choice when it matches the latest SDK or is unavailable', async () => {
jest.mocked(promptAsync).mockResolvedValue({ answer: 57 });
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: 57, available: [57, 56] });
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: 54, available: [57, 56] });
for (const call of jest.mocked(promptAsync).mock.calls) {
expect(call[0]).toEqual(
expect.objectContaining({
choices: [
expect.objectContaining({ title: 'Latest (SDK 57)' }),
expect.objectContaining({ title: 'Other SDK version…' }),
],
})
);
}
});
it('hides the "Other" choice when no other versions are available', async () => {
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 57 });
await promptForSdkVersionAsync({ latest: 57, expoGoCompatible: null, available: [57] });
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
choices: [expect.objectContaining({ title: 'Latest (SDK 57)' })],
})
);
});
it('offers the remaining versions when "other" is selected', async () => {
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ answer: 'other' })
.mockResolvedValueOnce({ sdkVersion: 53 });
const result = await promptForSdkVersionAsync({
latest: 57,
expoGoCompatible: 56,
available: [57, 56, 55, 54, 53],
});
expect(result).toBe(53);
expect(promptAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
message: 'Select an SDK version:',
choices: [
{ title: 'SDK 55', value: 55 },
{ title: 'SDK 54', value: 54 },
{ title: 'SDK 53', value: 53 },
],
})
);
});
});
describe('resolveTemplateAsync', () => {
it('uses the provided SDK version without prompting', async () => {
expect(await resolveTemplateAsync({ sdkVersion: '56' })).toEqual({
npmTag: 'sdk-56',
version: '56.0.34',
tarballUrl: 'https://registry.npmjs.org/expo-template-default/-/56.0.34.tgz',
});
expect((await resolveTemplateAsync({ sdkVersion: 'sdk-57' })).npmTag).toBe('sdk-57');
expect((await resolveTemplateAsync({ sdkVersion: 'latest' })).npmTag).toBe('latest');
expect(promptAsync).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it('throws for an invalid SDK version', async () => {
await expect(resolveTemplateAsync({ sdkVersion: 'abc' })).rejects.toThrow(
'Invalid SDK version'
);
});
it('throws for an SDK version without a published template', async () => {
await expect(resolveTemplateAsync({ sdkVersion: '50' })).rejects.toThrow(
'A project template for "sdk-50" is not available. Supported SDK versions: 57, 56.'
);
});
it('prompts with only the SDK versions that have a published template', async () => {
mockVersionsEndpoint({
sdkVersions: {
'57.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-57' },
'56.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-56' },
'55.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-55' },
},
});
jest.mocked(promptAsync).mockResolvedValueOnce({ answer: 56 });
const template = await resolveTemplateAsync({});
expect(template.npmTag).toBe('sdk-56');
// SDK 55 has no dist-tag in the packument, so it must not be offered.
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
choices: [
expect.objectContaining({ title: 'Latest (SDK 57)' }),
expect.objectContaining({ title: 'Other SDK version…' }),
],
})
);
});
it('falls back to the latest tag when the versions endpoint is unavailable', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
expect((await resolveTemplateAsync({})).npmTag).toBe('latest');
expect(promptAsync).not.toHaveBeenCalled();
});
it('falls back to the latest tag when no listed version has a template', async () => {
mockVersionsEndpoint({
sdkVersions: { '99.0.0': { releaseNoteUrl: 'https://expo.dev/changelog/sdk-99' } },
});
expect((await resolveTemplateAsync({})).npmTag).toBe('latest');
expect(promptAsync).not.toHaveBeenCalled();
});
});
@@ -1,40 +1,106 @@
import chalk from 'chalk';
import glob from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { pipeline } from 'stream/promises';
import { extract } from 'tar';
import { printDirectory } from './utils';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import fetch from '../../fetch';
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
import { expoCommandAsync } from '../../utils/expoCli';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
export const TEMPLATE_PACKAGE_NAME = 'expo-template-default';
const NPM_REGISTRY_URL =
process.env.npm_config_registry?.replace(/\/$/, '') ?? 'https://registry.npmjs.org';
export interface TemplateInfo {
npmTag: string;
version: string;
tarballUrl: string;
}
export interface TemplatePackument {
'dist-tags'?: Record<string, string>;
versions?: Record<string, { dist: { tarball: string } }>;
}
export async function fetchTemplatePackumentAsync(): Promise<TemplatePackument> {
const response = await fetch(`${NPM_REGISTRY_URL}/${TEMPLATE_PACKAGE_NAME}`, {
headers: { accept: 'application/vnd.npm.install-v1+json' },
signal: AbortSignal.timeout(10000),
});
return (await response.json()) as TemplatePackument;
}
export async function downloadTemplateAsync(
targetProjectDir: string,
template: TemplateInfo
): Promise<string> {
const spinner = ora(
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
`${chalk.bold(`Downloading the project template to ${printDirectory(targetProjectDir)}`)}`
).start();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}))
? 'ssh'
: 'https';
const targetExisted = await fs.pathExists(targetProjectDir);
try {
// The directory was verified to not exist earlier, but it may have been
// created while the user was answering prompts.
if (targetExisted && (await fs.readdir(targetProjectDir)).length > 0) {
throw new Error(
`Directory ${printDirectory(targetProjectDir)} already exists and is not empty.`
);
}
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
githubUsername,
githubRepositoryName,
targetProjectDir,
cloneMethod,
showOutput: false,
await fs.mkdirp(targetProjectDir);
const tarballResponse = await fetch(template.tarballUrl);
await pipeline(tarballResponse.body, extract({ cwd: targetProjectDir, strip: 1 }));
await restoreTemplateDotfilesAsync(targetProjectDir);
spinner.succeed(
`Downloaded ${chalk.bold(`${TEMPLATE_PACKAGE_NAME}@${template.version}`)} to ${printDirectory(
targetProjectDir
)}`
);
} catch (error) {
spinner.fail();
if (!targetExisted) {
await fs.remove(targetProjectDir);
}
throw error;
}
return targetProjectDir;
}
/**
* npm strips `.gitignore` files from published packages, and the template ships
* dot-directories with an underscore prefix (e.g. `_vscode`), so restore the
* real names after extraction. This mirrors the renames in create-expo-app;
* keep the directory list in sync with SUPPORTED_DIRECTORIES in
* https://github.com/expo/expo/blob/main/packages/create-expo-app/src/createFileTransform.ts
*/
async function restoreTemplateDotfilesAsync(projectDir: string): Promise<void> {
const entries = await glob('**/{gitignore,_eas,_vscode,_github,_cursor}', {
cwd: projectDir,
onlyFiles: false,
dot: true,
});
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
// Rename deeper entries first so parent renames do not invalidate child paths.
entries.sort((a, b) => b.split('/').length - a.split('/').length);
return finalTargetProjectDirectory;
for (const entry of entries) {
const basename = path.basename(entry);
const dotName = basename === 'gitignore' ? '.gitignore' : `.${basename.slice(1)}`;
await fs.move(
path.join(projectDir, entry),
path.join(projectDir, path.dirname(entry), dotName),
{ overwrite: true }
);
}
}
export async function installProjectDependenciesAsync(
@@ -110,10 +110,37 @@ export async function generateEasConfigAsync(projectDir: string): Promise<void>
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
}
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
const TEMPLATE_LICENSE = '0BSD';
// https://github.com/npm/validate-npm-package-name#naming-rules
function sanitizeNpmPackageName(name: string): string {
return (
name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/^[._]+/, '')
.replace(/[^a-z0-9._\-/@]/g, '') || 'app'
);
}
export async function updatePackageJsonAsync(
projectDir: string,
projectName: string
): Promise<void> {
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = await fs.readJson(packageJsonPath);
packageJson.name = sanitizeNpmPackageName(projectName);
packageJson.version = '1.0.0';
packageJson.private = true;
delete packageJson.description;
delete packageJson.tags;
delete packageJson.repository;
if (packageJson.license === TEMPLATE_LICENSE) {
delete packageJson.license;
}
if (!packageJson.scripts) {
packageJson.scripts = {};
}
@@ -125,6 +152,10 @@ export async function updatePackageJsonAsync(projectDir: string): Promise<void>
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
export async function removeTemplateFilesAsync(projectDir: string): Promise<void> {
await fs.remove(path.join(projectDir, 'LICENSE'));
}
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
const templatesSourceDir = path.join(__dirname, 'templates');
@@ -0,0 +1,181 @@
import { TemplateInfo, fetchTemplatePackumentAsync } from './commands';
import { getExpoApiBaseUrl } from '../../api';
import fetch from '../../fetch';
import Log from '../../log';
import { promptAsync } from '../../prompts';
export interface SdkVersions {
latest: number;
expoGoCompatible: number | null;
available: number[];
}
interface VersionsEndpointResponse {
sdkVersions?: Record<string, { releaseNoteUrl?: string; isDeprecated?: boolean }>;
expoGoSdkVersion?: string;
}
/**
* SDKs without a `releaseNoteUrl` are canary/in-development and are excluded.
*/
export async function fetchSdkVersionsAsync(): Promise<SdkVersions | null> {
let json: VersionsEndpointResponse;
try {
const response = await fetch(`${getExpoApiBaseUrl()}/v2/versions`, {
signal: AbortSignal.timeout(5000),
});
json = (await response.json()) as VersionsEndpointResponse;
} catch (error) {
Log.debug(`Failed to fetch SDK versions: ${error}`);
return null;
}
const available = Object.entries(json.sdkVersions ?? {})
.filter(([, info]) => !!info.releaseNoteUrl && !info.isDeprecated)
.map(([version]) => parseSdkMajor(version))
.filter((major): major is number => major !== null)
.sort((a, b) => b - a);
const latest = available[0];
if (latest == null) {
return null;
}
return {
latest,
expoGoCompatible: parseSdkMajor(json.expoGoSdkVersion),
available,
};
}
function parseSdkMajor(version: string | undefined): number | null {
const major = parseInt(version?.split('.')[0] ?? '', 10);
return Number.isFinite(major) ? major : null;
}
export async function promptForSdkVersionAsync({
latest,
expoGoCompatible,
available,
}: SdkVersions): Promise<number> {
const choices: { title: string; value: number | 'other'; description?: string }[] = [
{
title: `Latest (SDK ${latest})`,
value: latest,
description: 'Recommended for most projects',
},
];
if (
expoGoCompatible !== null &&
expoGoCompatible !== latest &&
available.includes(expoGoCompatible)
) {
choices.push({
title: `For learning with Expo Go (SDK ${expoGoCompatible})`,
value: expoGoCompatible,
description: 'Compatible with Expo Go on App Store and Play Store',
});
}
const otherVersions = available.filter(sdk => sdk !== latest && sdk !== expoGoCompatible);
if (otherVersions.length > 0) {
choices.push({ title: 'Other SDK version…', value: 'other' });
}
const { answer } = await promptAsync({
type: 'select',
name: 'answer',
message: 'Select an Expo SDK version:',
choices,
});
if (answer !== 'other') {
return answer;
}
const { sdkVersion } = await promptAsync({
type: 'select',
name: 'sdkVersion',
message: 'Select an SDK version:',
choices: otherVersions.map(sdk => ({
title: `SDK ${sdk}`,
value: sdk,
})),
});
return sdkVersion;
}
function resolveTemplateForTag(
distTags: Record<string, string>,
versions: Record<string, { dist: { tarball: string } }>,
npmTag: string
): TemplateInfo {
const version = distTags[npmTag];
const tarballUrl = version ? versions[version]?.dist.tarball : undefined;
if (!version || !tarballUrl) {
const supported = Object.keys(distTags)
.filter(tag => /^sdk-\d+$/.test(tag))
.sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10));
throw new Error(
`A project template for "${npmTag}" is not available. Supported SDK versions: ${supported
.map(tag => tag.slice(4))
.join(', ')}.`
);
}
return { npmTag, version, tarballUrl };
}
/**
* `sdkVersion` accepts "57", "sdk-57", or "latest".
*/
export async function resolveTemplateAsync({
sdkVersion,
}: {
sdkVersion?: string;
}): Promise<TemplateInfo> {
const packument = await fetchTemplatePackumentAsync();
const templateDistTags = packument['dist-tags'] ?? {};
const packumentVersions = packument.versions ?? {};
if (sdkVersion) {
if (sdkVersion === 'latest') {
return resolveTemplateForTag(templateDistTags, packumentVersions, 'latest');
}
const major = parseSdkMajor(sdkVersion.replace(/^sdk-/, ''));
if (major === null) {
throw new Error(
`Invalid SDK version: "${sdkVersion}". Specify a version number (e.g. 57) or "latest".`
);
}
return resolveTemplateForTag(templateDistTags, packumentVersions, `sdk-${major}`);
}
const versions = await fetchSdkVersionsAsync();
const availableWithTemplates =
versions?.available.filter(sdk => `sdk-${sdk}` in templateDistTags) ?? [];
if (!versions || availableWithTemplates.length === 0) {
if (!versions) {
Log.warn('Could not fetch the list of Expo SDK versions. Using the latest SDK.');
}
return resolveTemplateForTag(templateDistTags, packumentVersions, 'latest');
}
const latest = availableWithTemplates[0];
// Match the prompt guard in prompts.ts: pin to the latest released SDK
// instead of prompting when stdin is not interactive.
if (!process.stdin.isTTY && !global.test) {
Log.withInfo(`Using Expo SDK ${latest}`);
return resolveTemplateForTag(templateDistTags, packumentVersions, `sdk-${latest}`);
}
const selectedSdk = await promptForSdkVersionAsync({
latest,
expoGoCompatible: versions.expoGoCompatible,
available: availableWithTemplates,
});
return resolveTemplateForTag(templateDistTags, packumentVersions, `sdk-${selectedSdk}`);
}
@@ -17,7 +17,6 @@ jest.mock('fs-extra', () => ({
jest.mock('../../../prompts', () => ({
promptAsync: jest.fn(),
}));
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../project/expoConfig');
+17 -3
View File
@@ -6,7 +6,7 @@ import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
cloneTemplateAsync,
downloadTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../../commandUtils/new/commands';
@@ -14,10 +14,12 @@ import {
generateProjectConfigAsync,
promptForProjectAccountAsync,
} from '../../commandUtils/new/configs';
import { resolveTemplateAsync } from '../../commandUtils/new/sdkVersion';
import {
copyProjectTemplatesAsync,
generateAppConfigAsync,
generateEasConfigAsync,
removeTemplateFilesAsync,
updatePackageJsonAsync,
updateReadmeAsync,
} from '../../commandUtils/new/projectFiles';
@@ -108,7 +110,9 @@ export async function generateProjectFilesAsync(
await generateEasConfigAsync(projectDir);
await updatePackageJsonAsync(projectDir);
await updatePackageJsonAsync(projectDir, app.slug);
await removeTemplateFilesAsync(projectDir);
await copyProjectTemplatesAsync(projectDir);
@@ -146,6 +150,9 @@ export default class New extends EasCommand {
options: [...PACKAGE_MANAGERS] as const,
default: 'npm',
})(),
'sdk-version': Flags.string({
description: 'Expo SDK version to use for the new project (e.g. 57, or "latest")',
}),
};
static override contextDefinition = {
@@ -167,13 +174,20 @@ export default class New extends EasCommand {
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
// Resolve the template before any prompts when --sdk-version is provided,
// so an invalid version fails fast.
let template = flags['sdk-version']
? await resolveTemplateAsync({ sdkVersion: flags['sdk-version'] })
: null;
const {
projectName,
projectDirectory: targetProjectDirectory,
projectAccount,
} = await generateConfigsAsync(args, actor, graphqlClient);
const projectDirectory = await cloneTemplateAsync(targetProjectDirectory);
template ??= await resolveTemplateAsync({});
const projectDirectory = await downloadTemplateAsync(targetProjectDirectory, template);
const packageManager = flags['package-manager'];
await installProjectDependenciesAsync(projectDirectory, packageManager);
-109
View File
@@ -1,109 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import { runCommandAsync } from './runCommand';
import Log from '../log';
import { confirmAsync, promptAsync } from '../prompts';
export async function runGitCloneAsync({
targetProjectDir,
githubRepositoryName,
githubUsername,
cloneMethod,
showOutput = true,
}: {
githubUsername: string;
githubRepositoryName: string;
targetProjectDir: string;
cloneMethod: 'ssh' | 'https';
showOutput?: boolean;
}): Promise<{
targetProjectDir: string;
}> {
const url =
cloneMethod === 'ssh'
? `[email protected]:${githubUsername}/${githubRepositoryName}.git`
: `https://github.com/${githubUsername}/${githubRepositoryName}.git`;
try {
await runCommandAsync({
command: 'git',
args: ['clone', url, targetProjectDir],
shouldPrintStderrLineAsStdout: line => {
return line.includes('Cloning into');
},
showSpinner: false,
showOutput,
});
return { targetProjectDir };
} catch (error: any) {
if (error.stderr.includes('already exists')) {
Log.warn(`Directory ${targetProjectDir} already exists.`);
const shouldContinue = await confirmAsync({
message: 'Do you want to clone your project to some other destination?',
});
if (!shouldContinue) {
throw new Error('Directory already exists. Aborting...');
}
const { newTargetProjectDir } = await promptAsync({
type: 'text',
name: 'newTargetProjectDir',
message: 'New target directory path:',
validate: (input: string) => input !== '',
});
return await runGitCloneAsync({
githubRepositoryName,
githubUsername,
targetProjectDir: newTargetProjectDir,
cloneMethod,
});
} else if (error.stderr.includes('Permission denied')) {
Log.warn(
`It seems like you do not have permission to clone the repository using ${cloneMethod}.`
);
const newMethod = cloneMethod === 'ssh' ? 'https' : 'ssh';
const shouldContinue = await confirmAsync({
message: `Do you want to clone the repository using ${newMethod} instead?`,
});
if (!shouldContinue) {
throw new Error('Permission denied. Aborting...');
}
return await runGitCloneAsync({
githubRepositoryName,
githubUsername,
targetProjectDir,
cloneMethod: newMethod,
});
} else {
throw error;
}
}
}
export async function runGitPushAsync({
targetProjectDir,
}: {
targetProjectDir: string;
}): Promise<void> {
await runCommandAsync({
command: 'git',
args: ['push'],
cwd: targetProjectDir,
});
}
export async function canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}: {
githubUsername: string;
githubRepositoryName: string;
}): Promise<boolean> {
try {
await spawnAsync('git', [
'ls-remote',
`[email protected]:${githubUsername}/${githubRepositoryName}.git`,
]);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,112 @@
import { compileModsAsync } from '@expo/config-plugins';
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
import { spawnExpoCommand } from '../../../utils/expoCli';
import { Client } from '../../../vcs/vcs';
import { isExpoInstalled } from '../../projectUtils';
import { hasIgnoredIosProjectAsync } from '../../workflow';
import { getManagedApplicationTargetEntitlementsAsync } from '../entitlements';
jest.mock('@expo/config-plugins', () => ({
...jest.requireActual('@expo/config-plugins'),
compileModsAsync: jest.fn(),
}));
jest.mock('@expo/prebuild-config');
jest.mock('../../../log', () => ({
__esModule: true,
default: { warn: jest.fn() },
}));
jest.mock('../../../sentry', () => ({
__esModule: true,
default: {
withScope: jest.fn(),
captureMessage: jest.fn(),
},
}));
jest.mock('../../../utils/expoCli');
jest.mock('../../projectUtils');
jest.mock('../../workflow');
const projectDir = '/project';
const vcsClient = {} as Client;
const prebuildConfig = {
name: 'test-app',
slug: 'test-app',
ios: { bundleIdentifier: 'com.example.test' },
};
const entitlements = {
'aps-environment': 'production',
'com.apple.developer.associated-domains': ['applinks:example.com'],
};
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(hasIgnoredIosProjectAsync).mockResolvedValue(true);
jest.mocked(getPrebuildConfigAsync).mockResolvedValue({ exp: prebuildConfig } as any);
jest.mocked(compileModsAsync).mockResolvedValue({
...prebuildConfig,
ios: { ...prebuildConfig.ios, entitlements },
} as any);
});
it('uses the bundled config fallback when Expo is not installed', async () => {
jest.mocked(isExpoInstalled).mockReturnValue(false);
const originalProcessEnv = process.env;
let envDuringPrebuild: string | undefined;
jest.mocked(getPrebuildConfigAsync).mockImplementation(async () => {
envDuringPrebuild = process.env.TEST_ENTITLEMENTS_ENV;
return { exp: prebuildConfig } as any;
});
await expect(
getManagedApplicationTargetEntitlementsAsync(
projectDir,
{ TEST_ENTITLEMENTS_ENV: 'from-build-profile' },
vcsClient
)
).resolves.toEqual(entitlements);
expect(spawnExpoCommand).not.toHaveBeenCalled();
expect(getPrebuildConfigAsync).toHaveBeenCalledWith(projectDir, { platforms: ['ios'] });
expect(compileModsAsync).toHaveBeenCalledWith(prebuildConfig, {
projectRoot: projectDir,
platforms: ['ios'],
introspect: true,
ignoreExistingNativeFiles: true,
});
expect(envDuringPrebuild).toBe('from-build-profile');
expect(process.env).toBe(originalProcessEnv);
});
it('uses the bundled config fallback when the local Expo CLI fails', async () => {
jest.mocked(isExpoInstalled).mockReturnValue(true);
jest
.mocked(spawnExpoCommand)
.mockRejectedValue(
Object.assign(new Error('local Expo CLI failed'), { stderr: 'config failed' })
);
await expect(
getManagedApplicationTargetEntitlementsAsync(projectDir, {}, vcsClient)
).resolves.toEqual(entitlements);
expect(spawnExpoCommand).toHaveBeenCalledWith(
projectDir,
['config', '--json', '--type', 'introspect'],
{ env: expect.objectContaining({ EXPO_NO_DOTENV: '1' }) }
);
expect(getPrebuildConfigAsync).toHaveBeenCalledWith(projectDir, { platforms: ['ios'] });
});
it('does not make Expo a required peer of the bundled config fallback', () => {
const packageJson = require('@expo/prebuild-config/package.json') as {
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
};
const hasRequiredExpoPeer =
packageJson.peerDependencies?.expo !== undefined &&
packageJson.peerDependenciesMeta?.expo?.optional !== true;
expect(hasRequiredExpoPeer).toBe(false);
});
@@ -0,0 +1,70 @@
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { extract } from 'tar-stream';
import zlib from 'node:zlib';
import { FileEntry, packFilesIterableAsync } from '../assets';
async function extractTarEntriesAsync(tarData: Buffer): Promise<Map<string, string>> {
const entries = new Map<string, string>();
const extractor = extract();
extractor.on('entry', (header, stream, next) => {
const chunks: Buffer[] = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('end', () => {
entries.set(header.name, Buffer.concat(chunks).toString('utf8'));
next();
});
stream.resume();
});
await new Promise<void>((resolve, reject) => {
extractor.on('finish', resolve);
extractor.on('error', reject);
Readable.from(tarData).pipe(extractor);
});
return entries;
}
describe(packFilesIterableAsync, () => {
let tarPath: string | null = null;
afterEach(async () => {
if (tarPath) {
await fs.promises.rm(tarPath, { force: true });
tarPath = null;
}
});
it('packs file entries into a gzip-compressed tarball', async () => {
const files: FileEntry[] = [
['index.html', '<h1>hello</h1>'],
['assets/data.json', '{"key":"value"}'],
];
tarPath = await packFilesIterableAsync(files);
const compressed = await fs.promises.readFile(tarPath);
// gzip magic bytes
expect(compressed[0]).toBe(0x1f);
expect(compressed[1]).toBe(0x8b);
// portable mode zeroes the gzip MTIME field (bytes 4-7)
expect(compressed.subarray(4, 8)).toEqual(Buffer.from([0, 0, 0, 0]));
const entries = await extractTarEntriesAsync(zlib.gunzipSync(compressed));
expect(entries.get('index.html')).toBe('<h1>hello</h1>');
expect(entries.get('assets/data.json')).toBe('{"key":"value"}');
expect(entries.size).toBe(2);
});
it('accepts async iterables and forwards gzip options', async () => {
async function* files(): AsyncIterable<FileEntry> {
yield ['a.txt', 'aaaa'];
yield ['b.txt', Buffer.from('bbbb')];
}
tarPath = await packFilesIterableAsync(files(), { level: 9 });
const compressed = await fs.promises.readFile(tarPath);
const entries = await extractTarEntriesAsync(zlib.gunzipSync(compressed));
expect(entries.get('a.txt')).toBe('aaaa');
expect(entries.get('b.txt')).toBe('bbbb');
});
});
@@ -0,0 +1,443 @@
import cliProgress from 'cli-progress';
import fetch, { Headers, Response } from 'node-fetch';
import Log from '../../log';
import { AssetFileEntry } from '../assets';
import {
UploadPayload,
batchUploadAsync,
callUploadApiAsync,
createProgressBar,
uploadAsync,
} from '../upload';
jest.mock('node-fetch', () => ({
...jest.requireActual('node-fetch'),
__esModule: true,
default: jest.fn(),
}));
const mockedFetch = jest.mocked(fetch);
const asset: AssetFileEntry = {
normalizedPath: 'index.html',
path: __filename,
size: 123,
sha512: 'a'.repeat(128),
type: 'text/html',
};
const response = (status = 200, body: unknown = { success: true }): Response =>
new Response(JSON.stringify(body), {
status,
statusText: status === 200 ? 'OK' : 'Request failed',
headers: { 'content-type': 'application/json' },
});
async function runTimersAndExpectRejection(
promise: Promise<unknown>,
expected: string | RegExp
): Promise<void> {
const expectation = expect(promise).rejects.toThrow(expected);
await jest.runAllTimersAsync();
await expectation;
}
describe(uploadAsync, () => {
beforeEach(() => {
jest.useFakeTimers({ now: 0 });
jest.spyOn(Math, 'random').mockReturnValue(0);
jest.spyOn(Log, 'warn').mockImplementation(() => {});
mockedFetch.mockReset();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('uploads an individual asset with its hash and metadata', async () => {
mockedFetch.mockResolvedValue(response());
const result = await uploadAsync({ baseURL: 'https://eas.expo.app/?token=secret' }, { asset });
expect(result.payload).toEqual({ asset });
expect(mockedFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockedFetch.mock.calls[0];
expect(String(url)).toBe(`https://eas.expo.app/asset/${asset.sha512}?token=secret`);
expect(init?.method).toBe('POST');
expect((init?.headers as Headers).get('content-type')).toBe('text/html');
expect((init?.headers as Headers).get('content-length')).toBe('123');
expect(init?.body).toBeDefined();
});
it('uploads a worker deployment file without changing the URL', async () => {
mockedFetch.mockResolvedValue(response());
await uploadAsync({ baseURL: 'https://eas.expo.app/deploy' }, { filePath: __filename });
const [url, init] = mockedFetch.mock.calls[0];
expect(String(url)).toBe('https://eas.expo.app/deploy');
expect(init?.method).toBe('POST');
expect(init?.body).toBeDefined();
});
it('uploads multipart assets to the batch endpoint and reports progress', async () => {
mockedFetch.mockResolvedValue(response());
const onProgress = jest.fn();
await uploadAsync(
{ baseURL: 'https://eas.expo.app/asset/?token=secret' },
{ multipart: [asset, { ...asset, sha512: 'b'.repeat(128) }] },
onProgress
);
const [url, init] = mockedFetch.mock.calls[0];
expect(String(url)).toBe('https://eas.expo.app/asset/batch?token=secret');
expect(init?.method).toBe('PATCH');
expect((init?.headers as Headers).get('content-type')).toMatch(/^multipart\/form-data;/);
expect(onProgress).toHaveBeenNthCalledWith(1, 0);
expect(onProgress).toHaveBeenLastCalledWith(1);
});
it.each([408, 409, 429, 500, 503])('retries HTTP %s responses', async status => {
mockedFetch.mockImplementation(async () => response(status, { error: `status ${status}` }));
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset });
await runTimersAndExpectRejection(promise, `status ${status}`);
expect(mockedFetch).toHaveBeenCalledTimes(5);
});
it('does not retry a terminal HTTP response', async () => {
mockedFetch.mockResolvedValue(response(400, { error: 'invalid upload' }));
await expect(uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset })).rejects.toThrow(
'invalid upload'
);
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
it('reports payload-too-large responses without retrying', async () => {
mockedFetch.mockResolvedValue(response(413));
await expect(uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset })).rejects.toThrow(
'File size exceeded the upload limit'
);
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
it('reports CDN HTML errors with the request ID', async () => {
mockedFetch.mockResolvedValue(
new Response('<html></html>', {
status: 403,
statusText: 'Forbidden',
headers: { 'content-type': 'text/html', 'cf-ray': 'ray-id' },
})
);
await expect(uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset })).rejects.toThrow(
'Request ID ray-id'
);
});
it('switches to network mode and preserves the original network error', async () => {
const networkError = Object.assign(new Error('getaddrinfo ENOTFOUND eas.expo.app'), {
code: 'ENOTFOUND',
});
mockedFetch.mockRejectedValue(networkError);
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset });
await runTimersAndExpectRejection(promise, networkError.message);
expect(mockedFetch).toHaveBeenCalledTimes(8);
expect(Log.warn).toHaveBeenCalledTimes(1);
expect(Log.warn).toHaveBeenCalledWith(
`The upload encountered an error but is still retrying: ${networkError.message}`
);
});
it('smoothly increases retries based on the total request count', async () => {
mockedFetch.mockImplementation(async () => response(503, { error: 'overloaded' }));
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
totalRequests: 32,
});
await runTimersAndExpectRejection(promise, 'overloaded');
// log10(32) / 3 results in six retries, plus the initial request.
expect(mockedFetch).toHaveBeenCalledTimes(7);
});
it('caps request-count scaling at 1,000 requests', async () => {
mockedFetch.mockImplementation(async () => response(503, { error: 'overloaded' }));
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
totalRequests: 1_000_000,
});
await runTimersAndExpectRejection(promise, 'overloaded');
// The scaled 40s ceiling cuts the last attempt the backoff schedule would have allowed.
expect(mockedFetch).toHaveBeenCalledTimes(8);
});
it('keeps the retry state when it switches to network mode', async () => {
let calls = 0;
mockedFetch.mockImplementation(async () => {
if (++calls <= 7) {
return response(503, { error: 'overloaded' });
}
throw new Error('socket disconnected');
});
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
totalRequests: 1_000,
});
await runTimersAndExpectRejection(promise, 'socket disconnected');
// The warning must not repeat once the network-mode attempts take over.
expect(Log.warn).toHaveBeenCalledTimes(1);
expect(Log.warn).toHaveBeenCalledWith(
'The upload encountered an error but is still retrying: overloaded'
);
});
it('warns before a short retry budget is exhausted', async () => {
mockedFetch.mockImplementation(async () => response(503, { error: 'overloaded' }));
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset });
await runTimersAndExpectRejection(promise, 'overloaded');
expect(Log.warn).toHaveBeenCalledTimes(1);
});
it('stops retrying at the scaled upload ceiling', async () => {
mockedFetch.mockImplementation(async () => response(503, { error: 'overloaded' }));
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
totalRequests: 1_000,
});
await runTimersAndExpectRejection(promise, 'overloaded');
// The ceiling stops the next retry from being scheduled, so the last attempt already in
// flight can overshoot it by up to one maxTimeout (10s at this scale).
expect(Date.now()).toBeLessThanOrEqual(40_000 + 10_000);
});
it('clears the network flag after a success', async () => {
const state = { shared: { hasSeenNetworkError: false, hasWarned: false } };
mockedFetch.mockResolvedValueOnce(response(503)).mockResolvedValueOnce(response());
const promise = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
state,
});
await jest.runAllTimersAsync();
await promise;
expect(state.shared.hasSeenNetworkError).toBe(false);
});
it('keeps retry warning clocks independent when retry state is shared', async () => {
const shared = { hasSeenNetworkError: false, hasWarned: false };
mockedFetch
.mockResolvedValueOnce(response(503, { error: 'overloaded' }))
.mockResolvedValueOnce(response())
.mockImplementation(async () => response(503, { error: 'overloaded' }));
const retryingUpload = uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
state: { shared },
});
await uploadAsync({ baseURL: 'https://eas.expo.app' }, { asset }, undefined, {
state: { shared },
});
await runTimersAndExpectRejection(retryingUpload, 'overloaded');
expect(Log.warn).toHaveBeenCalledTimes(1);
});
it('does not retry or enter network mode after cancellation', async () => {
const controller = new AbortController();
controller.abort();
const abortError = Object.assign(new Error('The operation was aborted'), {
name: 'AbortError',
});
mockedFetch.mockRejectedValue(abortError);
await expect(
uploadAsync({ baseURL: 'https://eas.expo.app', signal: controller.signal }, { asset })
).rejects.toBe(abortError);
expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(Log.warn).not.toHaveBeenCalled();
});
});
describe(callUploadApiAsync, () => {
beforeEach(() => {
jest.useFakeTimers({ now: 0 });
jest.spyOn(Math, 'random').mockReturnValue(0);
jest.spyOn(Log, 'warn').mockImplementation(() => {});
mockedFetch.mockReset();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('returns a successful JSON response', async () => {
mockedFetch.mockResolvedValue(response(200, { result: 'complete' }));
await expect(callUploadApiAsync('https://eas.expo.app/finalize')).resolves.toEqual({
result: 'complete',
});
});
it('retries server errors and passes through the final error', async () => {
mockedFetch.mockResolvedValue(response(503));
const promise = callUploadApiAsync('https://eas.expo.app/finalize');
await runTimersAndExpectRejection(promise, 'Deployment failed: Request failed');
// Deploy API calls get a somewhat larger retry budget than a single asset upload.
expect(mockedFetch).toHaveBeenCalledTimes(7);
});
it('retries invalid JSON responses', async () => {
mockedFetch
.mockResolvedValueOnce(new Response('invalid JSON'))
.mockResolvedValueOnce(response(200, { success: true }));
const promise = callUploadApiAsync('https://eas.expo.app/finalize');
await jest.runAllTimersAsync();
await expect(promise).resolves.toEqual({ success: true });
expect(mockedFetch).toHaveBeenCalledTimes(2);
});
it('uses network retries for fetch errors', async () => {
const networkError = new Error('socket disconnected');
mockedFetch.mockRejectedValue(networkError);
const promise = callUploadApiAsync('https://eas.expo.app/finalize');
await runTimersAndExpectRejection(promise, networkError.message);
// Network mode adds attempts but shares the 30s deadline, so it cannot double the wait.
expect(mockedFetch).toHaveBeenCalledTimes(10);
});
it('stops retrying at the 30s ceiling', async () => {
mockedFetch.mockResolvedValue(response(503));
const promise = callUploadApiAsync('https://eas.expo.app/finalize');
await runTimersAndExpectRejection(promise, 'Deployment failed: Request failed');
expect(Date.now()).toBeLessThanOrEqual(30_000);
});
it('shares the ceiling with the network-mode attempts', async () => {
mockedFetch.mockRejectedValue(new Error('socket disconnected'));
const promise = callUploadApiAsync('https://eas.expo.app/finalize');
await runTimersAndExpectRejection(promise, 'socket disconnected');
expect(Date.now()).toBeLessThanOrEqual(30_000 + 5_000);
});
it('does not retry an aborted request', async () => {
const controller = new AbortController();
controller.abort();
const abortError = Object.assign(new Error('The operation was aborted'), {
name: 'AbortError',
});
mockedFetch.mockRejectedValue(abortError);
await expect(
callUploadApiAsync('https://eas.expo.app/finalize', { signal: controller.signal as any })
).rejects.toBe(abortError);
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
});
describe(batchUploadAsync, () => {
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('uploads every payload and reports aggregate progress', async () => {
mockedFetch.mockResolvedValue(response());
const payloads: UploadPayload[] = [
{ asset },
{ asset: { ...asset, sha512: 'b'.repeat(128) } },
{ asset: { ...asset, sha512: 'c'.repeat(128) } },
];
const progress = jest.fn();
const results = [];
for await (const result of batchUploadAsync(
{ baseURL: 'https://eas.expo.app' },
payloads,
progress
)) {
results.push(result);
}
expect(mockedFetch).toHaveBeenCalledTimes(3);
expect(results.at(-1)?.progress).toBe(1);
expect(progress).toHaveBeenLastCalledWith(1);
});
it('accepts an empty upload queue', async () => {
const results = [];
for await (const result of batchUploadAsync({ baseURL: 'https://eas.expo.app' }, [])) {
results.push(result);
}
expect(results).toEqual([]);
expect(mockedFetch).not.toHaveBeenCalled();
});
it('aborts sibling requests after the first upload failure', async () => {
mockedFetch.mockImplementation(async (url, init) => {
if (String(url).includes(asset.sha512)) {
return response(400, { error: 'terminal failure' });
}
return await new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new Error('aborted'));
});
});
});
const iterator = batchUploadAsync({ baseURL: 'https://eas.expo.app' }, [
{ asset },
{ asset: { ...asset, sha512: 'b'.repeat(128) } },
]);
await iterator.next();
await iterator.next();
await expect(iterator.next()).rejects.toThrow('terminal failure');
});
});
describe(createProgressBar, () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('starts, updates, and stops the upload progress bar', () => {
const start = jest.spyOn(cliProgress.SingleBar.prototype, 'start').mockImplementation(() => {});
const update = jest
.spyOn(cliProgress.SingleBar.prototype, 'update')
.mockImplementation(() => {});
const stop = jest.spyOn(cliProgress.SingleBar.prototype, 'stop').mockImplementation(() => {});
const progressBar = createProgressBar('Uploading 3 assets');
progressBar.update(0.5);
progressBar.stop();
expect(start).toHaveBeenCalledWith(1, 0);
expect(update).toHaveBeenCalledWith(0.5);
expect(stop).toHaveBeenCalledTimes(1);
});
});
+252 -106
View File
@@ -7,6 +7,7 @@ import os from 'node:os';
import { Readable } from 'node:stream';
import promiseRetry from 'promise-retry';
import Log from '../log';
import { AssetFileEntry } from './assets';
import {
createMultipartBodyFromFilesAsync,
@@ -15,7 +16,86 @@ import {
} from './utils/multipart';
const MAX_CONCURRENCY = Math.min(10, Math.max(os.availableParallelism() * 2, 20));
const MAX_RETRIES = 4;
const MAX_RETRY_WARNING_DELAY_MS = 30_000;
const UPLOAD_RETRY_LIMITS: RetryLimits = { retries: 4, maxTimeout: 5_000, maxRetryTime: 20_000 };
const API_RETRY_LIMITS: RetryLimits = { retries: 6, maxTimeout: 5_000, maxRetryTime: 30_000 };
interface SharedRetryState {
hasSeenNetworkError: boolean;
hasWarned: boolean;
}
interface RetryState {
shared: SharedRetryState;
firstRetryAt?: number;
}
interface UploadRetryOptions {
totalRequests?: number;
state?: RetryState;
deadlineAt?: number;
}
interface RetryLimits {
retries: number;
maxTimeout: number;
maxRetryTime: number;
}
interface RetryOptions {
retries: number;
factor: number;
minTimeout: number;
maxTimeout: number;
maxRetryTime: number;
randomize: boolean;
}
interface RetryWarningContext {
state: RetryState;
warningDelayMs: number;
subject: string;
}
const getRetryScale = (totalRequests: number): number =>
Math.min(1, Math.log10(Math.max(1, totalRequests)) / 3);
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const getRetryOptions = (
limits: RetryLimits,
totalRequests: number,
networkMode: boolean
): RetryOptions => {
const scale = getRetryScale(totalRequests);
return {
retries: Math.round(limits.retries * (1 + scale)) + (networkMode ? 2 : 0),
factor: 2,
minTimeout: 1_000,
maxTimeout: Math.round(limits.maxTimeout * (1 + scale)),
maxRetryTime: Math.round(limits.maxRetryTime * (1 + scale)),
randomize: true,
};
};
const getRetryWarningDelay = (maxRetryTime: number): number =>
Math.min(MAX_RETRY_WARNING_DELAY_MS, Math.round(maxRetryTime / 2));
const retryWithWarning = (
retry: (error: unknown) => never,
error: unknown,
attempt: number,
{ state, warningDelayMs, subject }: RetryWarningContext
): never => {
state.firstRetryAt ??= Date.now();
if (!state.shared.hasWarned && attempt > 1 && Date.now() - state.firstRetryAt >= warningDelayMs) {
state.shared.hasWarned = true;
Log.warn(`${subject} encountered an error but is still retrying: ${getErrorMessage(error)}`);
}
return retry(error);
};
export type UploadPayload =
| { filePath: string }
@@ -56,110 +136,156 @@ type OnProgressUpdateCallback = (progress: number) => void;
export async function uploadAsync(
init: UploadRequestInit,
payload: UploadPayload,
onProgressUpdate?: OnProgressUpdateCallback
onProgressUpdate?: OnProgressUpdateCallback,
retryOptions: UploadRetryOptions = {},
networkMode = retryOptions.state?.shared.hasSeenNetworkError ?? false
): Promise<UploadResult> {
return await promiseRetry(
async retry => {
if (onProgressUpdate) {
onProgressUpdate(0);
}
const headers = new Headers(init.headers);
const url = new URL(`${init.baseURL}`);
let errorPrefix: string;
let body: BodyInit | undefined;
let method = init.method || 'POST';
if ('asset' in payload) {
const { asset } = payload;
errorPrefix = `Upload of "${asset.normalizedPath}" failed`;
if (asset.type) {
headers.set('content-type', asset.type);
}
if (asset.size) {
headers.set('content-length', `${asset.size}`);
}
method = 'POST';
url.pathname = `/asset/${asset.sha512}`;
body = Readable.from(createReadStreamAsync(asset), { objectMode: false });
} else if ('filePath' in payload) {
const { filePath } = payload;
errorPrefix = 'Worker deployment failed';
body = fs.createReadStream(filePath);
} else if ('multipart' in payload) {
const { multipart } = payload;
errorPrefix = `Upload of ${multipart.length} assets failed`;
headers.set('content-type', multipartContentType);
method = 'PATCH';
url.pathname = '/asset/batch';
body = Readable.from(createMultipartBodyFromFilesAsync(multipart, onProgressUpdate), {
objectMode: false,
});
}
let response: Response;
try {
response = await fetch(url, {
method,
body,
headers,
agent: getAgent(),
signal: init.signal as any,
});
} catch (error) {
return retry(error);
}
const getErrorMessageAsync = async (): Promise<string> => {
const rayId = response.headers.get('cf-ray');
const contentType = response.headers.get('Content-Type');
if (contentType?.startsWith('text/html')) {
// NOTE(@kitten): We've received a CDN error most likely. There's not much we can do
// except for quoting the Request ID, so a user can send it to us. We can check
// why a request was blocked by looking up a WAF event via the "Ray ID" here:
// https://dash.cloudflare.com/e6f39f67f543faa6038768e8f37e4234/expo.app/security/events
let message = `CDN firewall has aborted the upload with ${response.statusText}.`;
if (rayId) {
message += `\nReport this error quoting Request ID ${rayId}`;
}
return `${errorPrefix}: ${message}`;
} else {
const json = await response.json().catch(() => null);
return json?.error ?? `${errorPrefix}: ${response.statusText}`;
}
};
if (
response.status === 408 ||
response.status === 409 ||
response.status === 429 ||
(response.status >= 500 && response.status <= 599)
) {
return retry(new Error(await getErrorMessageAsync()));
} else if (response.status === 413) {
const message = `${errorPrefix!}: File size exceeded the upload limit`;
throw new Error(message);
} else if (!response.ok) {
throw new Error(await getErrorMessageAsync());
} else if (onProgressUpdate) {
onProgressUpdate(1);
}
return {
payload,
response,
};
},
{
retries: MAX_RETRIES,
minTimeout: 50,
randomize: false,
}
const state = retryOptions.state ?? {
shared: { hasSeenNetworkError: false, hasWarned: false },
};
const retryOptionsForAttempts = getRetryOptions(
UPLOAD_RETRY_LIMITS,
retryOptions.totalRequests ?? 1,
networkMode
);
const warningDelayMs = getRetryWarningDelay(retryOptionsForAttempts.maxRetryTime);
const deadlineAt = retryOptions.deadlineAt ?? Date.now() + retryOptionsForAttempts.maxRetryTime;
retryOptionsForAttempts.maxRetryTime = Math.max(0, deadlineAt - Date.now());
const warningContext: RetryWarningContext = {
state,
warningDelayMs,
subject: 'The upload',
};
return await promiseRetry(async (retry, attempt) => {
if (onProgressUpdate) {
onProgressUpdate(0);
}
const headers = new Headers(init.headers);
const url = new URL(`${init.baseURL}`);
let errorPrefix: string;
let body: BodyInit | undefined;
let method = init.method || 'POST';
if ('asset' in payload) {
const { asset } = payload;
errorPrefix = `Upload of "${asset.normalizedPath}" failed`;
if (asset.type) {
headers.set('content-type', asset.type);
}
if (asset.size) {
headers.set('content-length', `${asset.size}`);
}
method = 'POST';
url.pathname = `/asset/${asset.sha512}`;
body = Readable.from(createReadStreamAsync(asset), { objectMode: false });
} else if ('filePath' in payload) {
const { filePath } = payload;
errorPrefix = 'Worker deployment failed';
body = fs.createReadStream(filePath);
} else if ('multipart' in payload) {
const { multipart } = payload;
errorPrefix = `Upload of ${multipart.length} assets failed`;
headers.set('content-type', multipartContentType);
method = 'PATCH';
url.pathname = '/asset/batch';
body = Readable.from(createMultipartBodyFromFilesAsync(multipart, onProgressUpdate), {
objectMode: false,
});
}
let response: Response;
try {
response = await fetch(url, {
method,
body,
headers,
agent: getAgent(),
signal: init.signal as any,
});
} catch (error) {
if (init.signal?.aborted) {
throw error;
}
if (!networkMode) {
state.shared.hasSeenNetworkError = true;
return await uploadAsync(
init,
payload,
onProgressUpdate,
{ ...retryOptions, state, deadlineAt },
true
);
}
return retryWithWarning(retry, error, attempt, warningContext);
}
const getErrorMessageAsync = async (): Promise<string> => {
const rayId = response.headers.get('cf-ray');
const contentType = response.headers.get('Content-Type');
if (contentType?.startsWith('text/html')) {
// NOTE(@kitten): We've received a CDN error most likely. There's not much we can do
// except for quoting the Request ID, so a user can send it to us. We can check
// why a request was blocked by looking up a WAF event via the "Ray ID" here:
// https://dash.cloudflare.com/e6f39f67f543faa6038768e8f37e4234/expo.app/security/events
let message = `CDN firewall has aborted the upload with ${response.statusText}.`;
if (rayId) {
message += `\nReport this error quoting Request ID ${rayId}`;
}
return `${errorPrefix}: ${message}`;
} else {
const json = await response.json().catch(() => null);
return json?.error ?? `${errorPrefix}: ${response.statusText}`;
}
};
if (
response.status === 408 ||
response.status === 409 ||
response.status === 429 ||
(response.status >= 500 && response.status <= 599)
) {
return retryWithWarning(
retry,
new Error(await getErrorMessageAsync()),
attempt,
warningContext
);
} else if (response.status === 413) {
const message = `${errorPrefix!}: File size exceeded the upload limit`;
throw new Error(message);
} else if (!response.ok) {
throw new Error(await getErrorMessageAsync());
} else if (onProgressUpdate) {
onProgressUpdate(1);
}
state.shared.hasSeenNetworkError = false;
return {
payload,
response,
};
}, retryOptionsForAttempts);
}
export async function callUploadApiAsync(url: string | URL, init?: RequestInit): Promise<unknown> {
return await promiseRetry(async retry => {
async function callUploadApiWithRetryAsync(
url: string | URL,
init: RequestInit | undefined,
networkMode = false,
state: RetryState = { shared: { hasSeenNetworkError: false, hasWarned: false } },
deadlineAt?: number
): Promise<unknown> {
const retryOptions = getRetryOptions(API_RETRY_LIMITS, 1, networkMode);
const warningDelayMs = getRetryWarningDelay(retryOptions.maxRetryTime);
const retryDeadlineAt = deadlineAt ?? Date.now() + retryOptions.maxRetryTime;
retryOptions.maxRetryTime = Math.max(0, retryDeadlineAt - Date.now());
const warningContext: RetryWarningContext = {
state,
warningDelayMs,
subject: 'The deployment',
};
return await promiseRetry(async (retry, attempt) => {
let response: Response;
try {
response = await fetch(url, {
@@ -167,17 +293,33 @@ export async function callUploadApiAsync(url: string | URL, init?: RequestInit):
agent: getAgent(),
});
} catch (error) {
return retry(error);
if (init?.signal?.aborted) {
throw error;
}
if (!networkMode) {
state.shared.hasSeenNetworkError = true;
return await callUploadApiWithRetryAsync(url, init, true, state, retryDeadlineAt);
}
return retryWithWarning(retry, error, attempt, warningContext);
}
if (response.status >= 500 && response.status <= 599) {
retry(new Error(`Deployment failed: ${response.statusText}`));
retryWithWarning(
retry,
new Error(`Deployment failed: ${response.statusText}`),
attempt,
warningContext
);
}
try {
return await response.json();
} catch (error) {
retry(error);
return retryWithWarning(retry, error, attempt, warningContext);
}
});
}, retryOptions);
}
export async function callUploadApiAsync(url: string | URL, init?: RequestInit): Promise<unknown> {
return await callUploadApiWithRetryAsync(url, init);
}
export interface UploadPending {
@@ -192,6 +334,7 @@ export async function* batchUploadAsync(
): AsyncGenerator<UploadPending> {
const progressTracker = new Array(payloads.length).fill(0);
const controller = new AbortController();
const sharedRetryState: SharedRetryState = { hasSeenNetworkError: false, hasWarned: false };
const queue = new Set<Promise<UploadResult>>();
const initWithSignal = { ...init, signal: controller.signal };
const getProgressValue = (): number => {
@@ -216,7 +359,10 @@ export async function* batchUploadAsync(
progressTracker[currentIndex] = progress;
sendProgressUpdate();
});
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).then(
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate, {
totalRequests: payloads.length,
state: { shared: sharedRetryState },
}).then(
result => {
queue.delete(uploadPromise);
progressTracker[currentIndex] = 1;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "23.0.0",
"version": "23.1.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": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "23.0.0",
"version": "23.1.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "23.0.0",
"@expo/eas-build-job": "23.0.0",
"@expo/logger": "23.0.0",
"@expo/build-tools": "23.1.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "23.1.0",
"@expo/logger": "23.1.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "23.0.0",
"bunyan": "^1.8.15",
"@expo/turtle-spawn": "23.1.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
@@ -43,7 +43,6 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/bunyan": "^1.8.11",
"@types/fs-extra": "^11.0.4",
"@types/hapi__joi": "^17.1.14",
"@types/lodash": "^4.17.4",
+1 -1
View File
@@ -1,6 +1,6 @@
import { Artifacts, SkipNativeBuildError } from '@expo/build-tools';
import { ArchiveSourceType, BuildJob, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { DEBUG } from 'bunyan';
import { DEBUG } from '@expo/bunyan';
import chalk from 'chalk';
import fs from 'fs-extra';
import pickBy from 'lodash/pickBy';
+6 -3
View File
@@ -1,6 +1,7 @@
import { LogBuffer } from '@expo/build-tools';
import { LoggerLevel } from '@expo/logger';
import bunyan from 'bunyan';
import type { bunyan as ExpoLogger } from '@expo/logger';
import bunyan from '@expo/bunyan';
import chalk from 'chalk';
import omit from 'lodash/omit';
import { Writable } from 'stream';
@@ -129,7 +130,9 @@ class PrettyStream extends Writable {
export const logBuffer = new BuildCliLogBuffer(MAX_LINES_IN_BUFFER);
export function createLogger(level?: LoggerLevel): bunyan {
export function createLogger(level?: LoggerLevel): ExpoLogger {
// @expo/bunyan provides the Bunyan API used here, but its stricter stream types are not
// structurally compatible with the legacy @types/bunyan types exposed by @expo/logger.
return bunyan.createLogger({
name: 'eas-build-cli',
serializers: bunyan.stdSerializers,
@@ -143,5 +146,5 @@ export function createLogger(level?: LoggerLevel): bunyan {
stream: logBuffer,
},
],
});
}) as unknown as ExpoLogger;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "23.0.0",
"version": "23.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+192
View File
@@ -0,0 +1,192 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import bunyan from 'bunyan';
import defaultLogger, { LoggerLevel, PipeMode, createLogger, pipeSpawnOutput } from '../index';
// Collects raw bunyan records written to the logger.
function createRecordCollector(logger: ReturnType<typeof createLogger>): any[] {
const records: any[] = [];
logger.addStream({
type: 'raw',
stream: {
write(record: any) {
records.push(record);
},
} as any,
level: bunyan.TRACE,
});
return records;
}
function createSilentLogger(): ReturnType<typeof createLogger> {
const logger = createLogger({ name: 'test-logger', streams: [] });
return logger;
}
describe(createLogger, () => {
it('creates a logger that writes records with name, level, and msg', () => {
const logger = createSilentLogger();
const records = createRecordCollector(logger);
logger.info('hello world');
expect(records).toHaveLength(1);
expect(records[0].name).toBe('test-logger');
expect(records[0].level).toBe(bunyan.INFO);
expect(records[0].msg).toBe('hello world');
});
it('supports structured fields and util.format-style messages', () => {
const logger = createSilentLogger();
const records = createRecordCollector(logger);
logger.info({ phase: 'INSTALL_DEPENDENCIES' }, 'step %d done', 2);
expect(records[0].phase).toBe('INSTALL_DEPENDENCIES');
expect(records[0].msg).toBe('step 2 done');
});
it('serializes errors with the standard err serializer', () => {
const logger = createSilentLogger();
const records = createRecordCollector(logger);
logger.error(new Error('boom'));
expect(records[0].err.message).toBe('boom');
expect(records[0].err.stack).toEqual(expect.stringContaining('boom'));
expect(records[0].msg).toBe('boom');
});
it('creates child loggers that inherit fields and add their own', () => {
const logger = createSilentLogger();
const records = createRecordCollector(logger);
const child = logger.child({ source: 'stdout' });
child.warn('from child');
expect(records[0].name).toBe('test-logger');
expect(records[0].source).toBe('stdout');
expect(records[0].level).toBe(bunyan.WARN);
});
it('respects the configured level', () => {
const logger = createLogger({ name: 'test-logger', streams: [] });
const records: any[] = [];
logger.addStream({
type: 'raw',
stream: {
write(record: any) {
records.push(record);
},
} as any,
level: bunyan.INFO,
});
logger.debug('too quiet');
logger.info('loud enough');
expect(records).toHaveLength(1);
expect(records[0].msg).toBe('loud enough');
});
it('reports and sets the level programmatically', () => {
const logger = createLogger({
name: 'test-logger',
streams: [
{
type: 'raw',
stream: { write() {} } as any,
level: LoggerLevel.INFO,
},
],
});
expect(logger.level()).toBe(bunyan.INFO);
logger.level(LoggerLevel.DEBUG);
expect(logger.level()).toBe(bunyan.DEBUG);
});
it('supports rotating-file streams', async () => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'expo-logger-test-'));
const logPath = path.join(directory, 'rotating.log');
let rotatingStream: any;
try {
const logger = createLogger({
name: 'test-logger',
streams: [
{
type: 'rotating-file',
path: logPath,
period: '1d',
count: 1,
},
],
});
rotatingStream = (logger as any).streams[0].stream;
logger.info('written to a rotating file');
await new Promise<void>((resolve, reject) => {
rotatingStream.stream.once('error', reject);
rotatingStream.stream.end(resolve);
});
expect(await fs.promises.readFile(logPath, 'utf8')).toContain('written to a rotating file');
} finally {
clearTimeout(rotatingStream?.timeout);
rotatingStream?.stream.destroy();
await fs.promises.rm(directory, { recursive: true, force: true });
}
});
});
describe('defaultLogger', () => {
it('is configured with the expo-logger name at INFO level', () => {
expect(defaultLogger.fields.name).toBe('expo-logger');
expect(defaultLogger.level()).toBe(bunyan.INFO);
});
});
describe(pipeSpawnOutput, () => {
async function pipeAndCollect(
stdoutData: string | null,
stderrData: string | null,
options?: Parameters<typeof pipeSpawnOutput>[2]
): Promise<any[]> {
const logger = createSilentLogger();
const records = createRecordCollector(logger);
const stdout = stdoutData === null ? null : Readable.from([stdoutData]);
const stderr = stderrData === null ? null : Readable.from([stderrData]);
pipeSpawnOutput(logger, { stdout, stderr }, options);
await new Promise(resolve => setImmediate(resolve));
return records;
}
it('logs stdout and stderr lines tagged with their source', async () => {
const records = await pipeAndCollect('out line 1\nout line 2\n', 'err line\n');
const stdoutRecords = records.filter(r => r.source === 'stdout');
const stderrRecords = records.filter(r => r.source === 'stderr');
expect(stdoutRecords.map(r => r.msg)).toEqual(['out line 1', 'out line 2']);
expect(stderrRecords.map(r => r.msg)).toEqual(['err line']);
});
it('tags stderr as stdout in COMBINED_AS_STDOUT mode', async () => {
const records = await pipeAndCollect('out\n', 'err\n', { mode: PipeMode.COMBINED_AS_STDOUT });
expect(records).toHaveLength(2);
expect(records.every(r => r.source === 'stdout')).toBe(true);
});
it('applies the line transformer and drops lines it nulls out', async () => {
const records = await pipeAndCollect('keep\ndrop\n', null, {
lineTransformer: line => (line === 'drop' ? null : `[prefix] ${line}`),
});
expect(records.map(r => r.msg)).toEqual(['[prefix] keep']);
});
});
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "23.0.0",
"version": "23.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "23.0.0",
"@expo/logger": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/logger": "23.1.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
@@ -36,8 +36,7 @@
"joi": "^17.13.1",
"jsep": "^1.3.8",
"lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2",
"uuid": "^9.0.1",
"uuid": "^11.1.1",
"yaml": "^2.4.3",
"zod": "^4.3.5"
},
@@ -45,8 +44,8 @@
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.get": "^4.4.9",
"@types/node": "20.14.2",
"bunyan": "^1.8.15",
"eslint-plugin-async-protect": "^3.1.0",
"jest": "^29.7.0",
"rimraf": "3.0.2",
@@ -0,0 +1,92 @@
import { get, parsePath } from '../get';
describe(parsePath, () => {
it('splits dot-separated paths', () => {
expect(parsePath('steps.step1.outputs.out')).toEqual(['steps', 'step1', 'outputs', 'out']);
});
it('parses bracketed array indices', () => {
expect(parsePath('list[0].name')).toEqual(['list', '0', 'name']);
expect(parsePath('a[0][1]')).toEqual(['a', '0', '1']);
});
it('parses bracketed keys without quotes as emitted by jsepEval', () => {
expect(parsePath('a[b]')).toEqual(['a', 'b']);
});
it('parses quoted bracketed keys, keeping dots inside them intact', () => {
expect(parsePath('a["b.c"]')).toEqual(['a', 'b.c']);
expect(parsePath("a['b']")).toEqual(['a', 'b']);
});
it('throws on unterminated brackets', () => {
expect(() => parsePath('a[b')).toThrow('unterminated bracket');
expect(() => parsePath('a["b]')).toThrow('unterminated quoted bracket');
});
});
describe(get, () => {
const context = {
steps: {
step1: {
outputs: {
my_output: 'value1',
},
},
},
eas: {
job: {
version: { buildNumber: '42' },
secrets: null,
list: ['first', { name: 'second' }],
},
runtimeVersion: undefined,
},
};
it('resolves dot-separated paths, as used by template interpolation', () => {
expect(get(context, 'steps.step1.outputs.my_output')).toBe('value1');
expect(get(context, 'eas.job.version.buildNumber')).toBe('42');
});
it('resolves single identifiers, as used by jsepEval', () => {
expect(get(context, 'steps')).toBe(context.steps);
});
it('resolves array indices in both bracket and dot form', () => {
expect(get(context, 'eas.job.list[0]')).toBe('first');
expect(get(context, 'eas.job.list[1].name')).toBe('second');
expect(get(context, 'eas.job.list.1.name')).toBe('second');
});
it('resolves computed member paths emitted by jsepEval, e.g. a[b]', () => {
expect(get(context, 'eas[job]')).toBe(context.eas.job);
expect(get({ a: { b: 1 } }, 'a[b]')).toBe(1);
});
it('returns undefined for missing paths instead of throwing', () => {
expect(get(context, 'eas.job.doesNotExist')).toBe(undefined);
expect(get(context, 'eas.job.doesNotExist.nested')).toBe(undefined);
expect(get(context, 'doesNotExist.at.all')).toBe(undefined);
});
it('returns undefined when traversing through null or undefined', () => {
expect(get(context, 'eas.job.secrets.robotAccessToken')).toBe(undefined);
expect(get(context, 'eas.runtimeVersion.anything')).toBe(undefined);
expect(get(null, 'a.b')).toBe(undefined);
expect(get(undefined, 'a')).toBe(undefined);
});
it('returns null values as-is rather than undefined', () => {
expect(get(context, 'eas.job.secrets')).toBe(null);
});
it('prefers a verbatim own property over a nested path, like lodash.get', () => {
expect(get({ 'a.b': 'flat', a: { b: 'nested' } }, 'a.b')).toBe('flat');
expect(get({ a: { b: 'nested' } }, 'a.b')).toBe('nested');
});
it('reads properties of primitives encountered mid-path', () => {
expect(get({ a: 'text' }, 'a.length')).toBe(4);
});
});
+60
View File
@@ -0,0 +1,60 @@
// Minimal replacement for lodash.get, covering the path shapes this package
// produces: dot-separated keys (`steps.step1.outputs.out`), bracketed array
// indices (`list[0].name`), and bracketed keys with or without quotes
// (`a[b]`, `a["b.c"]`, `a['b']`) as emitted by jsepEval's getParameterPath.
const QUOTE_CHARACTERS = ["'", '"'];
export function parsePath(path: string): string[] {
const keys: string[] = [];
let index = 0;
while (index < path.length) {
const character = path[index];
if (character === '.') {
index += 1;
} else if (character === '[') {
const maybeQuote = path[index + 1];
if (QUOTE_CHARACTERS.includes(maybeQuote)) {
const closingQuoteIndex = path.indexOf(`${maybeQuote}]`, index + 2);
if (closingQuoteIndex === -1) {
throw new Error(`Invalid path: unterminated quoted bracket in "${path}"`);
}
keys.push(path.slice(index + 2, closingQuoteIndex));
index = closingQuoteIndex + 2;
} else {
const closingBracketIndex = path.indexOf(']', index + 1);
if (closingBracketIndex === -1) {
throw new Error(`Invalid path: unterminated bracket in "${path}"`);
}
keys.push(path.slice(index + 1, closingBracketIndex));
index = closingBracketIndex + 1;
}
} else {
let end = index;
while (end < path.length && path[end] !== '.' && path[end] !== '[') {
end += 1;
}
keys.push(path.slice(index, end));
index = end;
}
}
return keys;
}
export function get(object: unknown, path: string): any {
// Like lodash.get, a path that exists verbatim as an own property wins
// over interpreting it as a nested path.
if (object != null && typeof object === 'object' && Object.hasOwn(object, path)) {
return (object as Record<string, unknown>)[path];
}
let current: any = object;
for (const key of parsePath(path)) {
if (current == null) {
return undefined;
}
current = current[key];
}
return current;
}
export default get;
+2 -1
View File
@@ -6,7 +6,8 @@
import assert from 'assert';
import jsep from 'jsep';
import get from 'lodash.get';
import { get } from './get';
const binaryOperatorFunctions = {
'===': (a: any, b: any) => a === b,
+1 -1
View File
@@ -1,6 +1,6 @@
import cloneDeep from 'lodash.clonedeep';
import get from 'lodash.get';
import { get } from './get';
import { nullthrows } from './nullthrows';
import { BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildConfigError, BuildStepRuntimeError } from '../errors';
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "23.0.0",
"version": "23.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "23.0.0",
"@expo/logger": "23.1.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "23.0.0",
"version": "23.1.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,17 +19,17 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "23.0.0",
"@expo/build-tools": "23.1.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "23.0.0",
"@expo/logger": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/logger": "23.1.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.0.0",
"@expo/steps": "23.1.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.0.0",
"@expo/turtle-spawn": "23.1.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
Generated
+74 -97
View File
@@ -2291,24 +2291,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:23.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:23.1.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.1.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:23.1.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:23.0.0"
"@expo/steps": "npm:23.1.0"
"@expo/template-file": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.1.0"
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
@@ -2326,7 +2326,6 @@ __metadata:
"@types/uuid": "npm:^9.0.8"
"@urql/core": "npm:^6.0.1"
bplist-parser: "npm:0.3.2"
bunyan: "npm:^1.8.15"
content-disposition: "npm:1.0.1"
fast-glob: "npm:^3.3.2"
fast-xml-parser: "npm:^4.4.1"
@@ -2376,6 +2375,15 @@ __metadata:
languageName: node
linkType: hard
"@expo/bunyan@npm:^4.0.1":
version: 4.0.1
resolution: "@expo/bunyan@npm:4.0.1"
dependencies:
uuid: "npm:^8.0.0"
checksum: 10c0/ebbec51c7b19dcfcbd981da9c1c6262c0dc03ea118356fefca3b427f445308845fc33d97da92350d68fda174f9f1d5ee95ed3ac978f1f6cc88de73d785b909cc
languageName: node
linkType: hard
"@expo/code-signing-certificates@npm:0.0.5":
version: 0.0.5
resolution: "@expo/code-signing-certificates@npm:0.0.5"
@@ -2549,13 +2557,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:23.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:23.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:23.1.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.1.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
jest: "npm:^29.7.0"
@@ -2568,12 +2576,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:23.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:23.1.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.1.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2687,7 +2695,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:23.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:23.1.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -2887,19 +2895,19 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:23.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:23.1.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:23.0.0"
"@expo/logger": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.1.0"
"@expo/logger": "npm:23.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/lodash.clonedeep": "npm:^4.5.9"
"@types/lodash.get": "npm:^4.4.9"
"@types/node": "npm:20.14.2"
arg: "npm:^5.0.2"
bunyan: "npm:^1.8.15"
eslint-plugin-async-protect: "npm:^3.1.0"
fast-glob: "npm:^3.3.2"
fs-extra: "npm:^11.2.0"
@@ -2907,13 +2915,12 @@ __metadata:
joi: "npm:^17.13.1"
jsep: "npm:^1.3.8"
lodash.clonedeep: "npm:^4.5.0"
lodash.get: "npm:^4.4.2"
rimraf: "npm:3.0.2"
ts-jest: "npm:^29.1.4"
ts-mockito: "npm:^2.6.1"
ts-node: "npm:^10.9.2"
typescript: "npm:^5.5.4"
uuid: "npm:^9.0.1"
uuid: "npm:^11.1.1"
yaml: "npm:^2.4.3"
zod: "npm:^4.3.5"
languageName: unknown
@@ -2942,11 +2949,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:23.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:23.1.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:23.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -2962,17 +2969,17 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:23.0.0"
"@expo/build-tools": "npm:23.1.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/logger": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.1.0"
"@expo/logger": "npm:23.1.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:23.0.0"
"@expo/steps": "npm:23.1.0"
"@expo/template-file": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.1.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -7358,15 +7365,6 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash.get@npm:^4.4.9":
version: 4.4.9
resolution: "@types/lodash.get@npm:4.4.9"
dependencies:
"@types/lodash": "npm:*"
checksum: 10c0/934ae4f02831afdb9216cb5fcb702f8f4e0b45e13f2d10ed9c7ce7e891800e5dea726b513c98b8370d6292d42ad7745c622ae75733676746b711bea95b6259d5
languageName: node
linkType: hard
"@types/lodash.template@npm:^4.5.3":
version: 4.5.3
resolution: "@types/lodash.template@npm:4.5.3"
@@ -7762,13 +7760,6 @@ __metadata:
languageName: node
linkType: hard
"@types/uuid@npm:9.0.7":
version: 9.0.7
resolution: "@types/uuid@npm:9.0.7"
checksum: 10c0/b329ebd4f9d1d8e08d4f2cc211be4922d70d1149f73d5772630e4a3acfb5170c6d37b3d7a39a0412f1a56e86e8a844c7f297c798b082f90380608bf766688787
languageName: node
linkType: hard
"@types/uuid@npm:^9.0.7, @types/uuid@npm:^9.0.8":
version: 9.0.8
resolution: "@types/uuid@npm:9.0.8"
@@ -8027,12 +8018,12 @@ __metadata:
languageName: node
linkType: hard
"agent-cli-detector@npm:0.1.6":
version: 0.1.6
resolution: "agent-cli-detector@npm:0.1.6"
"agent-cli-detector@npm:0.1.7":
version: 0.1.7
resolution: "agent-cli-detector@npm:0.1.7"
bin:
agent-cli-detector: dist/cli.js
checksum: 10c0/0653c78987a7382ebd6e19334a2c9362eae700486614354fcf4d10b173202997b7b330545391deab343fe830930ea1b1db90c0d2a5106e6e9a3254abeef6ed6d
checksum: 10c0/4ff016e5fad2d96a0c53db9fd75c2359bb005a672969e8e6e7bce5c910e94107c226cbcba1303409d1da3a1cdf8f22290e63049705bb530823cce4699d39fc1d
languageName: node
linkType: hard
@@ -9753,7 +9744,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:23.0.0"
"@expo/steps": "npm:23.1.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10337,18 +10328,17 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/logger": "npm:23.0.0"
"@expo/build-tools": "npm:23.1.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:23.1.0"
"@expo/logger": "npm:23.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:23.0.0"
"@types/bunyan": "npm:^1.8.11"
"@expo/turtle-spawn": "npm:23.1.0"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
"@types/lodash": "npm:^4.17.4"
"@types/semver": "npm:^7.5.8"
"@types/uuid": "npm:^9.0.7"
bunyan: "npm:^1.8.15"
chalk: "npm:4.1.2"
env-paths: "npm:^2.2.1"
fs-extra: "npm:^11.2.0"
@@ -10395,11 +10385,11 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/eas-json": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.1.0"
"@expo/eas-json": "npm:23.1.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:23.1.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10411,7 +10401,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:23.0.0"
"@expo/steps": "npm:23.1.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -10440,11 +10430,10 @@ __metadata:
"@types/tar": "npm:6.1.10"
"@types/tar-stream": "npm:3.1.3"
"@types/tough-cookie": "npm:4.0.2"
"@types/uuid": "npm:9.0.7"
"@types/wrap-ansi": "npm:3.0.0"
"@urql/core": "npm:4.0.11"
"@urql/exchange-retry": "npm:1.2.0"
agent-cli-detector: "npm:0.1.6"
agent-cli-detector: "npm:0.1.7"
ajv: "npm:8.11.0"
ajv-formats: "npm:2.1.1"
axios: "npm:1.18.1"
@@ -10480,7 +10469,7 @@ __metadata:
memfs: "npm:3.4.13"
mime: "npm:3.0.0"
minimatch: "npm:5.1.2"
minizlib: "npm:3.0.1"
minizlib: "npm:3.1.0"
mockdate: "npm:3.0.5"
nanoid: "npm:3.3.8"
nock: "npm:13.4.0"
@@ -10512,7 +10501,7 @@ __metadata:
turndown: "npm:7.1.2"
typescript: "npm:5.5.4"
untildify: "npm:4.0.0"
uuid: "npm:9.0.1"
uuid: "npm:11.1.1"
wrap-ansi: "npm:7.0.0"
yaml: "npm:2.6.0"
zod: "npm:^4.1.3"
@@ -11699,7 +11688,7 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2":
"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.2":
version: 10.5.0
resolution: "glob@npm:10.5.0"
dependencies:
@@ -14236,7 +14225,7 @@ __metadata:
languageName: node
linkType: hard
"lodash.get@npm:^4, lodash.get@npm:^4.4.2":
"lodash.get@npm:^4":
version: 4.4.2
resolution: "lodash.get@npm:4.4.2"
checksum: 10c0/48f40d471a1654397ed41685495acb31498d5ed696185ac8973daef424a749ca0c7871bf7b665d5c14f5cc479394479e0307e781f61d5573831769593411be6e
@@ -15026,13 +15015,12 @@ __metadata:
languageName: node
linkType: hard
"minizlib@npm:3.0.1":
version: 3.0.1
resolution: "minizlib@npm:3.0.1"
"minizlib@npm:3.1.0, minizlib@npm:^3.0.1, minizlib@npm:^3.1.0":
version: 3.1.0
resolution: "minizlib@npm:3.1.0"
dependencies:
minipass: "npm:^7.0.4"
rimraf: "npm:^5.0.5"
checksum: 10c0/82f8bf70da8af656909a8ee299d7ed3b3372636749d29e105f97f20e88971be31f5ed7642f2e898f00283b68b701cc01307401cdc209b0efc5dd3818220e5093
minipass: "npm:^7.1.2"
checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
languageName: node
linkType: hard
@@ -15046,15 +15034,6 @@ __metadata:
languageName: node
linkType: hard
"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0":
version: 3.1.0
resolution: "minizlib@npm:3.1.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
languageName: node
linkType: hard
"mkdirp@npm:^1.0.3":
version: 1.0.4
resolution: "mkdirp@npm:1.0.4"
@@ -17404,17 +17383,6 @@ __metadata:
languageName: node
linkType: hard
"rimraf@npm:^5.0.5":
version: 5.0.7
resolution: "rimraf@npm:5.0.7"
dependencies:
glob: "npm:^10.3.7"
bin:
rimraf: dist/esm/bin.mjs
checksum: 10c0/bd6dbfaa98ae34ce1e54d1e06045d2d63e8859d9a1979bb4a4628b652b459a2d17b17dc20ee072b034bd2d09bd691e801d24c4d9cfe94e16fdbcc8470a1d4807
languageName: node
linkType: hard
"rimraf@npm:~2.4.0":
version: 2.4.5
resolution: "rimraf@npm:2.4.5"
@@ -19438,6 +19406,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:11.1.1, uuid@npm:^11.1.1":
version: 11.1.1
resolution: "uuid@npm:11.1.1"
bin:
uuid: dist/esm/bin/uuid
checksum: 10c0/9e3af58eba872ece5a5e76f4773a94fc78a0ef2c2444c38dbe6b42f41dadf76c01850fd783604f27986f6195e6286aef064d45987d401b2a33127b98ddf7c0c5
languageName: node
linkType: hard
"uuid@npm:3.4.0":
version: 3.4.0
resolution: "uuid@npm:3.4.0"
@@ -19447,15 +19424,6 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:9.0.1, uuid@npm:^9.0.1":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
bin:
uuid: dist/bin/uuid
checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
languageName: node
linkType: hard
"uuid@npm:^11.1.0":
version: 11.1.0
resolution: "uuid@npm:11.1.0"
@@ -19483,6 +19451,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^9.0.1":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
bin:
uuid: dist/bin/uuid
checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
languageName: node
linkType: hard
"uuidv7@npm:1.1.0":
version: 1.1.0
resolution: "uuidv7@npm:1.1.0"