Compare commits

..
Author SHA1 Message Date
Brent Vatne 2cac6b3812 [eas-cli] Fix multi-account review findings
- Make --account per-command: use an in-memory session override instead of
  persistently switching the active account
- Skip --account interception for commands that define their own account flag
  (project:init, chat, billing:manage, billing:subscribe)
- Support --account=value, reject flag-shaped values, stop at the -- terminator
- Error when --account is combined with EXPO_TOKEN
- Invalidate session secrets on the server in all multi-account logout paths
- Preserve unrelated top-level keys in ~/.expo/state.json on account writes
- Keep the v1 accounts map in sync when logging in or out with the flag off,
  so toggling the flag cannot resurrect a stale identity or drop accounts
- Guard v0->v1 migration against a legacy session with no userId
- Pass the browser flag through the add-account flows; error when logout
  username/--all are used without the feature flag
2026-08-26 23:02:36 -07:00
Brent Vatne 2d694aa02e [eas-cli] Update account command args to oclif object-style API 2026-08-26 22:03:05 -07:00
Brent Vatne a79750f9a5 Add changelog entry and fix prettier formatting 2026-08-26 22:00:07 -07:00
Brent Vatne b9cbf27808 Fix --account flag parsing to work with oclif 2026-08-26 21:58:41 -07:00
Brent Vatne 3033559fdf [eas-cli] Add global --account flag for per-command account selection
Allow specifying which account to use for any command via --account flag
when multi-account is enabled. Switches to the specified account before
executing the command.
2026-08-26 21:58:41 -07:00
Brent Vatne d001580c02 [eas-cli] Update account commands for multi-account support
- login: Add menu to add account or switch when already logged in
- logout: Add username arg and --all flag for selective logout
- view: Show other logged-in accounts when multi-account enabled
2026-08-26 21:58:22 -07:00
Brent Vatne bb51f9b9c1 [eas-cli] Add account:list and account:switch commands
New commands for multi-account support (hidden when feature flag disabled):
- account:list: Show all logged-in accounts with active indicator
- account:switch: Switch between accounts interactively or by username
2026-08-26 21:57:53 -07:00
Brent Vatne de095bc521 [eas-cli] Add multi-account session management support
Add experimental multi-account support behind EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 flag.

SessionManager changes:
- Add v1 schema with accounts object and activeAccountId
- Support storing multiple account sessions
- Add switchAccountAsync/switchAccountByUsernameAsync methods
- Add getAllAccounts, removeAccountAsync, removeAllAccountsAsync
- Maintain backward compatibility with Expo CLI via legacy fields
- Read credentials from active account when multi-account enabled
2026-08-26 21:57:53 -07:00
55 changed files with 1526 additions and 1455 deletions

No files matched your search

+1 -4
View File
@@ -81,13 +81,10 @@ 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 and PRODUCTION in cli-versions.json"
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
git push
- name: Prepare changelog for Slack
run: |
+1 -10
View File
@@ -6,23 +6,14 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 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))
- [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))
### 🐛 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
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "23.0.0",
"version": "22.6.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "23.0.0",
"version": "22.6.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": "23.0.0",
"@expo/eas-build-job": "23.0.0",
"@expo/downloader": "22.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "23.0.0",
"@expo/logger": "22.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.0.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.0.0",
"@expo/steps": "22.4.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
+2 -18
View File
@@ -18,12 +18,8 @@ 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,
restoreCocoapodsCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -111,12 +107,6 @@ 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 () => {
@@ -260,12 +250,6 @@ 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,48 +70,6 @@ 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,10 +3,7 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import {
installDependenciesAsync,
installDependenciesWithNpmCacheFallbackAsync,
} from '../installDependencies';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
@@ -239,49 +236,6 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
});
});
describe(installDependenciesAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
});
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: {
@@ -58,11 +58,7 @@ export async function installDependenciesAsync({
break;
}
case PackageManager.BUN:
args = [
'install',
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
];
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
break;
default:
throw new Error(`Unsupported package manager: ${packageManager}`);
-2
View File
@@ -5,8 +5,6 @@ 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,28 +162,6 @@ 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,8 +98,6 @@ function createStepsForIosSimulatorBuild({
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
@@ -108,16 +106,6 @@ 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,
@@ -135,16 +123,6 @@ 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, {
@@ -160,7 +138,6 @@ function createStepsForIosSimulatorBuild({
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
@@ -182,10 +159,6 @@ function createStepsForIosSimulatorBuild({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
@@ -1,136 +0,0 @@
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,17 +1,11 @@
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, uploadCacheAsync } from '../saveCache';
import { compressCacheAsync } 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;
}
@@ -87,71 +81,3 @@ 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,11 +20,6 @@ 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';
@@ -84,13 +79,6 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
} else {
await restoreCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
@@ -218,67 +206,6 @@ 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,11 +20,6 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import {
compressCocoapodsCacheAsync,
getCocoapodsCachePaths,
resolveCocoapodsCacheKeyAsync,
} from '../../utils/cocoapodsCache';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
@@ -83,77 +78,11 @@ 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,7 +103,6 @@ export async function uploadCacheAsync({
archivePath,
size,
platform,
force = false,
}: {
logger: bunyan;
jobId: string;
@@ -114,7 +113,6 @@ export async function uploadCacheAsync({
archivePath: string;
size: number;
platform: Platform | undefined;
force?: boolean;
}): Promise<void> {
const routerURL = platform
? 'v2/turtle-builds/caches/upload-sessions'
@@ -129,14 +127,12 @@ 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',
'960',
'1280',
'--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 = '960';
const SERVE_SIM_MAX_DIMENSION = '1280';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
@@ -1,78 +0,0 @@
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: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
extraEnv: {},
});
});
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
extraEnv: {},
});
});
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
extraEnv: { 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: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
extraEnv: {},
});
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: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
extraEnv: { 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: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
extraEnv: {},
});
expect(Sentry.capture).toHaveBeenCalledWith(
@@ -173,17 +173,9 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${PRODUCTION_VERSION}`],
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
extraEnv: {},
});
});
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, () => {
@@ -226,25 +218,4 @@ 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',
});
});
});
@@ -1,106 +0,0 @@
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 });
}
}
+3 -5
View File
@@ -12,8 +12,6 @@ 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
@@ -76,21 +74,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
extraEnv: NPX_ENV,
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
extraEnv: { EXPO_STAGING: '1' },
};
} else {
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
extraEnv: NPX_ENV,
extraEnv: {},
};
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "23.0.0",
"version": "22.4.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "23.0.0"
"@expo/steps": "22.4.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "23.0.0",
"version": "22.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": "23.0.0",
"version": "22.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",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "23.0.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "23.0.0",
"@expo/logger": "22.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "23.0.0",
"@expo/turtle-spawn": "22.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+142 -150
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "23.0.0",
"version": "22.6.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "23.0.0",
"@expo/eas-json": "23.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/eas-json": "22.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "23.0.0",
"@expo/logger": "22.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.0.0",
"@expo/steps": "22.4.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -30,6 +30,7 @@ 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<
@@ -224,11 +225,75 @@ 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();
@@ -0,0 +1,63 @@
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.`);
}
}
+65 -2
View File
@@ -3,8 +3,10 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { getActorDisplayName } from '../../user/User';
import { confirmAsync, selectAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { Actor, getActorDisplayName } from '../../user/User';
import { isMultiAccountEnabled } from '../../utils/easCli';
export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
@@ -47,6 +49,13 @@ 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({
@@ -60,4 +69,58 @@ 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)}`);
}
}
}
+105 -2
View File
@@ -1,17 +1,120 @@
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 });
await sessionManager.logoutAsync();
Log.log('Logged out');
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.');
}
}
}
@@ -0,0 +1,98 @@
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)}`);
}
}
+38 -23
View File
@@ -4,6 +4,7 @@ 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';
@@ -11,39 +12,53 @@ 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);
}
// 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 {
if (!actor) {
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 {
@@ -122,13 +122,7 @@ describe(SimulatorGet, () => {
}
it('emits JSON when --json is passed', async () => {
const session = makeDeviceRunSession({
type: DeviceRunSessionType.ServeSim,
remoteConfig: {
__typename: 'ServeSimRunSessionRemoteConfig',
previewUrl: 'https://preview.example.com',
},
});
const session = makeDeviceRunSession();
mockByIdAsync.mockResolvedValue(session);
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
@@ -143,7 +137,7 @@ describe(SimulatorGet, () => {
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
name: undefined,
type: 'web-preview-only',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
@@ -332,33 +332,6 @@ 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({ type: DeviceRunSessionType.ServeSim });
const session = makeSession();
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
const { command, getContextAsync } = createCommand(['--json']);
@@ -132,7 +132,7 @@ describe(SimulatorList, () => {
{
id: 'session-123',
name: undefined,
type: 'web-preview-only',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
@@ -162,8 +162,6 @@ describe(SimulatorList, () => {
'new',
'--type',
'appium',
'--type',
'web-preview-only',
'--platform',
'ios',
'--name',
@@ -181,7 +179,7 @@ describe(SimulatorList, () => {
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.ServeSim],
types: [DeviceRunSessionType.Appium],
platforms: [AppPlatform.Ios],
name: 'checkout',
},
@@ -103,8 +103,7 @@ 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. All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
description: 'Type of simulator session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
})(),
@@ -61,8 +61,7 @@ export default class SimulatorList extends EasCommand {
multiple: true,
})(),
type: Flags.option({
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.',
description: 'Filter by session type (repeatable)',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
multiple: true,
})(),
@@ -2,8 +2,6 @@ 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';
@@ -41,20 +39,6 @@ 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(
+1 -1
View File
@@ -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]: 'web-preview-only',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
export const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
+612 -24
View File
@@ -1,7 +1,9 @@
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';
@@ -13,21 +15,61 @@ 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 UserSettingsData = {
auth?: SessionData;
};
type ConnectionType = 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
type SessionData = {
/**
* Legacy session data format (v0 schema).
* These fields are also used for Expo CLI backward compatibility.
*/
type LegacySessionData = {
sessionSecret: string;
// These fields are potentially used by Expo CLI.
userId: string;
username: string;
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
currentConnection: ConnectionType;
};
/**
* 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;
@@ -40,9 +82,26 @@ 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 {
@@ -50,12 +109,16 @@ export default class SessionManager {
}
public getSessionSecret(): string | null {
return this.getSession()?.sessionSecret ?? null;
return this.getActiveSession()?.sessionSecret ?? null;
}
private getSession(): SessionData | null {
// ============================================
// State File Reading
// ============================================
private readStateFile(): StateData | null {
try {
return JsonFile.read<UserSettingsData>(getStateJsonPath())?.auth ?? null;
return JsonFile.read<StateData>(getStateJsonPath()) ?? null;
} catch (error: any) {
if (error.code === 'ENOENT') {
return null;
@@ -64,25 +127,550 @@ 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> {
await JsonFile.setAsync(getStateJsonPath(), 'auth', sessionData, {
default: {},
ensureDir: true,
});
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);
}
}
public async logoutAsync(): Promise<void> {
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);
}
}
this.currentActor = undefined;
if (isMultiAccountEnabled()) {
// Clearing the active account invalidates its session secret on the server.
await this.setSessionAsync(undefined);
return;
}
await this.invalidateCurrentSessionOnServerAsync();
await this.setSessionAsync(undefined);
}
@@ -48,6 +48,8 @@ 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(() => {
@@ -55,17 +57,17 @@ afterEach(() => {
});
describe(SessionManager, () => {
describe('getSession', () => {
describe('getActiveSession', () => {
it('returns null when session is not stored', () => {
const sessionManager = new SessionManager(analytics);
expect(sessionManager['getSession']()).toBeNull();
expect(sessionManager['getActiveSession']()).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['getSession']()).toMatchObject(authStub);
expect(sessionManager['getActiveSession']()).toMatchObject(authStub);
});
});
@@ -392,4 +394,123 @@ 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();
});
});
});
+10
View File
@@ -1,3 +1,13 @@
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';
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "23.0.0",
"version": "22.0.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "23.0.0",
"@expo/eas-build-job": "22.0.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "23.0.0",
"version": "22.6.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,11 +26,11 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "23.0.0",
"@expo/eas-build-job": "23.0.0",
"@expo/logger": "23.0.0",
"@expo/build-tools": "22.6.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "23.0.0",
"@expo/turtle-spawn": "22.0.0",
"bunyan": "^1.8.15",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "23.0.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "23.0.0",
"version": "22.4.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "23.0.0",
"@expo/logger": "23.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "23.0.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "23.0.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "23.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
-2
View File
@@ -17,8 +17,6 @@ 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(),
};
});
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "23.0.0",
"version": "22.6.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,23 +19,23 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "23.0.0",
"@expo/build-tools": "22.6.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "23.0.0",
"@expo/logger": "23.0.0",
"@expo/downloader": "22.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.0.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.0.0",
"@expo/steps": "22.4.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.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",
+58 -70
View File
@@ -1,17 +1,15 @@
import {
BuildContext,
CacheManager,
downloadCacheAsync,
uploadCacheAsync,
} from '@expo/build-tools';
import { BuildContext, CacheManager, uploadWithSignedUrl } from '@expo/build-tools';
import downloadFile from '@expo/downloader';
import { BuildJob } from '@expo/eas-build-job';
import crypto from 'crypto';
import filesize from 'filesize';
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;
@@ -20,7 +18,11 @@ export class GCSCacheManager implements CacheManager {
return;
}
const paths = this.getPaths(ctx);
if ((ctx.job?.cache?.disabled ?? false) || paths.length === 0) {
if (
(ctx.job?.cache?.disabled ?? false) ||
!config.buildCache.gcsSignedUploadUrlForBuildCache ||
paths.length === 0
) {
return;
}
@@ -45,22 +47,23 @@ 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 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,
await uploadWithSignedUrl({
signedUrl: config.buildCache.gcsSignedUploadUrlForBuildCache,
srcGeneratorAsync: async () => {
return fs.createReadStream(archivePath);
},
});
} catch (err: any) {
ctx.logger.error({ err });
@@ -69,47 +72,27 @@ export class GCSCacheManager implements CacheManager {
public async restoreCache(ctx: BuildContext<BuildJob>): Promise<void> {
const paths = this.getPaths(ctx);
if ((ctx.job?.cache?.disabled ?? false) || paths.length === 0) {
if (
(ctx.job?.cache?.disabled ?? false) ||
!config.buildCache.gcsSignedBuildCacheDownloadUrl ||
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(
{
@@ -136,18 +119,23 @@ export class GCSCacheManager implements CacheManager {
);
}
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 ?? '',
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),
};
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}`;
}
}
+38 -152
View File
@@ -1,8 +1,11 @@
import { downloadCacheAsync, uploadCacheAsync } from '@expo/build-tools';
import { Cache, Platform, Workflow } from '@expo/eas-build-job';
import { uploadWithSignedUrl } from '@expo/build-tools';
import downloadFile from '@expo/downloader';
import { Cache, Platform } from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
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.
@@ -12,6 +15,15 @@ import path from 'path';
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(),
}));
@@ -29,34 +41,10 @@ describe(GCSCacheManager, () => {
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
jest.mocked(uploadCacheAsync).mockReset();
jest.mocked(downloadCacheAsync).mockReset();
jest.mocked(uploadWithSignedUrl).mockReset();
jest.mocked(downloadFile).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,
@@ -64,34 +52,37 @@ describe(GCSCacheManager, () => {
): Promise<void> {
await setupBeforeSave();
const manager = new GCSCacheManager();
const mockCtx = createMockCtx(cacheConfig);
let tarContent: Buffer | undefined;
jest.mocked(uploadCacheAsync).mockImplementation(async ({ archivePath }) => {
tarContent = await fs.readFile(archivePath);
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();
});
await manager.saveCache(mockCtx);
if (!tarContent) {
throw new Error('Expected cache archive to be uploaded');
const chunks = [];
for await (const chunk of tarReadStream!) {
chunks.push(chunk);
}
const uploadedTarContent = tarContent;
const tarContent = Buffer.concat(chunks);
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
await setupBeforeRestore();
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' };
jest.mocked(downloadFile).mockImplementation(async (_url, archivePath: string) => {
await fs.mkdirp(mockCtx.workingdir);
await fs.writeFile(archivePath, tarContent);
});
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 () => {
@@ -198,109 +189,4 @@ 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'],
});
});
});
Generated
+39 -161
View File
@@ -2291,24 +2291,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:23.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:22.6.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/downloader": "npm:22.0.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:23.0.0"
"@expo/template-file": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/steps": "npm:22.4.0"
"@expo/template-file": "npm:22.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
@@ -2533,7 +2533,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:23.0.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:22.0.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -2549,13 +2549,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:23.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:22.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
jest: "npm:^29.7.0"
@@ -2568,12 +2568,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:23.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:22.0.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/eas-build-job": "npm:22.0.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2687,7 +2687,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:23.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:22.0.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -2887,12 +2887,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:23.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:22.4.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:23.0.0"
"@expo/logger": "npm:23.0.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -2919,7 +2919,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:23.0.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:22.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 +2942,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:23.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:22.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -2962,17 +2962,18 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:23.0.0"
"@expo/build-tools": "npm:22.6.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/logger": "npm:23.0.0"
"@expo/downloader": "npm:22.0.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:23.0.0"
"@expo/template-file": "npm:23.0.0"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/steps": "npm:22.4.0"
"@expo/template-file": "npm:22.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -2993,7 +2994,6 @@ __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"
@@ -8332,20 +8332,6 @@ __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"
@@ -8906,19 +8892,7 @@ __metadata:
languageName: node
linkType: hard
"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":
"call-bound@npm:^1.0.2":
version: 1.0.4
resolution: "call-bound@npm:1.0.4"
dependencies:
@@ -9753,7 +9727,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:23.0.0"
"@expo/steps": "npm:22.4.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10081,17 +10055,6 @@ __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"
@@ -10337,11 +10300,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:23.0.0"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/logger": "npm:23.0.0"
"@expo/build-tools": "npm:22.6.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:23.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@types/bunyan": "npm:^1.8.11"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
@@ -10395,11 +10358,11 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:23.0.0"
"@expo/eas-json": "npm:23.0.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/eas-json": "npm:22.0.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:23.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10411,7 +10374,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:23.0.0"
"@expo/steps": "npm:22.4.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -10691,7 +10654,7 @@ __metadata:
languageName: node
linkType: hard
"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1":
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
@@ -11446,13 +11409,6 @@ __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"
@@ -11474,27 +11430,6 @@ __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"
@@ -11837,7 +11772,7 @@ __metadata:
languageName: node
linkType: hard
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
@@ -12070,15 +12005,6 @@ __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"
@@ -12872,13 +12798,6 @@ __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"
@@ -13780,19 +13699,6 @@ __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"
@@ -13867,13 +13773,6 @@ __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"
@@ -15849,13 +15748,6 @@ __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"
@@ -17596,20 +17488,6 @@ __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"