mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0658d58efd | ||
|
|
76a700c45c | ||
|
|
5ded193ccf | ||
|
|
93420d1fa0 | ||
|
|
add787f625 | ||
|
|
ec2c2aca5e | ||
|
|
1e86c48f48 | ||
|
|
545c2dbf9b | ||
|
|
3cb029147e | ||
|
|
d48cfbba69 | ||
|
|
07b5c0daa3 | ||
|
|
b78c84af2c | ||
|
|
dfaa2df90d | ||
|
|
2e2aaa4ac9 | ||
|
|
3ef0f74110 | ||
|
|
51ba7198d7 | ||
|
|
e10e6a2235 | ||
|
|
17f84cf7d6 | ||
|
|
f7d9b7b297 | ||
|
|
b287d7a4e0 | ||
|
|
d9d1b91fa5 |
No files matched your search
@@ -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
|
||||
@@ -81,10 +67,13 @@ jobs:
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
+33
-1
@@ -8,12 +8,44 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimental multi-account support behind `EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1`: `account:list`, `account:switch`, a global `--account` flag, and multi-account `account:login`/`account:logout`. ([#3340](https://github.com/expo/eas-cli/pull/3340) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [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
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.6.0",
|
||||
"version": "23.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.6.0",
|
||||
"version": "23.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,17 +39,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/downloader": "23.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "22.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": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/template-file": "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 = {
|
||||
|
||||
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -107,6 +111,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
|
||||
@@ -250,6 +260,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides the minimum release age only for the internal config command', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
job: { platform: 'ios', buildProfile: 'production' },
|
||||
} as any;
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
jest.mocked(spawn).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
|
||||
} as any);
|
||||
|
||||
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.env).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -236,6 +239,85 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(installDependenciesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
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();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
|
||||
@@ -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: {
|
||||
@@ -58,7 +62,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
|
||||
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('restores and saves caches around an iOS simulator build', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
const stepNames = steps.map(step => step.displayName);
|
||||
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
|
||||
const installPodsIndex = stepNames.indexOf('Install Pods');
|
||||
const saveCacheIndex = stepNames.indexOf('Save Cache');
|
||||
|
||||
expect(restoreCacheIndex).toBeGreaterThan(-1);
|
||||
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
|
||||
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
|
||||
|
||||
const restoreCacheStep = steps[restoreCacheIndex];
|
||||
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
},
|
||||
});
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../../utils/cocoapodsCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../restoreCache', () => ({
|
||||
decompressCacheAsync: jest.fn(),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
downloadPublicCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../saveCache', () => ({
|
||||
compressCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/cocoapodsCache', () => ({
|
||||
compressCocoapodsCacheAsync: jest.fn(),
|
||||
getCocoapodsCachePaths: jest.fn(),
|
||||
resolveCocoapodsCacheKeyAsync: jest.fn(),
|
||||
restoreCocoapodsCacheArchiveAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const env = {
|
||||
EAS_PODS_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const secrets = { robotAccessToken: 'robot-token' };
|
||||
|
||||
describe('CocoaPods build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
|
||||
'/workingdir/ios/Podfile.lock': 'lockfile',
|
||||
'/tmp/cocoapods-cache.tar.gz': 'archive',
|
||||
},
|
||||
'/'
|
||||
);
|
||||
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
|
||||
iosDirectory: '/workingdir/ios',
|
||||
podsDirectory: '/workingdir/ios/Pods',
|
||||
podfileLockPath: '/workingdir/ios/Podfile.lock',
|
||||
});
|
||||
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
|
||||
});
|
||||
|
||||
it('does nothing when the cache is disabled', async () => {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the newest matching CocoaPods cache', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
|
||||
});
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefixes: ['ios-pods-1.16.2-'],
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
workingDirectory: '/workingdir',
|
||||
});
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: 'prefix_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('compresses and saves the installed Pods directory', async () => {
|
||||
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
@@ -81,3 +87,71 @@ describe('cache compress/decompress round trip', () => {
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadCacheAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['normal upload', undefined, false],
|
||||
['forced upload', true, true],
|
||||
] as const)('sends force for %s', async (_name, force, expectedForce) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
|
||||
const archivePath = path.join(tempDir, 'cache.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, 'cache archive');
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
headers: {},
|
||||
url: 'https://storage.expo.test/cache',
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(async (_url, request) => {
|
||||
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
|
||||
for await (const _chunk of body) {
|
||||
// Consume the archive stream as a real upload would.
|
||||
}
|
||||
return new Response(undefined, { status: 200 });
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadCacheAsync({
|
||||
logger: createLoggerMock(),
|
||||
jobId: 'build-id',
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
robotAccessToken: 'robot-token',
|
||||
paths: ['/cache/path'],
|
||||
key: 'cache-key',
|
||||
archivePath,
|
||||
size: 13,
|
||||
platform: Platform.ANDROID,
|
||||
force,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
|
||||
expect(uploadSessionUrl.toString()).toBe(
|
||||
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
|
||||
);
|
||||
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
|
||||
buildId: 'build-id',
|
||||
force: expectedForce,
|
||||
key: 'cache-key',
|
||||
size: 13,
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
|
||||
'https://storage.expo.test/cache'
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getCcacheKeyPrefix,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
@@ -79,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -206,6 +218,67 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Restoring CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [podsDirectory],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
Datadog.log(`CocoaPods cache restored (${hitType})`, {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No CocoaPods cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore CocoaPods cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -78,11 +83,77 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
try {
|
||||
await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]);
|
||||
} catch {
|
||||
logger.warn('No CocoaPods installation found, skipping cache save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Saving CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing CocoaPods cache...');
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [podsDirectory],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save CocoaPods cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function uploadCacheAsync({
|
||||
archivePath,
|
||||
size,
|
||||
platform,
|
||||
force = false,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
jobId: string;
|
||||
@@ -113,6 +114,7 @@ export async function uploadCacheAsync({
|
||||
archivePath: string;
|
||||
size: number;
|
||||
platform: Platform | undefined;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const routerURL = platform
|
||||
? 'v2/turtle-builds/caches/upload-sessions'
|
||||
@@ -127,12 +129,14 @@ export async function uploadCacheAsync({
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
})
|
||||
: JSON.stringify({
|
||||
jobRunId: jobId,
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
|
||||
@@ -132,7 +132,7 @@ describe(createServeSimArgs, () => {
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'960',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
|
||||
@@ -21,7 +21,7 @@ import { turtleFetch } from '../../utils/turtleFetch';
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../cocoapodsCache';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('CocoaPods cache utilities', () => {
|
||||
let workingDirectory: string;
|
||||
const cocoapodsVersion = '1.16.2';
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-'));
|
||||
await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(workingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => {
|
||||
await expect(
|
||||
resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion)
|
||||
).resolves.toEqual({
|
||||
key: 'ios-pods-1.16.2-',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the Podfile.lock hash when it exists', async () => {
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n');
|
||||
|
||||
const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/);
|
||||
expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-');
|
||||
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n');
|
||||
const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(secondResult.key).not.toBe(firstResult.key);
|
||||
});
|
||||
|
||||
it('preserves symlinks and executable permissions through an archive round trip', async () => {
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions');
|
||||
const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A');
|
||||
await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary');
|
||||
await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current'));
|
||||
|
||||
const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh');
|
||||
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.promises.writeFile(scriptPath, '#!/bin/sh\n');
|
||||
await fs.promises.chmod(scriptPath, 0o755);
|
||||
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current');
|
||||
expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A');
|
||||
|
||||
const restoredScriptStat = await fs.promises.stat(scriptPath);
|
||||
expect(restoredScriptStat.mode & 0o111).toBe(0o111);
|
||||
|
||||
await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
@@ -133,7 +133,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
@@ -141,7 +141,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -173,9 +173,17 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the minimum release age for every npx invocation', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result.extraEnv).toEqual({ NPM_CONFIG_MIN_RELEASE_AGE: '0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(runEasCliCommand, () => {
|
||||
@@ -218,4 +226,25 @@ describe(runEasCliCommand, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides a user-provided minimum release age', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
await runEasCliCommand({
|
||||
args: ['deploy', '--json'],
|
||||
options: {
|
||||
cwd: '/tmp/project',
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const spawnEnv = jest.mocked(spawn).mock.calls[0][2]?.env;
|
||||
expect(spawnEnv).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
|
||||
|
||||
const PODS_DIRECTORY_NAME = 'Pods';
|
||||
const PODFILE_LOCK_NAME = 'Podfile.lock';
|
||||
|
||||
export function getCocoapodsCachePaths(workingDirectory: string): {
|
||||
iosDirectory: string;
|
||||
podsDirectory: string;
|
||||
podfileLockPath: string;
|
||||
} {
|
||||
const iosDirectory = path.join(workingDirectory, 'ios');
|
||||
return {
|
||||
iosDirectory,
|
||||
podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME),
|
||||
podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCocoapodsCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
cocoapodsVersion: string
|
||||
): Promise<{ key: string; keyPrefix: string }> {
|
||||
const normalizedVersion = cocoapodsVersion.trim();
|
||||
if (!normalizedVersion) {
|
||||
throw new Error('Failed to determine CocoaPods version');
|
||||
}
|
||||
|
||||
const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
try {
|
||||
await fs.promises.access(podfileLockPath);
|
||||
} catch {
|
||||
return { key: keyPrefix, keyPrefix };
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${hashFiles([podfileLockPath])}`,
|
||||
keyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressCocoapodsCacheAsync({
|
||||
workingDirectory,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.access(podsDirectory);
|
||||
|
||||
const archiveDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'save-cocoapods-cache-')
|
||||
);
|
||||
const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz');
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: iosDirectory,
|
||||
gzip: true,
|
||||
},
|
||||
[PODS_DIRECTORY_NAME]
|
||||
);
|
||||
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheArchiveAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.mkdir(iosDirectory, { recursive: true });
|
||||
|
||||
const temporaryRestoreDirectory = await fs.promises.mkdtemp(
|
||||
path.join(iosDirectory, '.eas-pods-cache-')
|
||||
);
|
||||
try {
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: temporaryRestoreDirectory,
|
||||
filter: entryPath =>
|
||||
entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
|
||||
});
|
||||
|
||||
const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
|
||||
const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory);
|
||||
if (!restoredPodsStat.isDirectory()) {
|
||||
throw new Error('CocoaPods cache archive does not contain a Pods directory');
|
||||
}
|
||||
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await fs.promises.rename(restoredPodsDirectory, podsDirectory);
|
||||
} finally {
|
||||
await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
@@ -74,21 +76,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.4.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": "22.4.0"
|
||||
"@expo/steps": "23.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "22.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": "22.0.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+154
-144
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "22.6.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": "22.0.0",
|
||||
"@expo/eas-json": "22.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": "22.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": "22.4.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",
|
||||
|
||||
@@ -30,7 +30,6 @@ import Log, { link } from '../log';
|
||||
import Sentry from '../sentry';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { getActorDisplayName } from '../user/User';
|
||||
import { isMultiAccountEnabled } from '../utils/easCli';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type ContextInput<
|
||||
@@ -225,75 +224,11 @@ export default abstract class EasCommand extends Command {
|
||||
|
||||
protected abstract runAsync(): Promise<any>;
|
||||
|
||||
/**
|
||||
* Parse and remove the --account flag from command line arguments.
|
||||
* This is a global flag available on all commands when multi-account is enabled.
|
||||
* We remove it from this.argv so child commands don't see it during their parse().
|
||||
* Commands that declare their own `account` flag (with different semantics,
|
||||
* e.g. `project:init`) are skipped so their flag keeps working.
|
||||
*/
|
||||
private parseAndRemoveAccountFlag(): string | undefined {
|
||||
if (this.ctor.flags && 'account' in this.ctor.flags) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const terminatorIndex = this.argv.indexOf('--');
|
||||
const searchEnd = terminatorIndex === -1 ? this.argv.length : terminatorIndex;
|
||||
|
||||
for (let i = 0; i < searchEnd; i++) {
|
||||
const arg = this.argv[i];
|
||||
if (arg === '--account') {
|
||||
const value = i + 1 < searchEnd ? this.argv[i + 1] : undefined;
|
||||
if (!value || value.startsWith('-')) {
|
||||
throw new Error('The --account flag requires a username value.');
|
||||
}
|
||||
this.argv.splice(i, 2);
|
||||
return value;
|
||||
}
|
||||
if (arg.startsWith('--account=')) {
|
||||
const value = arg.slice('--account='.length);
|
||||
if (!value) {
|
||||
throw new Error('The --account flag requires a username value.');
|
||||
}
|
||||
this.argv.splice(i, 1);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
async run(): Promise<any> {
|
||||
this.analyticsInternal = await createAnalyticsAsync();
|
||||
this.sessionManagerInternal = new SessionManager(this.analytics);
|
||||
|
||||
// Handle --account flag for per-command account selection
|
||||
if (isMultiAccountEnabled()) {
|
||||
const accountFlag = this.parseAndRemoveAccountFlag();
|
||||
if (accountFlag) {
|
||||
if (this.sessionManager.getAccessToken()) {
|
||||
throw new Error(
|
||||
'EXPO_TOKEN is set in your environment and is used for all EAS authentication, so --account has no effect. Unset EXPO_TOKEN to select an account with --account.'
|
||||
);
|
||||
}
|
||||
const accounts = this.sessionManager.getAllAccounts();
|
||||
const targetAccount = accounts.find(a => a.username === accountFlag);
|
||||
if (!targetAccount) {
|
||||
const availableAccounts = accounts.map(a => a.username).join(', ') || 'none';
|
||||
throw new Error(
|
||||
`Account '${accountFlag}' not found. Available accounts: ${availableAccounts}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!targetAccount.isActive &&
|
||||
!this.sessionManager.useAccountForProcessByUsername(accountFlag)
|
||||
) {
|
||||
throw new Error(`Account '${accountFlag}' not found.`);
|
||||
}
|
||||
Log.log(chalk.dim(`Using account: ${accountFlag}`));
|
||||
}
|
||||
}
|
||||
|
||||
// this is needed for logEvent call below as it identifies the user in the analytics system
|
||||
// if possible
|
||||
const actor = await this.sessionManager.getUserAsync();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { fromNow } from '../../utils/date';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountList extends EasCommand {
|
||||
static override description = 'list all logged-in Expo accounts';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
// Hide this command when the feature flag is disabled
|
||||
static override get hidden(): boolean {
|
||||
return !isMultiAccountEnabled();
|
||||
}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
Log.error(
|
||||
'Multi-account listing is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { sessionManager } = await this.getContextAsync(AccountList, { nonInteractive: true });
|
||||
|
||||
if (sessionManager.getAccessToken()) {
|
||||
Log.log(chalk.yellow('Using EXPO_TOKEN from environment for authentication.'));
|
||||
Log.log('Account switching is not available when using EXPO_TOKEN.');
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.log('No accounts logged in.');
|
||||
Log.log(`Run ${chalk.bold('eas login')} to log in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log('Logged-in accounts:');
|
||||
Log.newLine();
|
||||
|
||||
for (const account of accounts) {
|
||||
const marker = account.isActive ? chalk.green('●') : chalk.dim('○');
|
||||
const activeLabel = account.isActive ? chalk.dim(' (active)') : '';
|
||||
const username = account.isActive ? chalk.bold(account.username) : account.username;
|
||||
|
||||
Log.log(`${marker} ${username}${activeLabel}`);
|
||||
|
||||
if (account.lastUsedAt) {
|
||||
const lastUsed = fromNow(new Date(account.lastUsedAt));
|
||||
Log.log(chalk.dim(` └─ Last used: ${lastUsed} ago`));
|
||||
}
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`Use ${chalk.bold('eas account:switch')} to change accounts.`);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@ import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, selectAsync } from '../../prompts';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
|
||||
export default class AccountLogin extends EasCommand {
|
||||
static override description = 'log in with your Expo account';
|
||||
@@ -49,13 +47,6 @@ export default class AccountLogin extends EasCommand {
|
||||
}
|
||||
|
||||
if (actor) {
|
||||
if (isMultiAccountEnabled()) {
|
||||
// Multi-account mode: offer options
|
||||
await this.handleMultiAccountLoginAsync(sessionManager, actor, { sso, browser });
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: simple confirmation
|
||||
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
|
||||
|
||||
const shouldContinue = await confirmAsync({
|
||||
@@ -69,58 +60,4 @@ export default class AccountLogin extends EasCommand {
|
||||
await sessionManager.showLoginPromptAsync({ sso, browser });
|
||||
Log.log('Logged in');
|
||||
}
|
||||
|
||||
private async handleMultiAccountLoginAsync(
|
||||
sessionManager: SessionManager,
|
||||
actor: Actor,
|
||||
{ sso, browser }: { sso: boolean; browser: boolean }
|
||||
): Promise<void> {
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
const currentUsername = getActorDisplayName(actor);
|
||||
|
||||
Log.log(`You're logged in as ${chalk.bold(currentUsername)}.`);
|
||||
|
||||
const choices: { title: string; value: string }[] = [
|
||||
{
|
||||
title: 'Add another account',
|
||||
value: 'add',
|
||||
},
|
||||
];
|
||||
|
||||
// Add switch options for other accounts
|
||||
const otherAccounts = accounts.filter(a => !a.isActive);
|
||||
if (otherAccounts.length > 0) {
|
||||
for (const account of otherAccounts) {
|
||||
choices.push({
|
||||
title: `Switch to ${account.username}`,
|
||||
value: `switch:${account.username}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
choices.push({
|
||||
title: 'Cancel',
|
||||
value: 'cancel',
|
||||
});
|
||||
|
||||
const action = await selectAsync('What would you like to do?', choices);
|
||||
|
||||
if (action === 'cancel') {
|
||||
Errors.error('Aborted', { exit: 1 });
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
await sessionManager.showLoginPromptAsync({ sso, browser });
|
||||
const newAccounts = sessionManager.getAllAccounts();
|
||||
Log.log('Logged in');
|
||||
Log.log(`You now have ${newAccounts.length} account${newAccounts.length > 1 ? 's' : ''}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.startsWith('switch:')) {
|
||||
const username = action.slice('switch:'.length);
|
||||
await sessionManager.switchAccountByUsernameAsync(username);
|
||||
Log.log(`Switched to ${chalk.bold(username)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,17 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountLogout extends EasCommand {
|
||||
static override description = 'log out';
|
||||
static override aliases = ['logout'];
|
||||
|
||||
static override args = {
|
||||
username: Args.string({
|
||||
description: 'Username of the account to log out (multi-account mode only)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
all: Flags.boolean({
|
||||
description: 'Log out of all accounts (multi-account mode only)',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(AccountLogout);
|
||||
const { sessionManager } = await this.getContextAsync(AccountLogout, { nonInteractive: false });
|
||||
|
||||
if (!isMultiAccountEnabled()) {
|
||||
if (args.username || flags.all) {
|
||||
Log.error(
|
||||
'Logging out a specific account requires the experimental account switcher. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable it.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Legacy behavior
|
||||
await sessionManager.logoutAsync();
|
||||
Log.log('Logged out');
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-account mode
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.log('Not logged in to any accounts.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --all flag
|
||||
if (flags.all) {
|
||||
const confirmed = await confirmAsync({
|
||||
message: `Log out of all ${accounts.length} account${accounts.length > 1 ? 's' : ''}?`,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
Log.log('Aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionManager.removeAllAccountsAsync();
|
||||
Log.log('Logged out of all accounts');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle specific username argument
|
||||
if (args.username) {
|
||||
const account = accounts.find(a => a.username === args.username);
|
||||
if (!account) {
|
||||
Log.error(`Account '${args.username}' not found.`);
|
||||
Log.log('Logged-in accounts:');
|
||||
accounts.forEach(a => {
|
||||
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await sessionManager.removeAccountAsync(account.userId);
|
||||
Log.log(`Logged out of ${chalk.bold(args.username)}`);
|
||||
|
||||
// Show remaining accounts info
|
||||
const remainingAccounts = sessionManager.getAllAccounts();
|
||||
if (remainingAccounts.length > 0) {
|
||||
const activeAccount = remainingAccounts.find(a => a.isActive);
|
||||
if (activeAccount) {
|
||||
Log.log(`Active account is now: ${chalk.bold(activeAccount.username)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: log out of active account
|
||||
const activeAccount = accounts.find(a => a.isActive);
|
||||
if (!activeAccount) {
|
||||
Log.log('No active account to log out of.');
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionManager.removeAccountAsync(activeAccount.userId);
|
||||
Log.log(`Logged out of ${chalk.bold(activeAccount.username)}`);
|
||||
|
||||
// Show remaining accounts info
|
||||
const remainingAccounts = sessionManager.getAllAccounts();
|
||||
if (remainingAccounts.length > 0) {
|
||||
const newActiveAccount = remainingAccounts.find(a => a.isActive);
|
||||
if (newActiveAccount) {
|
||||
Log.log(`Active account is now: ${chalk.bold(newActiveAccount.username)}`);
|
||||
}
|
||||
} else {
|
||||
Log.log('No accounts remaining. Run `eas login` to log in.');
|
||||
}
|
||||
await sessionManager.logoutAsync();
|
||||
Log.log('Logged out');
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountSwitch extends EasCommand {
|
||||
static override description = 'switch to a different Expo account';
|
||||
|
||||
static override args = {
|
||||
username: Args.string({
|
||||
description: 'Username of the account to switch to',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
// Hide this command when the feature flag is disabled
|
||||
static override get hidden(): boolean {
|
||||
return !isMultiAccountEnabled();
|
||||
}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
Log.error(
|
||||
'Multi-account switching is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { args } = await this.parse(AccountSwitch);
|
||||
const { sessionManager } = await this.getContextAsync(AccountSwitch, { nonInteractive: false });
|
||||
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.warn('No accounts logged in. Run `eas login` to log in.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (accounts.length === 1) {
|
||||
Log.log(`Only one account is logged in: ${chalk.bold(accounts[0].username)}`);
|
||||
Log.log('Run `eas login` to add another account.');
|
||||
return;
|
||||
}
|
||||
|
||||
let targetUsername = args.username;
|
||||
|
||||
if (!targetUsername) {
|
||||
// Interactive mode: show account picker
|
||||
const choices = accounts.map(account => {
|
||||
const title = account.isActive
|
||||
? `${account.username} ${chalk.dim('(active)')}`
|
||||
: account.username;
|
||||
return { title, value: account.username };
|
||||
});
|
||||
|
||||
choices.push({
|
||||
title: chalk.dim('Add new account...'),
|
||||
value: '__add_new__',
|
||||
});
|
||||
|
||||
targetUsername = await selectAsync('Select an account:', choices);
|
||||
|
||||
if (targetUsername === '__add_new__') {
|
||||
// Match the default `eas login` behavior (browser-based login)
|
||||
await sessionManager.showLoginPromptAsync({ browser: true });
|
||||
Log.log('Logged in');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already active
|
||||
const targetAccount = accounts.find(a => a.username === targetUsername);
|
||||
if (!targetAccount) {
|
||||
Log.error(`Account '${targetUsername}' not found.`);
|
||||
Log.log('Available accounts:');
|
||||
accounts.forEach(a => {
|
||||
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetAccount.isActive) {
|
||||
Log.log(`Already using account ${chalk.bold(targetUsername)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the account
|
||||
await sessionManager.switchAccountByUsernameAsync(targetUsername);
|
||||
Log.log(`Switched to ${chalk.bold(targetUsername)}`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountView extends EasCommand {
|
||||
static override description = 'show the username you are logged in as';
|
||||
@@ -12,53 +11,39 @@ export default class AccountView extends EasCommand {
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.MaybeLoggedIn,
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
maybeLoggedIn: { actor, authenticationInfo },
|
||||
sessionManager,
|
||||
} = await this.getContextAsync(AccountView, { nonInteractive: true });
|
||||
if (actor) {
|
||||
const displayName = getActorDisplayName(actor);
|
||||
const loggedInAs = authenticationInfo.accessToken
|
||||
? `${displayName} (authenticated using EXPO_TOKEN)`
|
||||
: displayName;
|
||||
Log.log(chalk.green(loggedInAs));
|
||||
if ('email' in actor) {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
if (!actor) {
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Log.warn('Not logged in');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = getActorDisplayName(actor);
|
||||
const loggedInAs = authenticationInfo.accessToken
|
||||
? `${displayName} (authenticated using EXPO_TOKEN)`
|
||||
: displayName;
|
||||
Log.log(chalk.green(loggedInAs));
|
||||
if ('email' in actor) {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
// Show other logged-in accounts if multi-account is enabled
|
||||
if (isMultiAccountEnabled() && !authenticationInfo.accessToken) {
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
const otherAccounts = accounts.filter(a => !a.isActive);
|
||||
|
||||
if (otherAccounts.length > 0) {
|
||||
const otherUsernames = otherAccounts.map(a => a.username).join(', ');
|
||||
Log.log(chalk.dim(`Also logged in: ${otherUsernames}`));
|
||||
}
|
||||
}
|
||||
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static getRoleOnAccount(actor: Actor, account: Actor['accounts'][0]): Role {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -122,7 +122,13 @@ describe(SimulatorGet, () => {
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const session = makeDeviceRunSession();
|
||||
const session = makeDeviceRunSession({
|
||||
type: DeviceRunSessionType.ServeSim,
|
||||
remoteConfig: {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.com',
|
||||
},
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(session);
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
|
||||
@@ -137,7 +143,7 @@ describe(SimulatorGet, () => {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
type: 'agent-device',
|
||||
type: 'web-preview-only',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
|
||||
@@ -332,6 +332,33 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a ServeSim session for --type web-preview-only', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type: DeviceRunSessionType.ServeSim,
|
||||
remoteConfig: {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.test',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
'web-preview-only',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ type: DeviceRunSessionType.ServeSim })
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://preview.example.test'));
|
||||
});
|
||||
|
||||
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
|
||||
@@ -111,7 +111,7 @@ describe(SimulatorList, () => {
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const session = makeSession();
|
||||
const session = makeSession({ type: DeviceRunSessionType.ServeSim });
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--json']);
|
||||
@@ -132,7 +132,7 @@ describe(SimulatorList, () => {
|
||||
{
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
type: 'agent-device',
|
||||
type: 'web-preview-only',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
@@ -162,6 +162,8 @@ describe(SimulatorList, () => {
|
||||
'new',
|
||||
'--type',
|
||||
'appium',
|
||||
'--type',
|
||||
'web-preview-only',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--name',
|
||||
@@ -179,7 +181,7 @@ describe(SimulatorList, () => {
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Appium],
|
||||
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.ServeSim],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
},
|
||||
|
||||
@@ -103,7 +103,8 @@ export default class Simulator extends EasCommand {
|
||||
'Expo or development-client URL to open in the installed application after it launches.',
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'Type of simulator session to create',
|
||||
description:
|
||||
'Type of simulator session to create. All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
|
||||
@@ -61,7 +61,8 @@ export default class SimulatorList extends EasCommand {
|
||||
multiple: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Filter by session type (repeatable)',
|
||||
description:
|
||||
'Filter by session type (repeatable). All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
multiple: true,
|
||||
})(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { DeviceRunSessionResourceClass, DeviceRunSessionType } from '../../graph
|
||||
import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
} from '../utils';
|
||||
@@ -39,6 +41,20 @@ describe('Appium simulator configuration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator session type flags', () => {
|
||||
it('maps web-preview-only to the ServeSim GraphQL enum', () => {
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview-only']).toBe(
|
||||
DeviceRunSessionType.ServeSim
|
||||
);
|
||||
expect(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.ServeSim]).toBe(
|
||||
'web-preview-only'
|
||||
);
|
||||
expect(deviceRunSessionTypeToFlagValue(DeviceRunSessionType.ServeSim)).toBe('web-preview-only');
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['serve-sim']).toBeUndefined();
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator resource class flags', () => {
|
||||
it('maps CLI values to the GraphQL enum', () => {
|
||||
expect(DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE.large).toBe(
|
||||
|
||||
@@ -28,7 +28,7 @@ export const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, s
|
||||
[DeviceRunSessionType.AgentDevice]: 'agent-device',
|
||||
[DeviceRunSessionType.Appium]: 'appium',
|
||||
[DeviceRunSessionType.Argent]: 'argent',
|
||||
[DeviceRunSessionType.ServeSim]: 'serve-sim',
|
||||
[DeviceRunSessionType.ServeSim]: 'web-preview-only',
|
||||
};
|
||||
|
||||
export const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { Errors } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
|
||||
import { fetchSessionSecretAndUserAsync } from './fetchSessionSecretAndUser';
|
||||
import { fetchSessionSecretAndUserFromBrowserAuthFlowAsync } from './fetchSessionSecretAndUserFromBrowserAuthFlow';
|
||||
@@ -15,61 +13,21 @@ import { CurrentUserQuery } from '../graphql/generated';
|
||||
import { UserQuery } from '../graphql/queries/UserQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { isMultiAccountEnabled } from '../utils/easCli';
|
||||
import { getStateJsonPath } from '../utils/paths';
|
||||
|
||||
type ConnectionType = 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
|
||||
type UserSettingsData = {
|
||||
auth?: SessionData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy session data format (v0 schema).
|
||||
* These fields are also used for Expo CLI backward compatibility.
|
||||
*/
|
||||
type LegacySessionData = {
|
||||
type SessionData = {
|
||||
sessionSecret: string;
|
||||
|
||||
// These fields are potentially used by Expo CLI.
|
||||
userId: string;
|
||||
username: string;
|
||||
currentConnection: ConnectionType;
|
||||
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
|
||||
};
|
||||
|
||||
/**
|
||||
* Account data with timestamps for multi-account support (v1 schema).
|
||||
*/
|
||||
type AccountData = {
|
||||
sessionSecret: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
currentConnection: ConnectionType;
|
||||
addedAt: string;
|
||||
lastUsedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* State file format for v0 (legacy single-account).
|
||||
*/
|
||||
type StateDataV0 = {
|
||||
auth?: LegacySessionData;
|
||||
};
|
||||
|
||||
/**
|
||||
* State file format for v1 (multi-account).
|
||||
*/
|
||||
type StateDataV1 = {
|
||||
version: 1;
|
||||
auth: {
|
||||
activeAccountId: string | null;
|
||||
accounts: Record<string, AccountData>;
|
||||
// Legacy fields for Expo CLI backward compatibility
|
||||
sessionSecret?: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
currentConnection?: ConnectionType;
|
||||
};
|
||||
};
|
||||
|
||||
type StateData = StateDataV0 | StateDataV1;
|
||||
|
||||
type SessionData = LegacySessionData;
|
||||
|
||||
export type LoggedInAuthenticationInfo =
|
||||
| {
|
||||
accessToken: string;
|
||||
@@ -82,26 +40,9 @@ export type LoggedInAuthenticationInfo =
|
||||
|
||||
type Actor = NonNullable<CurrentUserQuery['meActor']>;
|
||||
|
||||
/**
|
||||
* Publicly exposed account info for multi-account features.
|
||||
*/
|
||||
export type StoredAccount = {
|
||||
userId: string;
|
||||
username: string;
|
||||
isActive: boolean;
|
||||
addedAt?: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
export default class SessionManager {
|
||||
private currentActor: Actor | undefined;
|
||||
|
||||
/**
|
||||
* When set, this stored account is used for the rest of the process instead of the
|
||||
* active account from the state file. Never persisted (used by the global --account flag).
|
||||
*/
|
||||
private perProcessAccountOverrideUserId: string | undefined;
|
||||
|
||||
constructor(private readonly analytics: AnalyticsWithOrchestration) {}
|
||||
|
||||
public getAccessToken(): string | null {
|
||||
@@ -109,16 +50,12 @@ export default class SessionManager {
|
||||
}
|
||||
|
||||
public getSessionSecret(): string | null {
|
||||
return this.getActiveSession()?.sessionSecret ?? null;
|
||||
return this.getSession()?.sessionSecret ?? null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// State File Reading
|
||||
// ============================================
|
||||
|
||||
private readStateFile(): StateData | null {
|
||||
private getSession(): SessionData | null {
|
||||
try {
|
||||
return JsonFile.read<StateData>(getStateJsonPath()) ?? null;
|
||||
return JsonFile.read<UserSettingsData>(getStateJsonPath())?.auth ?? null;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
@@ -127,550 +64,25 @@ export default class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private isV1Schema(state: StateData | null): state is StateDataV1 {
|
||||
return (
|
||||
state !== null &&
|
||||
'version' in state &&
|
||||
state.version === 1 &&
|
||||
state.auth?.accounts !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active session data, reading from appropriate schema.
|
||||
* For v1 schema, reads from the active account in the accounts object.
|
||||
* For v0 schema, reads from legacy fields.
|
||||
*/
|
||||
private getActiveSession(): SessionData | null {
|
||||
const state = this.readStateFile();
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For v1 schema with multi-account enabled, read from the active account
|
||||
if (isMultiAccountEnabled() && this.isV1Schema(state)) {
|
||||
const overrideId = this.perProcessAccountOverrideUserId;
|
||||
const activeId =
|
||||
overrideId && state.auth.accounts[overrideId] ? overrideId : state.auth.activeAccountId;
|
||||
if (activeId && state.auth.accounts[activeId]) {
|
||||
const account = state.auth.accounts[activeId];
|
||||
return {
|
||||
sessionSecret: account.sessionSecret,
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
currentConnection: account.currentConnection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Read from legacy fields (v0 schema or fallback)
|
||||
const auth = state.auth;
|
||||
if (!auth?.sessionSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionSecret: auth.sessionSecret,
|
||||
userId: auth.userId!,
|
||||
username: auth.username!,
|
||||
currentConnection: auth.currentConnection!,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// State File Writing
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Write session data, preserving existing schema structure.
|
||||
* - If multi-account disabled: only update legacy fields
|
||||
* - If multi-account enabled: update accounts object + legacy fields
|
||||
*/
|
||||
private async setSessionAsync(sessionData?: SessionData): Promise<void> {
|
||||
const statePath = getStateJsonPath();
|
||||
|
||||
if (!sessionData) {
|
||||
// Logout: clear session data
|
||||
if (isMultiAccountEnabled()) {
|
||||
await this.clearActiveAccountAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (this.isV1Schema(state)) {
|
||||
// Preserve other stored accounts: drop only the logged-out account and legacy fields
|
||||
const loggedOutUserId = state.auth.userId ?? state.auth.activeAccountId;
|
||||
const { [loggedOutUserId ?? '']: _removed, ...remainingAccounts } = state.auth.accounts;
|
||||
await this.writeStateFileAsync({
|
||||
...state,
|
||||
auth: {
|
||||
activeAccountId: null,
|
||||
accounts: remainingAccounts,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await JsonFile.setAsync(statePath, 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!isMultiAccountEnabled() && !this.isV1Schema(state)) {
|
||||
// Feature disabled and no v1 structure: plain legacy write
|
||||
const existingAuth = state?.auth ?? {};
|
||||
|
||||
const updatedAuth = {
|
||||
...existingAuth,
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync({ ...state, auth: updatedAuth });
|
||||
return;
|
||||
}
|
||||
|
||||
// Feature enabled, or a v1 state file already exists: keep the accounts
|
||||
// object and the legacy fields in sync so that toggling the feature flag
|
||||
// never resurrects a stale identity.
|
||||
await this.addOrUpdateAccountAsync(sessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write state file atomically with proper permissions.
|
||||
*/
|
||||
private async writeStateFileAsync(state: StateData): Promise<void> {
|
||||
const statePath = getStateJsonPath();
|
||||
const dir = path.dirname(statePath);
|
||||
await fs.ensureDir(dir);
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
const tempPath = `${statePath}.tmp.${process.pid}`;
|
||||
await fs.writeJson(tempPath, state, { spaces: 2 });
|
||||
await fs.rename(tempPath, statePath);
|
||||
|
||||
// Ensure restrictive permissions (owner read/write only)
|
||||
try {
|
||||
await fs.chmod(statePath, 0o600);
|
||||
} catch {
|
||||
// Ignore chmod errors on platforms that don't support it
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Multi-Account Methods (Feature Flag Gated)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get all stored accounts.
|
||||
* Returns single account array when multi-account is disabled.
|
||||
*/
|
||||
public getAllAccounts(): StoredAccount[] {
|
||||
const state = this.readStateFile();
|
||||
|
||||
if (!isMultiAccountEnabled() || !this.isV1Schema(state)) {
|
||||
// Return single account from legacy fields
|
||||
const session = this.getActiveSession();
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Multi-account enabled with v1 schema
|
||||
const accounts = Object.values(state.auth.accounts);
|
||||
const activeId = state.auth.activeAccountId;
|
||||
|
||||
return accounts.map(account => ({
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
isActive: account.userId === activeId,
|
||||
addedAt: account.addedAt,
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is already logged in (by userId).
|
||||
*/
|
||||
public isAccountLoggedIn(userId: string): boolean {
|
||||
const accounts = this.getAllAccounts();
|
||||
return accounts.some(a => a.userId === userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is already logged in (by username).
|
||||
*/
|
||||
public isAccountLoggedInByUsername(username: string): boolean {
|
||||
const accounts = this.getAllAccounts();
|
||||
return accounts.some(a => a.username === username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active account, or null if not logged in.
|
||||
*/
|
||||
public getActiveAccount(): StoredAccount | null {
|
||||
const accounts = this.getAllAccounts();
|
||||
return accounts.find(a => a.isActive) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an account in multi-account mode.
|
||||
* If the account already exists (by userId), update its session.
|
||||
* Always sets the new/updated account as active.
|
||||
*/
|
||||
private async addOrUpdateAccountAsync(sessionData: SessionData): Promise<void> {
|
||||
const state = this.readStateFile();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let newState: StateDataV1;
|
||||
|
||||
if (this.isV1Schema(state)) {
|
||||
// Update existing v1 schema
|
||||
const existingAccount = state.auth.accounts[sessionData.userId];
|
||||
newState = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
...state.auth,
|
||||
activeAccountId: sessionData.userId,
|
||||
accounts: {
|
||||
...state.auth.accounts,
|
||||
[sessionData.userId]: {
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
addedAt: existingAccount?.addedAt ?? now,
|
||||
lastUsedAt: now,
|
||||
},
|
||||
},
|
||||
// Sync legacy fields
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Migrate from v0 to v1
|
||||
newState = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
activeAccountId: sessionData.userId,
|
||||
accounts: {
|
||||
[sessionData.userId]: {
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
addedAt: now,
|
||||
lastUsedAt: now,
|
||||
},
|
||||
},
|
||||
// Sync legacy fields
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
},
|
||||
};
|
||||
|
||||
// If there was an existing session in v0, preserve it as a separate account
|
||||
if (
|
||||
state?.auth?.sessionSecret &&
|
||||
state.auth.userId &&
|
||||
state.auth.userId !== sessionData.userId
|
||||
) {
|
||||
newState.auth.accounts[state.auth.userId] = {
|
||||
sessionSecret: state.auth.sessionSecret,
|
||||
userId: state.auth.userId,
|
||||
username: state.auth.username,
|
||||
currentConnection: state.auth.currentConnection,
|
||||
addedAt: now,
|
||||
lastUsedAt: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different account by userId.
|
||||
* Only available when multi-account is enabled.
|
||||
*/
|
||||
public async switchAccountAsync(userId: string): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
throw new Error('Multi-account switching is not enabled');
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
throw new Error('No accounts to switch to');
|
||||
}
|
||||
|
||||
const account = state.auth.accounts[userId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${userId}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const newState: StateDataV1 = {
|
||||
...state,
|
||||
auth: {
|
||||
...state.auth,
|
||||
activeAccountId: userId,
|
||||
accounts: {
|
||||
...state.auth.accounts,
|
||||
[userId]: {
|
||||
...account,
|
||||
lastUsedAt: now,
|
||||
},
|
||||
},
|
||||
// Sync legacy fields
|
||||
sessionSecret: account.sessionSecret,
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
currentConnection: account.currentConnection,
|
||||
},
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
|
||||
// Clear cached actor since we switched
|
||||
this.currentActor = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a stored account for the rest of this process without persisting a switch
|
||||
* to the state file. Used by the global --account flag for per-command account
|
||||
* selection. Returns false if no such account is stored.
|
||||
*/
|
||||
public useAccountForProcessByUsername(username: string): boolean {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const account = Object.values(state.auth.accounts).find(a => a.username === username);
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.perProcessAccountOverrideUserId = account.userId;
|
||||
this.currentActor = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different account by username.
|
||||
* Only available when multi-account is enabled.
|
||||
*/
|
||||
public async switchAccountByUsernameAsync(username: string): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
throw new Error('Multi-account switching is not enabled');
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
throw new Error('No accounts to switch to');
|
||||
}
|
||||
|
||||
const account = Object.values(state.auth.accounts).find(a => a.username === username);
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${username}`);
|
||||
}
|
||||
|
||||
await this.switchAccountAsync(account.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account by userId. Invalidates its session secret on the server (best-effort).
|
||||
* If removing the active account, switches to the most recently used remaining account.
|
||||
*/
|
||||
public async removeAccountAsync(userId: string): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
// When disabled, just clear everything
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
// V0 schema: just clear
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const { [userId]: removed, ...remainingAccounts } = state.auth.accounts;
|
||||
if (!removed) {
|
||||
return; // Account not found, nothing to do
|
||||
}
|
||||
|
||||
await this.invalidateSessionSecretOnServerAsync(removed.sessionSecret);
|
||||
|
||||
const wasActive = state.auth.activeAccountId === userId;
|
||||
let newActiveId: string | null = state.auth.activeAccountId;
|
||||
|
||||
if (wasActive) {
|
||||
// Find the most recently used remaining account
|
||||
const remaining = Object.values(remainingAccounts);
|
||||
if (remaining.length > 0) {
|
||||
remaining.sort(
|
||||
(a, b) => new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime()
|
||||
);
|
||||
newActiveId = remaining[0].userId;
|
||||
} else {
|
||||
newActiveId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const newActiveAccount = newActiveId ? remainingAccounts[newActiveId] : null;
|
||||
|
||||
const newState: StateDataV1 = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
activeAccountId: newActiveId,
|
||||
accounts: remainingAccounts,
|
||||
// Sync legacy fields to new active account (or clear if none)
|
||||
sessionSecret: newActiveAccount?.sessionSecret,
|
||||
userId: newActiveAccount?.userId,
|
||||
username: newActiveAccount?.username,
|
||||
currentConnection: newActiveAccount?.currentConnection,
|
||||
},
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
this.currentActor = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all accounts (logout all). Invalidates their session secrets on the
|
||||
* server (best-effort).
|
||||
*/
|
||||
public async removeAllAccountsAsync(): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const account of Object.values(state.auth.accounts)) {
|
||||
await this.invalidateSessionSecretOnServerAsync(account.sessionSecret);
|
||||
}
|
||||
|
||||
const newState: StateDataV1 = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
activeAccountId: null,
|
||||
accounts: {},
|
||||
// Clear legacy fields
|
||||
sessionSecret: undefined,
|
||||
userId: undefined,
|
||||
username: undefined,
|
||||
currentConnection: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
this.currentActor = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active account (used by logout when multi-account enabled).
|
||||
*/
|
||||
private async clearActiveAccountAsync(): Promise<void> {
|
||||
const state = this.readStateFile();
|
||||
|
||||
if (!this.isV1Schema(state)) {
|
||||
// V0 schema: just clear
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = state.auth.activeAccountId;
|
||||
if (!activeId) {
|
||||
return; // Already no active account
|
||||
}
|
||||
|
||||
await this.removeAccountAsync(activeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort invalidation of a session secret on the server.
|
||||
*/
|
||||
private async invalidateSessionSecretOnServerAsync(sessionSecret: string): Promise<void> {
|
||||
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
|
||||
try {
|
||||
await apiV2Client.postAsync('auth/logout', { body: {} });
|
||||
} catch (e) {
|
||||
// Best-effort: clear the local session even if the server request fails
|
||||
Log.debug('Failed to invalidate session secret on server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidateCurrentSessionOnServerAsync(): Promise<void> {
|
||||
const sessionSecret = this.getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
await this.invalidateSessionSecretOnServerAsync(sessionSecret);
|
||||
}
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', sessionData, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logoutAsync(): Promise<void> {
|
||||
this.currentActor = undefined;
|
||||
if (isMultiAccountEnabled()) {
|
||||
// Clearing the active account invalidates its session secret on the server.
|
||||
await this.setSessionAsync(undefined);
|
||||
return;
|
||||
const sessionSecret = this.getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
|
||||
try {
|
||||
await apiV2Client.postAsync('auth/logout', { body: {} });
|
||||
} catch (e) {
|
||||
// Best-effort: clear the local session even if the server request fails
|
||||
Log.debug('Failed to invalidate session secret on server:', e);
|
||||
}
|
||||
}
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
this.currentActor = undefined;
|
||||
await this.setSessionAsync(undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,6 @@ beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
process.env = { ...OLD_ENV }; // Make a copy
|
||||
// Tests must not depend on the experimental flag being set in the ambient shell
|
||||
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,17 +55,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe(SessionManager, () => {
|
||||
describe('getActiveSession', () => {
|
||||
describe('getSession', () => {
|
||||
it('returns null when session is not stored', () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
expect(sessionManager['getActiveSession']()).toBeNull();
|
||||
expect(sessionManager['getSession']()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored session data', async () => {
|
||||
await fs.mkdirp(path.dirname(getStateJsonPath()));
|
||||
await fs.writeJSON(getStateJsonPath(), { auth: authStub });
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
expect(sessionManager['getActiveSession']()).toMatchObject(authStub);
|
||||
expect(sessionManager['getSession']()).toMatchObject(authStub);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,123 +392,4 @@ describe(SessionManager, () => {
|
||||
).rejects.toThrowError('Cancelled login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-account (EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1)', () => {
|
||||
const loginAsUserAsync = async (
|
||||
sessionManager: SessionManager,
|
||||
{ id, username }: { id: string; username: string }
|
||||
): Promise<void> => {
|
||||
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValueOnce({
|
||||
sessionSecret: `SECRET_${id}`,
|
||||
id,
|
||||
username,
|
||||
});
|
||||
await sessionManager['loginAsync']({ username, password: 'PASSWORD' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER = '1';
|
||||
});
|
||||
|
||||
it('migrates v0 state to v1 on login, preserving the old session and unrelated top-level keys', async () => {
|
||||
await fs.mkdirp(path.dirname(getStateJsonPath()));
|
||||
await fs.writeJSON(getStateJsonPath(), {
|
||||
uuid: 'SOME_UUID',
|
||||
auth: {
|
||||
sessionSecret: 'OLD_SECRET',
|
||||
userId: 'OLD_USER_ID',
|
||||
username: 'OLD_USERNAME',
|
||||
currentConnection: 'Username-Password-Authentication',
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'USER_ID', username: 'USERNAME' });
|
||||
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.uuid).toBe('SOME_UUID');
|
||||
expect(state.version).toBe(1);
|
||||
expect(state.auth.activeAccountId).toBe('USER_ID');
|
||||
expect(Object.keys(state.auth.accounts).sort()).toEqual(['OLD_USER_ID', 'USER_ID']);
|
||||
expect(state.auth.sessionSecret).toBe('SECRET_USER_ID');
|
||||
expect(state.auth.username).toBe('USERNAME');
|
||||
});
|
||||
|
||||
it('uses an account for the process without persisting a switch', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
expect(sessionManager['getSessionSecret']()).toBe('SECRET_ID_B');
|
||||
|
||||
expect(sessionManager.useAccountForProcessByUsername('user-a')).toBe(true);
|
||||
expect(sessionManager['getSessionSecret']()).toBe('SECRET_ID_A');
|
||||
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.activeAccountId).toBe('ID_B');
|
||||
expect(state.auth.sessionSecret).toBe('SECRET_ID_B');
|
||||
|
||||
expect(sessionManager.useAccountForProcessByUsername('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates the session secret on the server when removing an account', async () => {
|
||||
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
await sessionManager.removeAccountAsync('ID_A');
|
||||
|
||||
expect(apiV2PostSpy).toHaveBeenCalledWith('auth/logout', { body: {} });
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(Object.keys(state.auth.accounts)).toEqual(['ID_B']);
|
||||
expect(state.auth.activeAccountId).toBe('ID_B');
|
||||
});
|
||||
|
||||
it('invalidates all session secrets on the server when removing all accounts', async () => {
|
||||
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
await sessionManager.removeAllAccountsAsync();
|
||||
|
||||
expect(apiV2PostSpy).toHaveBeenCalledTimes(2);
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.accounts).toEqual({});
|
||||
expect(state.auth.activeAccountId).toBeNull();
|
||||
expect(sessionManager['getSessionSecret']()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the v1 accounts map in sync when logging in with the flag off', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
|
||||
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.activeAccountId).toBe('ID_B');
|
||||
expect(Object.keys(state.auth.accounts).sort()).toEqual(['ID_A', 'ID_B']);
|
||||
|
||||
// Re-enabling the flag must not resurrect the stale identity
|
||||
process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER = '1';
|
||||
expect(sessionManager['getActiveSession']()).toMatchObject({ userId: 'ID_B' });
|
||||
});
|
||||
|
||||
it('preserves other stored accounts when logging out with the flag off', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
|
||||
await sessionManager.logoutAsync();
|
||||
|
||||
expect(sessionManager['getSessionSecret']()).toBeNull();
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.activeAccountId).toBeNull();
|
||||
expect(Object.keys(state.auth.accounts)).toEqual(['ID_A']);
|
||||
expect(state.auth.sessionSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,3 @@
|
||||
const packageJSON = require('../../package.json');
|
||||
|
||||
export const easCliVersion: string = packageJSON.version;
|
||||
|
||||
/**
|
||||
* Check if the experimental account switcher feature is enabled.
|
||||
* This allows users to log in to multiple accounts and switch between them.
|
||||
*
|
||||
* Enable with: EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1
|
||||
*/
|
||||
export function isMultiAccountEnabled(): boolean {
|
||||
return process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER === '1';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "22.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": "22.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "22.6.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": "22.6.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/logger": "22.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": "22.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,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';
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "22.0.0",
|
||||
"version": "23.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "22.4.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": "22.0.0",
|
||||
"@expo/logger": "22.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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "22.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": "22.0.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -17,6 +17,8 @@ jest.mock('node:fs', () => jest.requireMock('fs'));
|
||||
jest.mock('@expo/build-tools', () => {
|
||||
return {
|
||||
...jest.requireActual('@expo/build-tools'),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
};
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "22.6.0",
|
||||
"version": "23.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,23 +19,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "22.6.0",
|
||||
"@expo/build-tools": "23.1.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/logger": "22.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": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/template-file": "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",
|
||||
"filesize": "7.0.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"json-stable-stringify": "1.3.0",
|
||||
"koa": "3.1.2",
|
||||
"koa-body": "7.0.1",
|
||||
"koa-router": "14.0.0",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { BuildContext, CacheManager, uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import {
|
||||
BuildContext,
|
||||
CacheManager,
|
||||
downloadCacheAsync,
|
||||
uploadCacheAsync,
|
||||
} from '@expo/build-tools';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import filesize from 'filesize';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import stringify from 'json-stable-stringify';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import config from './config';
|
||||
|
||||
type ContentLengthRange = { minSizeBytes: number; maxSizeBytes: number };
|
||||
|
||||
export class GCSCacheManager implements CacheManager {
|
||||
private skipCacheUpdate = false;
|
||||
|
||||
@@ -18,11 +20,7 @@ export class GCSCacheManager implements CacheManager {
|
||||
return;
|
||||
}
|
||||
const paths = this.getPaths(ctx);
|
||||
if (
|
||||
(ctx.job?.cache?.disabled ?? false) ||
|
||||
!config.buildCache.gcsSignedUploadUrlForBuildCache ||
|
||||
paths.length === 0
|
||||
) {
|
||||
if ((ctx.job?.cache?.disabled ?? false) || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,23 +45,22 @@ export class GCSCacheManager implements CacheManager {
|
||||
}
|
||||
|
||||
const archiveSize = (await fs.stat(archivePath)).size;
|
||||
const { maxSizeBytes } = this.allowedContentLengthRange;
|
||||
const friendlyMaxSize = filesize(maxSizeBytes);
|
||||
const friendlyArchiveSize = filesize(archiveSize);
|
||||
|
||||
if (archiveSize > maxSizeBytes) {
|
||||
ctx.logger.error(
|
||||
`Unable to save cache. Max size is ${friendlyMaxSize} but archive size is ${friendlyArchiveSize}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: config.buildCache.gcsSignedUploadUrlForBuildCache,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(archivePath);
|
||||
},
|
||||
await uploadCacheAsync({
|
||||
logger: ctx.logger,
|
||||
jobId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
expoApiServerURL: nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set'),
|
||||
robotAccessToken: nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
),
|
||||
paths,
|
||||
key: this.getCacheKey(ctx),
|
||||
archivePath,
|
||||
size: archiveSize,
|
||||
platform: ctx.job.platform,
|
||||
force: ctx.job.cache?.clear,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ctx.logger.error({ err });
|
||||
@@ -72,27 +69,47 @@ export class GCSCacheManager implements CacheManager {
|
||||
|
||||
public async restoreCache(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
const paths = this.getPaths(ctx);
|
||||
if (
|
||||
(ctx.job?.cache?.disabled ?? false) ||
|
||||
!config.buildCache.gcsSignedBuildCacheDownloadUrl ||
|
||||
paths.length === 0
|
||||
) {
|
||||
if ((ctx.job?.cache?.disabled ?? false) || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.job.cache?.clear) {
|
||||
// Skip restore; saveCache will force upload the new archive for this key.
|
||||
return;
|
||||
}
|
||||
|
||||
const archivePath = path.join(ctx.workingdir, 'cache-restore.tar.gz');
|
||||
try {
|
||||
const { archivePath: downloadedArchivePath } = await downloadCacheAsync({
|
||||
logger: ctx.logger,
|
||||
jobId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
expoApiServerURL: nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set'),
|
||||
robotAccessToken: nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
),
|
||||
paths,
|
||||
key: this.getCacheKey(ctx),
|
||||
keyPrefixes: [],
|
||||
platform: ctx.job.platform,
|
||||
});
|
||||
await fs.move(downloadedArchivePath, archivePath, { overwrite: true });
|
||||
await fs.remove(path.dirname(downloadedArchivePath));
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404) {
|
||||
ctx.logger.info('No cache found for this key');
|
||||
} else {
|
||||
ctx.logger.error({ err });
|
||||
this.skipCacheUpdate = true; // if restore failed we don't want to update cache with new values
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.logger.info('Restoring files from cache:');
|
||||
paths.forEach(filePath => {
|
||||
ctx.logger.info(`- ${filePath}`);
|
||||
});
|
||||
|
||||
const archivePath = path.join(ctx.workingdir, 'cache-restore.tar.gz');
|
||||
try {
|
||||
await downloadFile(config.buildCache.gcsSignedBuildCacheDownloadUrl, archivePath, {});
|
||||
} catch (err: any) {
|
||||
ctx.logger.error({ err });
|
||||
this.skipCacheUpdate = true; // if restore failed we don't want to update cache with new values
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tar.extract(
|
||||
{
|
||||
@@ -119,23 +136,18 @@ export class GCSCacheManager implements CacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
private get allowedContentLengthRange(): ContentLengthRange {
|
||||
const headers = config.buildCache.gcsSignedUploadUrlForBuildCache?.headers;
|
||||
const defaultValues = { minSizeBytes: -Infinity, maxSizeBytes: Infinity };
|
||||
|
||||
if (!headers) {
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
const values = headers['x-goog-content-length-range'];
|
||||
if (!values) {
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
const [min, max] = values.split(',');
|
||||
return {
|
||||
minSizeBytes: Number(min),
|
||||
maxSizeBytes: Number(max),
|
||||
private getCacheKey(ctx: BuildContext<BuildJob>): string {
|
||||
const cacheForKey = { ...(ctx.job.cache ?? {}) };
|
||||
delete cacheForKey.clear;
|
||||
const keySource = {
|
||||
cache: cacheForKey,
|
||||
workflow: ctx.job.type,
|
||||
sdkVersion: ctx.metadata?.sdkVersion ?? '',
|
||||
};
|
||||
const base64CacheConfig = Buffer.from(
|
||||
nullthrows(stringify(keySource), 'Failed to stringify cache key source')
|
||||
).toString('base64');
|
||||
const hash = crypto.createHash('sha256').update(base64CacheConfig).digest('hex');
|
||||
return `eas-build-cache-${hash}`;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { Cache, Platform } from '@expo/eas-build-job';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { downloadCacheAsync, uploadCacheAsync } from '@expo/build-tools';
|
||||
import { Cache, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
// These tests use real fs and directories in os.tmpdir().
|
||||
// Something about tar v7 makes mocking fs with memfs problematic.
|
||||
@@ -15,15 +12,6 @@ import { Readable } from 'stream';
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
|
||||
jest.mock('@expo/downloader', () => {
|
||||
return jest.fn();
|
||||
});
|
||||
jest.mock('../config', () => ({
|
||||
buildCache: {
|
||||
gcsSignedUploadUrlForBuildCache: 'fakeurl',
|
||||
gcsSignedBuildCacheDownloadUrl: 'fakeUrl',
|
||||
},
|
||||
}));
|
||||
jest.mock('../sentry', () => ({
|
||||
capture: jest.fn(),
|
||||
}));
|
||||
@@ -41,10 +29,34 @@ describe(GCSCacheManager, () => {
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
jest.mocked(uploadWithSignedUrl).mockReset();
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
jest.mocked(uploadCacheAsync).mockReset();
|
||||
jest.mocked(downloadCacheAsync).mockReset();
|
||||
});
|
||||
|
||||
function createMockCtx(cacheConfig: Partial<Cache>) {
|
||||
return {
|
||||
logger: { info: jest.fn(), error: jest.fn() },
|
||||
workingdir: tmpDir,
|
||||
buildDirectory: `${tmpDir}/build`,
|
||||
env: {
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
},
|
||||
metadata: {
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.GENERIC,
|
||||
projectRootDirectory: '.',
|
||||
cache: cacheConfig,
|
||||
secrets: {
|
||||
robotAccessToken: 'robot-token',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function saveAndRestoreCacheAsync(
|
||||
setupBeforeSave: () => Promise<void> | void,
|
||||
setupBeforeRestore: () => Promise<void> | void,
|
||||
@@ -52,37 +64,34 @@ describe(GCSCacheManager, () => {
|
||||
): Promise<void> {
|
||||
await setupBeforeSave();
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = {
|
||||
logger: { info: jest.fn(), error: console.error },
|
||||
workingdir: tmpDir,
|
||||
buildDirectory: `${tmpDir}/build`,
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
projectRootDirectory: '.',
|
||||
cache: cacheConfig,
|
||||
},
|
||||
} as any;
|
||||
let tarReadStream: Readable;
|
||||
jest.mocked(uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
|
||||
const result = await srcGeneratorAsync();
|
||||
tarReadStream = result;
|
||||
return randomUUID();
|
||||
const mockCtx = createMockCtx(cacheConfig);
|
||||
let tarContent: Buffer | undefined;
|
||||
jest.mocked(uploadCacheAsync).mockImplementation(async ({ archivePath }) => {
|
||||
tarContent = await fs.readFile(archivePath);
|
||||
});
|
||||
|
||||
await manager.saveCache(mockCtx);
|
||||
const chunks = [];
|
||||
for await (const chunk of tarReadStream!) {
|
||||
chunks.push(chunk);
|
||||
if (!tarContent) {
|
||||
throw new Error('Expected cache archive to be uploaded');
|
||||
}
|
||||
const tarContent = Buffer.concat(chunks);
|
||||
const uploadedTarContent = tarContent;
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
await setupBeforeRestore();
|
||||
jest.mocked(downloadFile).mockImplementation(async (_url, archivePath: string) => {
|
||||
await fs.mkdirp(mockCtx.workingdir);
|
||||
await fs.writeFile(archivePath, tarContent);
|
||||
let downloadDir: string | undefined;
|
||||
jest.mocked(downloadCacheAsync).mockImplementation(async () => {
|
||||
downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cache-download-'));
|
||||
const archivePath = path.join(downloadDir, 'cache.tar.gz');
|
||||
await fs.writeFile(archivePath, uploadedTarContent);
|
||||
return { archivePath, matchedKey: 'matched-cache-key' };
|
||||
});
|
||||
await manager.restoreCache(mockCtx);
|
||||
|
||||
const uploadedKey = jest.mocked(uploadCacheAsync).mock.calls[0][0].key;
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(expect.objectContaining({ key: uploadedKey }));
|
||||
expect(downloadDir).toBeDefined();
|
||||
await expect(fs.pathExists(downloadDir!)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
test('save and restore for a single file', async () => {
|
||||
@@ -189,4 +198,109 @@ describe(GCSCacheManager, () => {
|
||||
).resolves.toBe('index.ts');
|
||||
await expect(fs.pathExists(path.join(tmpDir, 'cache-restore.tar.gz'))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('does not save or restore when cache is disabled', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ disabled: true, paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(downloadCacheAsync).not.toHaveBeenCalled();
|
||||
expect(uploadCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not save or restore when cache paths are empty', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ paths: [] });
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(downloadCacheAsync).not.toHaveBeenCalled();
|
||||
expect(uploadCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips restore but saves cache when clear is set', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ clear: true, paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(uploadCacheAsync).mockResolvedValue();
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(downloadCacheAsync).not.toHaveBeenCalled();
|
||||
expect(uploadCacheAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
force: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('saves after a cache miss', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(downloadCacheAsync).mockRejectedValue({ response: { status: 404 } });
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(mockCtx.logger.info).toHaveBeenCalledWith('No cache found for this key');
|
||||
const requestedKey = jest.mocked(downloadCacheAsync).mock.calls[0][0].key;
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
force: undefined,
|
||||
key: requestedKey,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('does not save when restore fails', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(downloadCacheAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(uploadCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cache key is stable when clear changes and job cache is not mutated', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const cacheConfig = {
|
||||
clear: true,
|
||||
disabled: false,
|
||||
key: 'custom-cache-key',
|
||||
paths: ['index.ts'],
|
||||
};
|
||||
const mockCtx = createMockCtx(cacheConfig);
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(uploadCacheAsync).mockResolvedValue();
|
||||
|
||||
await manager.saveCache(mockCtx);
|
||||
const keyWithClear = jest.mocked(uploadCacheAsync).mock.calls[0][0].key;
|
||||
const forceWithClear = jest.mocked(uploadCacheAsync).mock.calls[0][0].force;
|
||||
|
||||
mockCtx.job.cache = { ...cacheConfig, clear: false };
|
||||
await manager.saveCache(mockCtx);
|
||||
const keyWithoutClear = jest.mocked(uploadCacheAsync).mock.calls[1][0].key;
|
||||
const forceWithoutClear = jest.mocked(uploadCacheAsync).mock.calls[1][0].force;
|
||||
|
||||
expect(keyWithClear).toBe(keyWithoutClear);
|
||||
expect(keyWithClear).toMatch(/^eas-build-cache-[a-f0-9]{64}$/);
|
||||
expect(forceWithClear).toBe(true);
|
||||
expect(forceWithoutClear).toBe(false);
|
||||
expect(cacheConfig).toEqual({
|
||||
clear: true,
|
||||
disabled: false,
|
||||
key: 'custom-cache-key',
|
||||
paths: ['index.ts'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2291,24 +2291,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:22.6.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:22.0.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/downloader": "npm:23.0.0"
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:22.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:22.4.0"
|
||||
"@expo/template-file": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/steps": "npm:23.1.0"
|
||||
"@expo/template-file": "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"
|
||||
@@ -2533,7 +2541,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:22.0.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:23.0.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2549,13 +2557,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:22.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:22.0.0"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.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:22.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:22.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:22.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:22.4.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:22.0.0"
|
||||
"@expo/logger": "npm:22.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,19 +2915,18 @@ __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
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:22.0.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:23.0.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -2942,11 +2949,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:22.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:22.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,18 +2969,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:22.6.0"
|
||||
"@expo/build-tools": "npm:23.1.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/logger": "npm:22.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:22.4.0"
|
||||
"@expo/template-file": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/steps": "npm:23.1.0"
|
||||
"@expo/template-file": "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"
|
||||
@@ -2994,6 +3000,7 @@ __metadata:
|
||||
filesize: "npm:7.0.0"
|
||||
fs-extra: "npm:11.2.0"
|
||||
jest: "npm:29.7.0"
|
||||
json-stable-stringify: "npm:1.3.0"
|
||||
koa: "npm:3.1.2"
|
||||
koa-body: "npm:7.0.1"
|
||||
koa-router: "npm:14.0.0"
|
||||
@@ -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
|
||||
|
||||
@@ -8332,6 +8323,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-generator-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-generator-function@npm:1.0.0"
|
||||
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-limiter@npm:~1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "async-limiter@npm:1.0.1"
|
||||
@@ -8892,7 +8897,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bound@npm:^1.0.2":
|
||||
"call-bind@npm:^1.0.8":
|
||||
version: 1.0.9
|
||||
resolution: "call-bind@npm:1.0.9"
|
||||
dependencies:
|
||||
call-bind-apply-helpers: "npm:^1.0.2"
|
||||
es-define-property: "npm:^1.0.1"
|
||||
get-intrinsic: "npm:^1.3.0"
|
||||
set-function-length: "npm:^1.2.2"
|
||||
checksum: 10c0/a6621f6da1444481919ce3b4983dff725691e0754d3507ae483ce56e54985f2da7d6f1df512c56dbf28660745cf1ca52553f1fc9aef5557f3ce353ef14fab714
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bound@npm:^1.0.2, call-bound@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "call-bound@npm:1.0.4"
|
||||
dependencies:
|
||||
@@ -9727,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:22.4.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"
|
||||
@@ -10055,6 +10072,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"define-data-property@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "define-data-property@npm:1.1.4"
|
||||
dependencies:
|
||||
es-define-property: "npm:^1.0.0"
|
||||
es-errors: "npm:^1.3.0"
|
||||
gopd: "npm:^1.0.1"
|
||||
checksum: 10c0/dea0606d1483eb9db8d930d4eac62ca0fa16738b0b3e07046cddfacf7d8c868bbe13fa0cb263eb91c7d0d527960dc3f2f2471a69ed7816210307f6744fe62e37
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"define-lazy-prop@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "define-lazy-prop@npm:2.0.0"
|
||||
@@ -10300,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:22.6.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/logger": "npm:22.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:22.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"
|
||||
@@ -10358,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:22.0.0"
|
||||
"@expo/eas-json": "npm:22.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:22.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"
|
||||
@@ -10374,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:22.4.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"
|
||||
@@ -10403,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"
|
||||
@@ -10443,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"
|
||||
@@ -10475,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"
|
||||
@@ -10654,7 +10680,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-define-property@npm:^1.0.1":
|
||||
"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "es-define-property@npm:1.0.1"
|
||||
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
|
||||
@@ -11409,6 +11435,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"generator-function@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "generator-function@npm:2.0.1"
|
||||
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gensync@npm:^1.0.0-beta.2":
|
||||
version: 1.0.0-beta.2
|
||||
resolution: "gensync@npm:1.0.0-beta.2"
|
||||
@@ -11430,6 +11463,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.2.4":
|
||||
version: 1.3.1
|
||||
resolution: "get-intrinsic@npm:1.3.1"
|
||||
dependencies:
|
||||
async-function: "npm:^1.0.0"
|
||||
async-generator-function: "npm:^1.0.0"
|
||||
call-bind-apply-helpers: "npm:^1.0.2"
|
||||
es-define-property: "npm:^1.0.1"
|
||||
es-errors: "npm:^1.3.0"
|
||||
es-object-atoms: "npm:^1.1.1"
|
||||
function-bind: "npm:^1.1.2"
|
||||
generator-function: "npm:^2.0.0"
|
||||
get-proto: "npm:^1.0.1"
|
||||
gopd: "npm:^1.2.0"
|
||||
has-symbols: "npm:^1.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
math-intrinsics: "npm:^1.1.0"
|
||||
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "get-intrinsic@npm:1.3.0"
|
||||
@@ -11634,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:
|
||||
@@ -11772,7 +11826,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gopd@npm:^1.2.0":
|
||||
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "gopd@npm:1.2.0"
|
||||
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
|
||||
@@ -12005,6 +12059,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"has-property-descriptors@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "has-property-descriptors@npm:1.0.2"
|
||||
dependencies:
|
||||
es-define-property: "npm:^1.0.0"
|
||||
checksum: 10c0/253c1f59e80bb476cf0dde8ff5284505d90c3bdb762983c3514d36414290475fe3fd6f574929d84de2a8eec00d35cf07cb6776205ff32efd7c50719125f00236
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"has-symbols@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "has-symbols@npm:1.0.3"
|
||||
@@ -12798,6 +12861,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isarray@npm:^2.0.5":
|
||||
version: 2.0.5
|
||||
resolution: "isarray@npm:2.0.5"
|
||||
checksum: 10c0/4199f14a7a13da2177c66c31080008b7124331956f47bca57dd0b6ea9f11687aa25e565a2c7a2b519bc86988d10398e3049a1f5df13c9f6b7664154690ae79fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isarray@npm:~1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "isarray@npm:1.0.0"
|
||||
@@ -13699,6 +13769,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-stable-stringify@npm:1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "json-stable-stringify@npm:1.3.0"
|
||||
dependencies:
|
||||
call-bind: "npm:^1.0.8"
|
||||
call-bound: "npm:^1.0.4"
|
||||
isarray: "npm:^2.0.5"
|
||||
jsonify: "npm:^0.0.1"
|
||||
object-keys: "npm:^1.1.1"
|
||||
checksum: 10c0/8b3ff19e4c23c0ad591a49bc3a015d89a538db787d12fe9c4072e1d64d8cfa481f8c37719c629c3d84e848847617bf49f5fee894cf1d25959ab5b67e1c517f31
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-stringify-nice@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "json-stringify-nice@npm:1.1.4"
|
||||
@@ -13773,6 +13856,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonify@npm:^0.0.1":
|
||||
version: 0.0.1
|
||||
resolution: "jsonify@npm:0.0.1"
|
||||
checksum: 10c0/7f5499cdd59a0967ed35bda48b7cec43d850bbc8fb955cdd3a1717bb0efadbe300724d5646de765bb7a99fc1c3ab06eb80d93503c6faaf99b4ff50a3326692f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonparse@npm:^1.2.0, jsonparse@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "jsonparse@npm:1.3.1"
|
||||
@@ -14135,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
|
||||
@@ -14925,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
|
||||
|
||||
@@ -14945,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"
|
||||
@@ -15748,6 +15828,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-keys@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "object-keys@npm:1.1.1"
|
||||
checksum: 10c0/b11f7ccdbc6d406d1f186cdadb9d54738e347b2692a14439ca5ac70c225fa6db46db809711b78589866d47b25fc3e8dee0b4c722ac751e11180f9380e3d8601d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-treeify@npm:^1.1.33":
|
||||
version: 1.1.33
|
||||
resolution: "object-treeify@npm:1.1.33"
|
||||
@@ -17296,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"
|
||||
@@ -17488,6 +17564,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-function-length@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "set-function-length@npm:1.2.2"
|
||||
dependencies:
|
||||
define-data-property: "npm:^1.1.4"
|
||||
es-errors: "npm:^1.3.0"
|
||||
function-bind: "npm:^1.1.2"
|
||||
get-intrinsic: "npm:^1.2.4"
|
||||
gopd: "npm:^1.0.1"
|
||||
has-property-descriptors: "npm:^1.0.2"
|
||||
checksum: 10c0/82850e62f412a258b71e123d4ed3873fa9377c216809551192bb6769329340176f109c2eeae8c22a8d386c76739855f78e8716515c818bcaef384b51110f0f3c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-interval-async@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "set-interval-async@npm:3.0.3"
|
||||
@@ -19316,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"
|
||||
@@ -19325,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"
|
||||
@@ -19361,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"
|
||||
|
||||
Reference in new issue
Block a user