Compare commits

..
42 changed files with 711 additions and 1678 deletions

No files matched your search

+3 -4
View File
@@ -8,10 +8,9 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `eas account:avatar:set` to upload an account avatar from the command line. ([#4073](https://github.com/expo/eas-cli/pull/4073) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "22.3.0",
"version": "22.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "22.3.0",
"version": "22.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -54,7 +54,6 @@
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"content-disposition": "1.0.1",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
@@ -78,8 +77,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.9.0",
"@types/content-disposition": "0.5.9",
"@expo/repack-app": "~0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
+2 -2
View File
@@ -98,7 +98,7 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
target: { platform: ctx.job.platform },
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -238,7 +238,7 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
target: { platform: ctx.job.platform },
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
+2 -2
View File
@@ -103,7 +103,7 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -245,7 +245,7 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
@@ -19,10 +19,8 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
@@ -76,8 +74,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -51,29 +51,6 @@ function createMockGraphqlClient({
} as unknown as Client;
}
function createSuccessfulResponse({
body,
url,
headers,
}: {
body: Buffer;
url: string;
headers?: Record<string, string>;
}): Response {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
);
return {
ok: true,
body: Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
},
} as unknown as Response;
}
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -81,12 +58,11 @@ describe('downloadBuild', () => {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: APP_TAR_GZ_BUFFER,
url: APPLICATION_ARCHIVE_URL,
})
);
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -112,11 +88,11 @@ describe('downloadBuild', () => {
applicationArchiveUrl,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: applicationArchiveUrl,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -130,128 +106,6 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('downloads a direct application archive URL without querying GraphQL or forwarding the EAS token', async () => {
const applicationArchiveUrl = `https://artifacts.example.test/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient,
robotAccessToken: 'scoped-eas-token',
extensions: ['apk'],
});
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
applicationArchiveUrl,
expect.objectContaining({ headers: undefined })
);
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('uses the Content-Disposition filename for an extensionless URL', async () => {
const applicationArchiveUrl = 'https://artifacts.example.test/download';
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: Buffer.from('hello'),
url: applicationArchiveUrl,
headers: { 'content-disposition': 'attachment; filename="app.apk"' },
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
});
expect(path.basename(artifactPath)).toBe('app.apk');
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('uses the expected extension when the URL has no filename', async () => {
const applicationArchiveUrl = 'https://artifacts.example.test/';
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
});
expect(path.basename(artifactPath)).toBe('application.apk');
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
await expect(
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
message: 'Pass buildId or applicationArchiveUrl.',
});
await expect(
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
message: 'Pass only one of buildId or applicationArchiveUrl.',
});
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects a non-HTTP application archive URL', async () => {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: 'file:///tmp/app.apk',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
message: 'application_archive_url must be a valid HTTP or HTTPS URL.',
});
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
@@ -349,12 +203,11 @@ describe('downloadBuild', () => {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: APP_TAR_GZ_BUFFER,
url: APPLICATION_ARCHIVE_URL,
})
);
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
await expect(
downloadBuildAsync({
@@ -1,158 +0,0 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createInstallBuildFunction, installBuildAsync } from '../installBuild';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const temporaryDirectories: string[] = [];
async function makeTemporaryDirectoryAsync(): Promise<string> {
const temporaryDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-build-test-')
);
temporaryDirectories.push(temporaryDirectory);
return temporaryDirectory;
}
describe(installBuildAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
afterAll(async () => {
await Promise.all(
temporaryDirectories.map(temporaryDirectory =>
fs.promises.rm(temporaryDirectory, { recursive: true, force: true })
)
);
});
it('installs an iOS Simulator .app', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'Example.app');
await fs.promises.mkdir(artifactPath);
const logger = createMockLogger();
mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
expect(mockedSpawn.mock.calls).toEqual([
[
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
{ stdio: 'pipe', env: {} },
],
['xcrun', ['simctl', 'install', 'booted', artifactPath], { env: {}, logger }],
]);
});
it('installs an Android Emulator .apk', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
const logger = createMockLogger();
mockedSpawn.mockResolvedValueOnce({
stdout:
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
stderr: '',
} as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(result).toEqual({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
});
expect(mockedSpawn.mock.calls).toEqual([
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
]);
});
it.each([
[BuildRuntimePlatform.DARWIN, 'example.ipa'],
[BuildRuntimePlatform.LINUX, 'example.aab'],
])('rejects an incompatible %s artifact', async (runtimePlatform, filename) => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, filename);
await fs.promises.writeFile(artifactPath, 'artifact');
await expect(
installBuildAsync({
artifactPath,
runtimePlatform,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('installs an Android artifact without a launchable activity', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
const logger = createMockLogger();
mockedSpawn.mockResolvedValue({
stdout: "package: name='com.example.app'\n",
stderr: '',
} as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
expect(mockedSpawn.mock.calls).toEqual([
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
]);
});
it('exposes the installed application identifier and activity as step outputs', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
mockedSpawn.mockResolvedValueOnce({
stdout:
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
stderr: '',
} as any);
const installBuild = createInstallBuildFunction();
const buildStep = installBuild.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{ callInputs: { artifact_path: artifactPath } }
);
await buildStep.executeAsync();
expect(buildStep.outputById.application_identifier.value).toBe('com.example.app');
expect(buildStep.outputById.activity_name.value).toBe('com.example.app.MainActivity');
});
});
@@ -1,111 +0,0 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe(launchApplicationAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
it('launches an iOS Simulator application by bundle identifier', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app'],
{ env: {}, logger }
);
});
it('launches an Android Emulator application by package and activity', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
{ env: {}, logger }
);
});
it('requires an activity when launching an Android application', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
expect.any(Object)
);
});
it.each(['application_identifier', 'activity_name'])(
'reports an empty %s input as a user error',
async inputName => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
[inputName]: '',
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: `Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`,
});
expect(mockedSpawn).not.toHaveBeenCalled();
}
);
});
@@ -1,5 +1,4 @@
import { type bunyan } from '@expo/logger';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
@@ -194,53 +193,22 @@ describe(resolveIosSigningOptionsAsync, () => {
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).not.toBeNull();
expect(signingOptions).toMatchObject({
keychainPath: '/tmp/ios_keychain',
signingIdentity: 'Test App Certificate',
provisioningProfile: {
'com.example.testapp': '/tmp/ios_provisioning_profile',
},
expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain');
expect(signingOptions?.signingIdentity).toEqual('Test App Certificate');
expect(signingOptions?.provisioningProfile).toEqual({
'com.example.testapp': '/tmp/ios_provisioning_profile',
});
});
it('should resolve zsign signing options without touching the keychain', async () => {
const job = createTestIosJob();
const tmpDir = '/tmp';
vol.mkdirSync(tmpDir, { recursive: true });
const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
backend: 'zsign',
tmpDir,
});
expect(credentialsManagerSpy).not.toHaveBeenCalled();
expect(signingOptions).toMatchObject({
backend: 'zsign',
keyPassword: job.secrets?.buildCredentials?.['testapp'].distributionCertificate.password,
});
assert(signingOptions?.backend === 'zsign');
expect(signingOptions.certificatePath).toMatch(/\/tmp\/dist-cert-.*\.p12/);
expect(vol.existsSync(signingOptions.certificatePath)).toBe(true);
assert(typeof signingOptions.provisioningProfile === 'object');
const profilePath = signingOptions.provisioningProfile['testapp'];
expect(profilePath).toMatch(/\/tmp\/profile-testapp-.*\.mobileprovision/);
expect(vol.existsSync(profilePath)).toBe(true);
});
it('should return undefined if no build credentials are provided', async () => {
// @ts-expect-error: createTestIosJob does not support buildCredentials as null
const job = createTestIosJob({ buildCredentials: null });
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).toBeUndefined();
});
@@ -251,7 +219,6 @@ describe(resolveIosSigningOptionsAsync, () => {
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).toBeUndefined();
});
@@ -8,11 +8,9 @@ import {
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -28,10 +26,6 @@ import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
type DownloadBuildSource =
| { buildId: string; applicationArchiveUrl?: never }
| { buildId?: never; applicationArchiveUrl: string };
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
@@ -55,12 +49,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
inputProviders: [
BuildStepInput.createProvider({
id: 'build_id',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
required: false,
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
@@ -81,39 +70,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
const extensions = z.array(z.string()).parse(inputs.extensions.value);
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
const buildId = inputs.build_id.value
? z.string().uuid().parse(inputs.build_id.value)
: undefined;
const applicationArchiveUrl = inputs.application_archive_url.value
? parseHttpApplicationArchiveUrl(inputs.application_archive_url.value)
: undefined;
let source: DownloadBuildSource;
if (buildId) {
if (applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
source = { buildId };
} else {
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass build_id or application_archive_url.'
);
}
source = { applicationArchiveUrl };
}
logger.info(
buildId ? `Downloading build ${buildId}...` : `Downloading application archive...`
);
const buildId = z.string().uuid().parse(inputs.build_id.value);
logger.info(`Downloading build ${buildId}...`);
const { artifactPath } = await downloadBuildAsync({
logger,
...source,
buildId,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
@@ -153,50 +115,39 @@ async function fetchApplicationArchiveUrlAsync({
return applicationArchiveUrl;
}
export async function downloadBuildAsync(
params: DownloadBuildSource & {
logger: bunyan;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions } = params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
if (params.applicationArchiveUrl) {
if (params.buildId) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of buildId or applicationArchiveUrl.'
);
}
downloadUrl = parseHttpApplicationArchiveUrl(params.applicationArchiveUrl);
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass buildId or applicationArchiveUrl.'
);
}
export async function downloadBuildAsync({
logger,
buildId,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
const downloadDestinationDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
// URL may contain percent-encoded characters, e.g. my%20file.apk
// this replaces all non-alphanumeric characters (excluding dot) with underscore
const archiveFilename = path
.basename(new URL(response.url).pathname)
.replace(/([^a-z0-9.-]+)/gi, '_');
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
await streamPipeline(response.body, fs.createWriteStream(archivePath));
@@ -243,47 +194,3 @@ export async function downloadBuildAsync(
return { artifactPath: matchingFiles[0] };
}
function parseHttpApplicationArchiveUrl(value: unknown): string {
try {
const applicationArchiveUrl = z.string().parse(value);
const parsedUrl = new URL(applicationArchiveUrl);
assert(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:');
return applicationArchiveUrl;
} catch {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
'application_archive_url must be a valid HTTP or HTTPS URL.'
);
}
}
function resolveArchiveFilename({
response,
extensions,
}: {
response: Awaited<ReturnType<typeof fetch>>;
extensions: string[];
}): string {
const contentDispositionHeader = response.headers.get('content-disposition');
let headerFilename: string | undefined;
if (contentDispositionHeader) {
try {
headerFilename = contentDisposition.parse(contentDispositionHeader).parameters.filename;
} catch {
// Ignore malformed Content-Disposition headers and fall back to the response URL.
}
}
const urlFilename = path.basename(new URL(response.url).pathname);
let archiveFilename = path.basename(headerFilename ?? urlFilename ?? '');
if (!archiveFilename || archiveFilename === '.' || archiveFilename === '..') {
archiveFilename = 'application';
}
if (!path.extname(archiveFilename) && extensions.length === 1) {
archiveFilename = `${archiveFilename}.${extensions[0]}`;
}
// URL and header filenames may contain percent-encoded or unsafe filesystem characters.
return archiveFilename.replace(/([^a-z0-9.-]+)/gi, '_');
}
@@ -1,124 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
export function createInstallBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_build',
name: 'Install build',
__metricsId: 'eas/install_build',
inputProviders: [
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'application_identifier',
required: true,
}),
BuildStepOutput.createProvider({
id: 'activity_name',
required: false,
}),
],
fn: async ({ global, logger }, { inputs, outputs, env }) => {
const artifactPath = z.string().min(1).parse(inputs.artifact_path.value);
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform: global.runtimePlatform,
env,
logger,
});
outputs.application_identifier.set(applicationIdentifier);
if (activityName) {
outputs.activity_name.set(activityName);
}
},
});
}
export async function installBuildAsync({
artifactPath,
runtimePlatform,
env,
logger,
}: {
artifactPath: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{ applicationIdentifier: string; activityName?: string }> {
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
`Build artifact does not exist at ${artifactPath}.`,
{ cause: err }
);
});
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'iOS Simulator sessions require a .app build artifact.'
);
}
const infoPlistPath = path.join(artifactPath, 'Info.plist');
const { stdout } = await spawn(
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
{ stdio: 'pipe', env }
);
const applicationIdentifier = stdout.trim();
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
);
}
logger.info(`Installing ${artifactPath} on the iOS Simulator.`);
await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger });
return { applicationIdentifier };
}
if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'Android Emulator sessions require an .apk build artifact.'
);
}
const { stdout } = await spawn('aapt2', ['dump', 'badging', artifactPath], {
stdio: 'pipe',
env,
});
const applicationIdentifier = stdout.match(/package: name='([^']+)'/)?.[1];
const activityName = stdout.match(/launchable-activity: name='([^']+)'/)?.[1];
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read an Android application identifier from ${artifactPath}.`
);
}
logger.info(`Installing ${artifactPath} on the Android Emulator.`);
await spawn('adb', ['install', '-r', artifactPath], { env, logger });
return { applicationIdentifier, activityName };
}
@@ -1,94 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
export function createLaunchApplicationFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'launch_application',
name: 'Launch application',
__metricsId: 'eas/launch_application',
inputProviders: [
BuildStepInput.createProvider({
id: 'application_identifier',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'activity_name',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ global, logger }, { inputs, env }) => {
const applicationIdentifier = parseNonEmptyStringInput(
inputs.application_identifier.value,
'application_identifier'
);
const activityName =
inputs.activity_name.value === undefined
? undefined
: parseNonEmptyStringInput(inputs.activity_name.value, 'activity_name');
await launchApplicationAsync({
applicationIdentifier,
activityName,
runtimePlatform: global.runtimePlatform,
env,
logger,
});
},
});
}
export async function launchApplicationAsync({
applicationIdentifier,
activityName,
runtimePlatform,
env,
logger,
}: {
applicationIdentifier: string;
activityName?: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Launching ${applicationIdentifier}.`);
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier], {
env,
logger,
});
return;
}
if (!activityName) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY',
'Launching an Android application requires activity_name.'
);
}
logger.info(`Launching ${applicationIdentifier}.`);
await spawn('adb', ['shell', 'am', 'start', '-n', `${applicationIdentifier}/${activityName}`], {
env,
logger,
});
}
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`
);
}
return value;
}
@@ -62,12 +62,6 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_backend',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
allowedValues: ['fastlane', 'zsign'],
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -150,14 +144,12 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
backend: inputs.ios_signing_backend.value as 'fastlane' | 'zsign' | undefined,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
tmpDir,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -305,51 +297,24 @@ export async function resolveAndroidSigningOptionsAsync({
}
/**
* Resolves iOS signing options from the job secrets, dispatching on the
* requested signing backend.
* Resolves iOS signing options from the job secrets.
*/
export async function resolveIosSigningOptionsAsync({
job,
logger,
backend,
useAppEntitlements,
entitlementsPath,
tmpDir,
}: {
job: Job;
logger: bunyan;
backend?: 'fastlane' | 'zsign';
useAppEntitlements?: boolean;
entitlementsPath?: string;
tmpDir: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
if (iosJob.simulator || buildCredentials == null) {
return undefined;
}
const commonOptions = { buildCredentials, logger, useAppEntitlements, entitlementsPath };
return backend === 'zsign'
? await createIosZsignOptionsAsync({ ...commonOptions, tmpDir })
: await createIosFastlaneOptionsAsync(commonOptions);
}
/**
* Creates signing options for the fastlane backend: certificates are imported
* into a temporary keychain and provisioning profiles are parsed with the
* macOS `security` tool.
*/
async function createIosFastlaneOptionsAsync({
buildCredentials,
logger,
useAppEntitlements,
entitlementsPath,
}: {
buildCredentials: Ios.BuildCredentials;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions> {
const credentialsManager = new IosCredentialsManager(buildCredentials);
const credentials = await credentialsManager.prepare(logger);
@@ -365,53 +330,3 @@ async function createIosFastlaneOptionsAsync({
entitlementsPath,
};
}
/**
* Creates signing options for the zsign backend. The distribution certificate
* secret is already a PKCS#12 file, so it goes to disk as-is together with the
* provisioning profiles.
*/
async function createIosZsignOptionsAsync({
buildCredentials,
logger,
useAppEntitlements,
entitlementsPath,
tmpDir,
}: {
buildCredentials: Ios.BuildCredentials;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
tmpDir: string;
}): Promise<IosSigningOptions> {
const targets = Object.entries(buildCredentials);
const [targetName, targetCredentials] = targets[0];
logger.info(`Using the distribution certificate from target '${targetName}' for zsign`);
const certificatePath = path.join(tmpDir, `dist-cert-${randomUUID()}.p12`);
await fs.promises.writeFile(
certificatePath,
new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64'))
);
// zsign matches profiles to bundles by the app-id suffix itself, so the
// record keys are informational only.
const provisioningProfile: Record<string, string> = {};
for (const [target, credentials] of targets) {
const profilePath = path.join(tmpDir, `profile-${target}-${randomUUID()}.mobileprovision`);
await fs.promises.writeFile(
profilePath,
new Uint8Array(Buffer.from(credentials.provisioningProfileBase64, 'base64'))
);
provisioningProfile[target] = profilePath;
}
return {
backend: 'zsign',
certificatePath,
keyPassword: targetCredentials.distributionCertificate.password,
provisioningProfile,
useAppEntitlements,
entitlementsPath,
};
}
@@ -15,9 +15,8 @@ import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
import {
CcacheBuildTarget,
CACHE_KEY_PREFIX_BY_PLATFORM,
generateDefaultBuildCacheKeyAsync,
getCcacheKeyPrefix,
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
@@ -36,11 +35,6 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -54,20 +48,10 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await restoreCcacheAsync({
logger,
workingDirectory,
target,
platform,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
@@ -108,13 +92,13 @@ export function createCacheStatsBuildFunction(): BuildFunction {
export async function restoreCcacheAsync({
logger,
workingDirectory,
target,
platform,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
target: CcacheBuildTarget;
platform: Platform;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
@@ -152,7 +136,7 @@ export async function restoreCcacheAsync({
})
);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
logger.info(`Restoring cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -163,8 +147,8 @@ export async function restoreCcacheAsync({
robotAccessToken,
paths: [cachePath],
key: cacheKey,
keyPrefixes: [getCcacheKeyPrefix(target)],
platform: target.platform,
keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
platform,
});
await decompressCacheAsync({
@@ -191,7 +175,7 @@ export async function restoreCcacheAsync({
expoApiServerURL,
robotAccessToken,
paths: [cachePath],
platform: target.platform,
platform,
});
await decompressCacheAsync({
archivePath,
@@ -15,11 +15,7 @@ import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import {
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
@@ -34,11 +30,6 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -52,20 +43,10 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await saveCcacheAsync({
logger,
workingDirectory,
target,
platform,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
@@ -86,14 +67,14 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
export async function saveCcacheAsync({
logger,
workingDirectory,
target,
platform,
evictUsedBefore,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
target: CcacheBuildTarget;
platform: Platform;
evictUsedBefore: Date;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
@@ -118,7 +99,7 @@ export async function saveCcacheAsync({
}
try {
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
logger.info(`Saving cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -162,7 +143,7 @@ export async function saveCcacheAsync({
key: cacheKey,
paths: [cachePath],
size,
platform: target.platform,
platform,
});
} catch (err) {
logger.error({ err }, 'Failed to save cache');
@@ -27,11 +27,6 @@ export function createStartServeSimRemoteSessionBuildFunction(
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -42,7 +37,6 @@ export function createStartServeSimRemoteSessionBuildFunction(
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
logger.info('Starting serve-sim remote session.');
@@ -53,7 +47,6 @@ export function createStartServeSimRemoteSessionBuildFunction(
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);
@@ -157,20 +157,6 @@ describe(createServeSimArgs, () => {
'https://expo.dev',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
]);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
@@ -19,7 +19,7 @@ import { sleepAsync } from '../../utils/retry';
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_PACKAGE_SPEC = '@expo/serve-sim@latest';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '1280';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
@@ -596,24 +596,18 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
return args;
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createServeSimPackageSpec(packageVersion),
SERVE_SIM_PACKAGE_SPEC,
'--port',
String(port),
'--host',
@@ -706,24 +700,20 @@ export async function startServeSimWithTunnelAsync(
env,
logger,
timeoutMs,
packageVersion,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<ServeSimPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
);
logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`);
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const serveSim = spawnDetached({
command: 'npx',
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
env,
});
@@ -1,18 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { getCcacheKeyPrefix } from '../cacheKey';
describe(getCcacheKeyPrefix, () => {
it('uses separate prefixes for iOS device and simulator builds', () => {
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: false })).toBe(
'ios-device-ccache-'
);
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: true })).toBe(
'ios-simulator-ccache-'
);
});
it('does not change the Android prefix', () => {
expect(getCcacheKeyPrefix({ platform: Platform.ANDROID })).toBe('android-ccache-');
});
});
+7 -14
View File
@@ -7,24 +7,17 @@ import path from 'path';
import { findPackagerRootDir } from './packageManager';
const IOS_DEVICE_CACHE_KEY_PREFIX = 'ios-device-ccache-';
const IOS_SIMULATOR_CACHE_KEY_PREFIX = 'ios-simulator-ccache-';
const IOS_CACHE_KEY_PREFIX = 'ios-ccache-';
const ANDROID_CACHE_KEY_PREFIX = 'android-ccache-';
const PUBLIC_IOS_CACHE_KEY_PREFIX = 'public-ios-ccache-';
const PUBLIC_ANDROID_CACHE_KEY_PREFIX = 'public-android-ccache-';
const DARWIN_CACHE_PATH = 'Library/Caches/ccache';
const LINUX_CACHE_PATH = '.cache/ccache';
export type CcacheBuildTarget =
| { platform: Platform.ANDROID }
| { platform: Platform.IOS; simulator: boolean };
export function getCcacheKeyPrefix(target: CcacheBuildTarget): string {
if (target.platform === Platform.IOS) {
return target.simulator ? IOS_SIMULATOR_CACHE_KEY_PREFIX : IOS_DEVICE_CACHE_KEY_PREFIX;
}
return ANDROID_CACHE_KEY_PREFIX;
}
export const CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
[Platform.ANDROID]: ANDROID_CACHE_KEY_PREFIX,
[Platform.IOS]: IOS_CACHE_KEY_PREFIX,
};
export const PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
[Platform.ANDROID]: PUBLIC_ANDROID_CACHE_KEY_PREFIX,
@@ -43,7 +36,7 @@ export function getCcachePath(env: Record<string, string | undefined>): string {
export async function generateDefaultBuildCacheKeyAsync(
workingDirectory: string,
target: CcacheBuildTarget
platform: Platform
): Promise<string> {
// This will resolve which package manager and use the relevant lock file
// The lock file hash is the key and ensures cache is fresh
@@ -52,7 +45,7 @@ export async function generateDefaultBuildCacheKeyAsync(
const lockPath = path.join(packagerRunDir, manager.lockFile);
try {
return `${getCcacheKeyPrefix(target)}${hashFiles([lockPath])}`;
return `${CACHE_KEY_PREFIX_BY_PLATFORM[platform]}${hashFiles([lockPath])}`;
} catch (err: any) {
throw new Error(`Failed to read lockfile for cache key generation: ${err.message}`);
}
+223 -214
View File
File diff suppressed because it is too large. Load diff
-24
View File
@@ -37211,30 +37211,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "applicationArchiveUrl",
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildId",
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deviceIdentifier",
"description": "Identifier of the virtual device to start for the session. On iOS this is a\nSimulator device name or UDID (e.g. \"iPhone 16 Pro\"). On Android this is an\nAVD hardware profile id (e.g. \"pixel_7\"). If omitted, the runner picks a\ndefault device.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "22.3.0",
"version": "22.2.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -1,17 +1,17 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go, { toRepackTargetSdkVersion } from '../../commands/go';
import Go from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync, toRepackTargetSdkVersion } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
@@ -0,0 +1,154 @@
import { Config } from '@oclif/core';
import { vol } from 'memfs';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountUploadSessionType } from '../../../../graphql/generated';
import { AccountQuery } from '../../../../graphql/queries/AccountQuery';
import { selectAsync } from '../../../../prompts';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../../../../uploads';
import { Actor } from '../../../../user/User';
import { sleepAsync } from '../../../../utils/promise';
import AccountAvatarSet from '../set';
jest.mock('fs');
jest.mock('../../../../graphql/queries/AccountQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../../prompts');
jest.mock('../../../../uploads');
jest.mock('../../../../utils/promise');
const mockByIdProfileImageUrlAsync = jest.mocked(AccountQuery.byIdProfileImageUrlAsync);
const mockSelectAsync = jest.mocked(selectAsync);
const mockUploadAsync = jest.mocked(uploadAccountScopedFileAtPathToGCSAsync);
const mockSleepAsync = jest.mocked(sleepAsync);
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(AccountAvatarSet, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const personalAccount = { id: 'account-1', name: 'test-user' };
const orgAccount = { id: 'account-2', name: 'test-org' };
let now: number;
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
mockSleepAsync.mockImplementation(async ms => {
now += ms;
});
mockByIdProfileImageUrlAsync
.mockResolvedValueOnce('https://example.com/old.png')
.mockResolvedValueOnce('https://example.com/new.png');
});
afterEach(() => {
jest.restoreAllMocks();
});
function createCommand(
argv: string[],
accounts: { id: string; name: string }[]
): AccountAvatarSet {
const command = new AccountAvatarSet(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient, actor: { accounts } as Actor },
});
return command;
}
it('uploads the avatar for the account passed as an argument', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(['/app/avatar.png', 'test-org'], [personalAccount, orgAccount]);
await command.runAsync();
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
type: AccountUploadSessionType.ProfileImageUpload,
accountId: orgAccount.id,
path: '/app/avatar.png',
});
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('uses the only account without prompting', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(['/app/avatar.png'], [personalAccount]);
await command.runAsync();
expect(mockUploadAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ accountId: personalAccount.id })
);
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('prompts for an account when there are multiple', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
mockSelectAsync.mockResolvedValue(orgAccount);
const command = createCommand(['/app/avatar.png'], [personalAccount, orgAccount]);
await command.runAsync();
expect(mockSelectAsync).toHaveBeenCalled();
expect(mockUploadAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ accountId: orgAccount.id })
);
});
it('throws in non-interactive mode with multiple accounts and no account name', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(
['/app/avatar.png', '--non-interactive'],
[personalAccount, orgAccount]
);
await expect(command.runAsync()).rejects.toThrow(
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
);
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws for an account the user does not have access to', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(['/app/avatar.png', 'other-org'], [personalAccount, orgAccount]);
await expect(command.runAsync()).rejects.toThrow(
'Account "other-org" not found or you don\'t have access. Available accounts: test-user, test-org'
);
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws when the file does not exist', async () => {
const command = createCommand(['/app/missing.png', 'test-org'], [personalAccount, orgAccount]);
await expect(command.runAsync()).rejects.toThrow('No file found at /app/missing.png');
expect(mockUploadAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,114 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { getExpoWebsiteBaseUrl } from '../../../api';
import EasCommand from '../../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AccountUploadSessionType } from '../../../graphql/generated';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../../../uploads';
import {
pollForProfileImageChangeAsync,
validateProfileImageAsync,
} from '../../../utils/profileImages';
export default class AccountAvatarSet extends EasCommand {
static override description =
'set the avatar for an account (for a personal account, this is your user avatar)';
static override args = {
path: Args.string({
required: true,
description:
'Path to the avatar image (PNG or JPEG, at most 10 MB). Non-square images are center-cropped to a square.',
}),
account_name: Args.string({
required: false,
description:
'Name of the account to set the avatar for. Defaults to a prompt when you have access to multiple accounts.',
}),
};
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { path: imagePath, account_name: accountName },
flags,
} = await this.parse(AccountAvatarSet);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(AccountAvatarSet, { nonInteractive });
await validateProfileImageAsync(imagePath);
let targetAccount: { id: string; name: string };
if (accountName) {
const found = actor.accounts.find(account => account.name === accountName);
if (found) {
targetAccount = found;
} else {
const availableAccounts = actor.accounts.map(account => account.name).join(', ');
throw new Error(
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
);
}
} else if (actor.accounts.length === 1) {
targetAccount = actor.accounts[0];
} else if (nonInteractive) {
throw new Error(
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
);
} else {
targetAccount = await selectAsync(
'Select account to set the avatar for:',
actor.accounts.map(account => ({
title: account.name,
value: account,
})),
{ initial: actor.accounts[0] }
);
}
const accountSettingsUrl = new URL(
`/accounts/${targetAccount.name}/settings`,
getExpoWebsiteBaseUrl()
).toString();
const previousProfileImageUrl = await AccountQuery.byIdProfileImageUrlAsync(
graphqlClient,
targetAccount.id
);
const spinner = ora('Uploading avatar').start();
try {
await uploadAccountScopedFileAtPathToGCSAsync(graphqlClient, {
type: AccountUploadSessionType.ProfileImageUpload,
accountId: targetAccount.id,
path: imagePath,
});
spinner.text = 'Processing avatar';
await pollForProfileImageChangeAsync({
fetchProfileImageUrlAsync: async () =>
await AccountQuery.byIdProfileImageUrlAsync(graphqlClient, targetAccount.id),
previousProfileImageUrl,
fallbackUrl: accountSettingsUrl,
});
spinner.succeed(`Set avatar for ${chalk.bold(targetAccount.name)}`);
Log.withTick(`View it in the account settings: ${link(accountSettingsUrl)}`);
} catch (error) {
spinner.fail('Failed to set avatar');
throw error;
}
}
}
+16 -2
View File
@@ -1,4 +1,4 @@
import { ExpoConfig } from '@expo/config';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -30,7 +30,7 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync, selectAsync } from '../prompts';
import { ora } from '../ora';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
@@ -48,6 +48,20 @@ function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
export function toRepackTargetSdkVersion(sdkVersion: string | undefined): string | undefined {
const coerced = semver.coerce(sdkVersion);
return coerced ? `${coerced.major}.0.0` : sdkVersion;
@@ -137,7 +137,7 @@ describe(ProjectIconSet, () => {
const command = createCommand(['/app/icon.png']);
await expect(command.runAsync()).rejects.toThrow(
'Timed out waiting for the icon to be processed'
'Timed out waiting for the image to be processed'
);
expect(mockUploadAsync).toHaveBeenCalledTimes(1);
});
@@ -7,11 +7,11 @@ import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { uploadProjectIconAsync } from '../../../project/projectIcon';
import {
pollForProfileImageChangeAsync,
uploadProjectIconAsync,
validateIconAsync,
} from '../../../project/projectIcon';
validateProfileImageAsync,
} from '../../../utils/profileImages';
export default class ProjectIconSet extends EasCommand {
static override description = 'set the project icon displayed on the EAS dashboard';
@@ -45,7 +45,7 @@ export default class ProjectIconSet extends EasCommand {
nonInteractive: flags['non-interactive'],
});
await validateIconAsync(imagePath);
await validateProfileImageAsync(imagePath);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
@@ -58,13 +58,12 @@ export default class ProjectIconSet extends EasCommand {
try {
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath });
// The icon is processed asynchronously (resized and assigned to the
// project by the server), so poll until the icon URL changes.
spinner.text = 'Processing project icon';
await pollForProfileImageChangeAsync(graphqlClient, {
projectId,
await pollForProfileImageChangeAsync({
fetchProfileImageUrlAsync: async () =>
await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId),
previousProfileImageUrl,
projectDashboardUrl,
fallbackUrl: projectDashboardUrl,
});
spinner.succeed(`Set icon for ${chalk.bold(app.fullName)}`);
Log.withTick(`View it on the project page: ${link(projectDashboardUrl)}`);
@@ -23,7 +23,6 @@ import {
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../../simulator/expoGo';
import Simulator from '../index';
jest.mock('fs-extra');
@@ -46,7 +45,6 @@ jest.mock('../../../simulator/env', () => ({
loadSimulatorEnvAsync: jest.fn(),
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/expoGo');
jest.mock('../../../prompts');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
@@ -80,9 +78,6 @@ const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQue
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockResolveExpoGoApplicationArchiveUrlAsync = jest.mocked(
resolveExpoGoApplicationArchiveUrlAsync
);
const mockOra = jest.mocked(ora);
const mockPromptAsync = jest.mocked(promptAsync);
@@ -166,9 +161,6 @@ describe(Simulator, () => {
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockLoadSimulatorEnvAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(
'https://example.test/expo-go.tar.gz'
);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
@@ -506,103 +498,6 @@ describe(Simulator, () => {
);
});
it('forwards --build-id to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-id',
' 8d8b713c-1834-4bd3-91e6-46f895422cbc ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ buildId: '8d8b713c-1834-4bd3-91e6-46f895422cbc' })
);
});
it('forwards --application-archive-url to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--application-archive-url',
' https://example.test/builds/app.apk ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/builds/app.apk',
})
);
});
it.each([
['ios', AppPlatform.Ios, 'https://example.test/expo-go.tar.gz'],
['android', AppPlatform.Android, 'https://example.test/expo-go.apk'],
] as const)(
'resolves and forwards the %s Expo Go archive when --expo-go is passed',
async (platformFlag, appPlatform, applicationArchiveUrl) => {
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(applicationArchiveUrl);
const { command } = createCommand([
'--platform',
platformFlag,
'--non-interactive',
'--expo-go',
]);
await command.runAsync();
expect(mockResolveExpoGoApplicationArchiveUrlAsync).toHaveBeenCalledWith({
platform: platformFlag,
projectDir,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
applicationArchiveUrl,
platform: appPlatform,
})
);
}
);
it('rejects passing --build-id and --application-archive-url together', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-id',
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
'--application-archive-url',
'https://example.test/builds/app.tar.gz',
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it.each([
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--expo-go',
sourceFlag,
sourceValue,
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockResolveExpoGoApplicationArchiveUrlAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('stops the simulator session when interrupted before the session is ready', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
@@ -28,7 +28,6 @@ import {
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../simulator/expoGo';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
@@ -73,20 +72,6 @@ export default class Simulator extends EasCommand {
description:
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
}),
'build-id': Flags.string({
description: 'EAS Build to install and launch before the simulator session is ready.',
exclusive: ['application-archive-url', 'expo-go'],
}),
'application-archive-url': Flags.string({
description:
'Application archive URL to download, install, and launch before the simulator session is ready.',
exclusive: ['build-id', 'expo-go'],
}),
'expo-go': Flags.boolean({
description:
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
exclusive: ['build-id', 'application-archive-url'],
}),
type: Flags.option({
description: 'Type of simulator session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
@@ -154,8 +139,6 @@ export default class Simulator extends EasCommand {
// --name as if it had been omitted rather than surfacing a validation error.
const name = flags.name?.trim() || undefined;
const deviceIdentifier = flags.device?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
await loadSimulatorEnvAsync(projectDir);
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
@@ -166,12 +149,6 @@ export default class Simulator extends EasCommand {
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const applicationArchiveUrl = flags['expo-go']
? await resolveExpoGoApplicationArchiveUrlAsync({
platform: platform === AppPlatform.Ios ? 'ios' : 'android',
projectDir,
})
: applicationArchiveUrlFromFlag;
if (existingDeviceRunSessionId) {
Log.warn(
@@ -194,8 +171,6 @@ export default class Simulator extends EasCommand {
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
deviceIdentifier,
...(buildId ? { buildId } : {}),
...(applicationArchiveUrl ? { applicationArchiveUrl } : {}),
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
+7 -10
View File
@@ -5234,16 +5234,6 @@ export type CreateDeviceRunSessionEventLogUploadSessionResult = {
export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
* Application archive URL to download, install, and launch before the simulator session
* becomes available. Mutually exclusive with buildId.
*/
applicationArchiveUrl?: InputMaybe<Scalars['String']['input']>;
/**
* EAS Build to install and launch before the simulator session becomes available.
* Mutually exclusive with applicationArchiveUrl.
*/
buildId?: InputMaybe<Scalars['ID']['input']>;
/**
* Identifier of the virtual device to start for the session. On iOS this is a
* Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android this is an
@@ -14659,6 +14649,13 @@ export type AccountByNameQueryVariables = Exact<{
export type AccountByNameQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, name: string } } };
export type AccountByIdProfileImageUrlQueryVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type AccountByIdProfileImageUrlQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, profileImageUrl: string } } };
export type AccountFullUsageQueryVariables = Exact<{
accountId: Scalars['String']['input'];
currentDate: Scalars['DateTime']['input'];
@@ -4,6 +4,7 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AccountByIdProfileImageUrlQuery,
AccountFullUsageQuery as AccountFullUsageQueryType,
AccountFullUsageQueryVariables,
AccountUsageForOverageWarningQuery,
@@ -45,6 +46,35 @@ export const AccountQuery = {
return data.account.byName;
},
async byIdProfileImageUrlAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<string | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AccountByIdProfileImageUrlQuery>(
gql`
query AccountByIdProfileImageUrlQuery($accountId: String!) {
account {
byId(accountId: $accountId) {
id
profileImageUrl
}
}
}
`,
{ accountId },
{
requestPolicy: 'network-only',
additionalTypenames: ['Account'],
}
)
.toPromise()
);
return data.account.byId.profileImageUrl ?? null;
},
async getFullUsageAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
@@ -1,17 +0,0 @@
import { getConfigFilePaths } from '@expo/config';
import { getPrivateExpoConfigAsync } from './expoConfig';
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
@@ -1,102 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../expoGo';
jest.mock('@expo/spawn-async');
jest.mock('../../project/detectProjectSdkVersionAsync');
const projectDir = '/test/project';
const mockDetectProjectSdkVersionAsync = jest.mocked(detectProjectSdkVersionAsync);
const mockSpawnAsync = jest.mocked(spawnAsync);
describe(resolveExpoGoApplicationArchiveUrlAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockDetectProjectSdkVersionAsync.mockResolvedValue('55.0.0');
mockSpawnAsync.mockResolvedValue({
stdout: 'https://example.test/expo-go.tar.gz\n',
} as never);
});
it.each(['ios', 'android'] as const)(
'resolves the Expo Go URL for SDK 55 on %s',
async platform => {
await expect(resolveExpoGoApplicationArchiveUrlAsync({ platform, projectDir })).resolves.toBe(
'https://example.test/expo-go.tar.gz'
);
expect(mockSpawnAsync).toHaveBeenCalledWith(
'npx',
['--yes', 'expo-go', 'url', platform, '55'],
{
cwd: projectDir,
stdio: 'pipe',
}
);
}
);
it.each([
[
'plain output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from https://example.test/Expo-Go-57.0.9.tar.gz\n',
],
[
'Markdown hyperlink output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from [https://example.test/Expo-Go-57.0.9.tar.gz](https://example.test/Expo-Go-57.0.9.tar.gz)\n',
],
[
'terminal hyperlink output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from \u001B]8;;https://example.test/Expo-Go-57.0.9.tar.gz\u0007' +
'https://example.test/Expo-Go-57.0.9.tar.gz\u001B]8;;\u0007\n',
],
])('extracts the URL from %s', async (_description, stdout) => {
mockSpawnAsync.mockResolvedValue({ stdout } as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).resolves.toBe('https://example.test/Expo-Go-57.0.9.tar.gz');
});
it.each([undefined, 'UNVERSIONED'])(
'rejects an undetectable SDK version (%s)',
async sdkVersion => {
mockDetectProjectSdkVersionAsync.mockResolvedValue(sdkVersion);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).rejects.toThrow(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected."
);
expect(mockSpawnAsync).not.toHaveBeenCalled();
}
);
it('reports how to diagnose an expo-go failure', async () => {
mockSpawnAsync.mockRejectedValue(new Error('network unavailable') as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'android', projectDir })
).rejects.toThrow(
'Failed to resolve the Expo Go download URL for SDK 55 on android. network unavailable ' +
'Run "npx expo-go url android 55" to diagnose the problem'
);
});
it.each([
'',
'not a URL',
'file:///tmp/expo-go.app',
'Resolving the correct Expo Go version...\nDownload Expo Go from nowhere\n',
])('rejects an invalid expo-go URL (%s)', async stdout => {
mockSpawnAsync.mockResolvedValue({ stdout } as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).rejects.toThrow('expo-go returned an invalid download URL for SDK 55 on ios.');
});
});
-71
View File
@@ -1,71 +0,0 @@
import { stripVTControlCharacters } from 'util';
import spawnAsync from '@expo/spawn-async';
import semver from 'semver';
import { EasCommandError } from '../commandUtils/errors';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
export type ExpoGoPlatform = 'android' | 'ios';
const EXPO_GO_DOWNLOAD_MESSAGE = 'Download Expo Go from';
function extractApplicationArchiveUrl(stdout: string): string | undefined {
const output = stripVTControlCharacters(stdout);
const outputLines = output.trim().split(/\r?\n/);
const downloadMessageLine = outputLines.find(line => line.includes(EXPO_GO_DOWNLOAD_MESSAGE));
const bareUrlOutput = outputLines.length === 1 ? outputLines[0] : undefined;
const lineWithUrl = downloadMessageLine ?? bareUrlOutput;
return lineWithUrl?.match(/https?:\/\/[^\s\])]+/)?.[0];
}
export async function resolveExpoGoApplicationArchiveUrlAsync({
platform,
projectDir,
}: {
platform: ExpoGoPlatform;
projectDir: string;
}): Promise<string> {
const sdkVersion = await detectProjectSdkVersionAsync(projectDir);
const sdkMajorVersion = semver.coerce(sdkVersion)?.major;
if (!sdkMajorVersion) {
throw new EasCommandError(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected. " +
'Make sure the project has Expo installed and a valid app config, or use --build-id or --application-archive-url instead.'
);
}
const args = ['--yes', 'expo-go', 'url', platform, String(sdkMajorVersion)];
let stdout: string;
try {
({ stdout } = await spawnAsync('npx', args, {
cwd: projectDir,
stdio: 'pipe',
}));
} catch (error) {
const reason = error instanceof Error ? ` ${error.message}` : '';
throw new EasCommandError(
`Failed to resolve the Expo Go download URL for SDK ${sdkMajorVersion} on ${platform}.${reason} ` +
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
);
}
const applicationArchiveUrl = extractApplicationArchiveUrl(stdout);
try {
if (!applicationArchiveUrl) {
throw new Error('Missing URL');
}
const parsedUrl = new URL(applicationArchiveUrl);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Unsupported protocol');
}
} catch {
throw new EasCommandError(
`expo-go returned an invalid download URL for SDK ${sdkMajorVersion} on ${platform}. ` +
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
);
}
return applicationArchiveUrl;
}
+2 -2
View File
@@ -42,12 +42,12 @@ export async function uploadAccountScopedFileAtPathToGCSAsync(
type,
accountId,
path,
handleProgressEvent,
handleProgressEvent = () => {},
}: {
type: AccountUploadSessionType;
accountId: string;
path: string;
handleProgressEvent: ProgressHandler;
handleProgressEvent?: ProgressHandler;
}
): Promise<string> {
const signedUrl = await UploadSessionMutation.createAccountScopedUploadSessionAsync(
@@ -0,0 +1,57 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { sleepAsync } from './promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
export async function validateProfileImageAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The image must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
/**
* Profile images are processed asynchronously (resized and assigned to their
* entity by the server), so poll until the image URL changes.
*/
export async function pollForProfileImageChangeAsync({
fetchProfileImageUrlAsync,
previousProfileImageUrl,
fallbackUrl,
}: {
fetchProfileImageUrlAsync: () => Promise<string | null>;
previousProfileImageUrl: string | null;
/** Shown in the timeout error as the page where the image may still appear. */
fallbackUrl: string;
}): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await fetchProfileImageUrlAsync();
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the image to be processed. It may still appear shortly: ${chalk.underline(
fallbackUrl
)}`
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "22.3.0",
"version": "22.2.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,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "22.3.0",
"@expo/build-tools": "22.2.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "22.3.0",
"version": "22.2.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "22.3.0",
"@expo/build-tools": "22.2.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "22.0.0",
Generated
+10 -64
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:22.3.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:22.2.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2303,7 +2303,7 @@ __metadata:
"@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/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.1.0"
@@ -2312,7 +2312,6 @@ __metadata:
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
"@types/content-disposition": "npm:0.5.9"
"@types/fs-extra": "npm:^11.0.4"
"@types/jest": "npm:^29.5.12"
"@types/lodash": "npm:^4.17.4"
@@ -2327,7 +2326,6 @@ __metadata:
"@urql/core": "npm:^6.0.1"
bplist-parser: "npm:0.3.2"
bunyan: "npm:^1.8.15"
content-disposition: "npm:1.0.1"
fast-glob: "npm:^3.3.2"
fast-xml-parser: "npm:^4.4.1"
fs-extra: "npm:^11.2.0"
@@ -2820,16 +2818,15 @@ __metadata:
languageName: node
linkType: hard
"@expo/repack-app@npm:~0.9.0":
version: 0.9.0
resolution: "@expo/repack-app@npm:0.9.0"
"@expo/repack-app@npm:~0.6.1":
version: 0.6.1
resolution: "@expo/repack-app@npm:0.6.1"
dependencies:
commander: "npm:^14.0.2"
picocolors: "npm:^1.1.1"
zsign-wasm: "npm:^0.0.2"
bin:
repack-app: bin/cli.js
checksum: 10c0/0a493d97b80b98a1a90172baf81d7efee6b65399bf15171aae9c6505be3eedd4e59d05a8a33b3a6b6b720a2a96b7c2c5043c8ec92e390a5f917e2dd139d88aab
checksum: 10c0/fdf3088b4f327393056631270bf90a67ba949e7994892a62abc985bd9478119ab2f1d5be35fa1efb6bd1d2780c49a3dbb87cb669d9c65b44e3d9a0b41fbf091f
languageName: node
linkType: hard
@@ -2962,7 +2959,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:22.3.0"
"@expo/build-tools": "npm:22.2.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:22.0.0"
@@ -7087,7 +7084,7 @@ __metadata:
languageName: node
linkType: hard
"@types/content-disposition@npm:*, @types/content-disposition@npm:0.5.9":
"@types/content-disposition@npm:*":
version: 0.5.9
resolution: "@types/content-disposition@npm:0.5.9"
checksum: 10c0/9fd520dae1a9c7b85909e59f548f905a670a6e2f83642f9734d1b05126f64ac8c8e2c282a663edf27d58bc34fbbacf46a4d4a95d3802b42106174a19a68175c8
@@ -9495,7 +9492,7 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:1.0.1, content-disposition@npm:~1.0.1":
"content-disposition@npm:~1.0.1":
version: 1.0.1
resolution: "content-disposition@npm:1.0.1"
checksum: 10c0/bd7ff1fe8d2542d3a2b9a29428cc3591f6ac27bb5595bba2c69664408a68f9538b14cbd92479796ea835b317a09a527c8c7209c4200381dedb0c34d3b658849e
@@ -10300,7 +10297,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:22.3.0"
"@expo/build-tools": "npm:22.2.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"
@@ -12330,13 +12327,6 @@ __metadata:
languageName: node
linkType: hard
"immediate@npm:~3.0.5":
version: 3.0.6
resolution: "immediate@npm:3.0.6"
checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039
languageName: node
linkType: hard
"immutable@npm:^5.1.5":
version: 5.1.9
resolution: "immutable@npm:5.1.9"
@@ -13779,18 +13769,6 @@ __metadata:
languageName: node
linkType: hard
"jszip@npm:^3.10.1":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
dependencies:
lie: "npm:~3.3.0"
pako: "npm:~1.0.2"
readable-stream: "npm:~2.3.6"
setimmediate: "npm:^1.0.5"
checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863
languageName: node
linkType: hard
"just-diff-apply@npm:^5.2.0":
version: 5.5.0
resolution: "just-diff-apply@npm:5.5.0"
@@ -14033,15 +14011,6 @@ __metadata:
languageName: node
linkType: hard
"lie@npm:~3.3.0":
version: 3.3.0
resolution: "lie@npm:3.3.0"
dependencies:
immediate: "npm:~3.0.5"
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
languageName: node
linkType: hard
"lilconfig@npm:^3.1.3":
version: 3.1.3
resolution: "lilconfig@npm:3.1.3"
@@ -16227,13 +16196,6 @@ __metadata:
languageName: node
linkType: hard
"pako@npm:~1.0.2":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe
languageName: node
linkType: hard
"param-case@npm:^3.0.4":
version: 3.0.4
resolution: "param-case@npm:3.0.4"
@@ -17494,13 +17456,6 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
@@ -20037,12 +19992,3 @@ __metadata:
checksum: 10c0/5a2db7e59177a3d7e202543f5136cb87b97b047b77c8a3d824098d3fa8b80d3aa40a0a5f296965c3b82dfdccdd05dbbfacce91347f16a39c675680fd7b1ab109
languageName: node
linkType: hard
"zsign-wasm@npm:^0.0.2":
version: 0.0.2
resolution: "zsign-wasm@npm:0.0.2"
dependencies:
jszip: "npm:^3.10.1"
checksum: 10c0/f8919b66031c93970987fa9ac0e742cc7975e4c4c6fbf60fa477e1154109a50a4bf907b75dff8afe72b457df49ce5d060e3a3b93ba3c92b8c453d85206058f22
languageName: node
linkType: hard