mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad188babe | ||
|
|
91c9331e43 | ||
|
|
02ba5880c4 | ||
|
|
8229dc8265 | ||
|
|
15a456970b | ||
|
|
e74751c26c | ||
|
|
0e2a1756ce | ||
|
|
766e278aec | ||
|
|
77e22c740a | ||
|
|
8fdf47aa7d | ||
|
|
2279b650d5 | ||
|
|
c6a36e2e47 | ||
|
|
27cb8c12d6 | ||
|
|
1122a7f374 | ||
|
|
c06d18138c | ||
|
|
3f4ff1dc3e | ||
|
|
e852ab1cb7 | ||
|
|
d941398ce6 | ||
|
|
bae7684996 | ||
|
|
714aec69c7 | ||
|
|
bec6e670de | ||
|
|
f6ba878b29 | ||
|
|
07fbe5c777 | ||
|
|
c8739b66af | ||
|
|
777a3c947f | ||
|
|
ce3d17ae4c | ||
|
|
2f9dfb6610 | ||
|
|
b862a0575c | ||
|
|
c1fe243a70 | ||
|
|
8065715d4c | ||
|
|
402cc40bb9 | ||
|
|
61eeabb404 | ||
|
|
edbaaf8000 | ||
|
|
68e561e879 |
No files matched your search
@@ -10,8 +10,26 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
|
||||
|
||||
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
|
||||
|
||||
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/steps": "18.9.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
|
||||
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
|
||||
|
||||
function makeIosBuildContext({
|
||||
expoVersion,
|
||||
usePrecompiledModules = true,
|
||||
}: {
|
||||
expoVersion?: string;
|
||||
usePrecompiledModules?: boolean;
|
||||
}): BuildContext<Ios.Job> {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, '.keep')]: '',
|
||||
...(expoVersion
|
||||
? {
|
||||
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: expoVersion,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const job: Ios.Job = {
|
||||
...createTestIosJob(),
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
(spawn as jest.Mock).mockImplementation(command => {
|
||||
if (command === 'node') {
|
||||
return Promise.resolve({
|
||||
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions that support them', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['--print', "require.resolve('expo/package.json')"],
|
||||
expect.objectContaining({
|
||||
cwd: PROJECT_DIR,
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
|
||||
const ctx = makeIosBuildContext({});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars without the builder flag', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '55.0.18',
|
||||
usePrecompiledModules: false,
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
|
||||
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
@@ -12,6 +17,7 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
|
||||
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
|
||||
|
||||
return {
|
||||
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
|
||||
@@ -20,6 +26,7 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
env: {
|
||||
...ctx.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
...precompiledModulesEnv,
|
||||
},
|
||||
lineTransformer: (line?: string) => {
|
||||
if (
|
||||
@@ -35,3 +42,77 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<Env> {
|
||||
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const validExpoPackageVersion = semver.valid(expoPackageVersion);
|
||||
if (!validExpoPackageVersion) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
|
||||
const env: Env = {
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
|
||||
};
|
||||
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
|
||||
env
|
||||
)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')}\nPrecompiled modules pod install environment is configured.`
|
||||
);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<string> {
|
||||
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const expoPackageJsonPath = stdout.toString().trim();
|
||||
return (await fs.readJson(expoPackageJsonPath)).version;
|
||||
}
|
||||
|
||||
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
// return PRECOMPILED_MODULES_BASE_URL;
|
||||
// }
|
||||
//
|
||||
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
// return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
// `${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
// );
|
||||
// }
|
||||
@@ -35,9 +35,11 @@ import { createRestoreCacheFunction } from './functions/restoreCache';
|
||||
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
|
||||
import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
@@ -77,6 +79,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
@@ -91,6 +94,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
parseFlowMetadata,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
@@ -803,3 +805,22 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].properties).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyLatestAttemptXml', () => {
|
||||
it('picks the highest attempt number', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
|
||||
});
|
||||
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
|
||||
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
|
||||
});
|
||||
|
||||
it('throws when source has no xml files', async () => {
|
||||
vol.fromJSON({ '/tmp/r/.keep': '' });
|
||||
await expect(
|
||||
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as parser from '../maestroResultParser';
|
||||
import { createMaestroTestsBuildFunction } from '../maestroTests';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
const SPAWN_SUCCESS = {
|
||||
status: 0,
|
||||
pid: 1,
|
||||
output: [''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
} as any;
|
||||
|
||||
const rejectExit1 = (): Error => {
|
||||
const e: any = new Error('exit 1');
|
||||
e.status = 1;
|
||||
return e;
|
||||
};
|
||||
|
||||
function createStep(
|
||||
callInputs?: Record<string, unknown>,
|
||||
options: { env?: Record<string, string | undefined> } = {}
|
||||
): ReturnType<
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
}
|
||||
|
||||
describe('createMaestroTestsBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
// Default-stub copyLatestAttemptXml so tests that don't care about it
|
||||
// don't hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(createMaestroTestsBuildFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
// Make spawn reject so the step still throws; we're locking in that outputs
|
||||
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
|
||||
// `final_report_path` is empty).
|
||||
mockedSpawn.mockRejectedValue(new Error('stop'));
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow();
|
||||
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
|
||||
/\.maestro\/tests\/junit-reports$/
|
||||
);
|
||||
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when maestro exits 0 on first attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = mockedSpawn.mock.calls[0];
|
||||
expect(cmd).toBe('maestro');
|
||||
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
|
||||
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
|
||||
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError for unknown-shape spawn rejections', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('mystery'));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('killed'), {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('treats reject-with-status as a retryable test failure', async () => {
|
||||
const rejectWithExit = (status: number): Error =>
|
||||
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
|
||||
status,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
});
|
||||
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
// Default `retries` is 0, so even though the first attempt rejects with
|
||||
// `status: 1`, no retry occurs and the post-loop check throws UserError.
|
||||
// Spawn ran once.
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries all flows on failure', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('writes final report via copyLatestAttemptXml on success', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(copySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows copyLatestAttemptXml failure when maestro succeeded', async () => {
|
||||
// Throwing on copy failure would mask the real reason for early-failing
|
||||
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
|
||||
// failure as SystemError, which cancels the build's billing.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest
|
||||
.spyOn(parser, 'copyLatestAttemptXml')
|
||||
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError early on empty flow_path', async () => {
|
||||
const step = createStep({ flow_path: [], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'HTML',
|
||||
platform: 'ios',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
|
||||
});
|
||||
|
||||
it('does not include --output when output_format is undefined', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: '',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args.some(a => a.startsWith('--output'))).toBe(false);
|
||||
expect(args).not.toContain('--output');
|
||||
expect(args).not.toContain('--output=undefined');
|
||||
expect(args).not.toContain('--output=null');
|
||||
expect(args).not.toContain('--output=');
|
||||
});
|
||||
|
||||
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
|
||||
// output_format.toLowerCase() must propagate end-to-end: final_report_path
|
||||
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'JUNIT',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
|
||||
expect(copySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
shards: 3,
|
||||
include_tags: 'smoke,critical',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--shard-split=3');
|
||||
expect(args).toContain('--include-tags=smoke,critical');
|
||||
expect(args).toContain('--exclude-tags=slow');
|
||||
});
|
||||
|
||||
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions).toBeDefined();
|
||||
expect(typeof spawnOptions.cwd).toBe('string');
|
||||
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
|
||||
expect(spawnOptions.env).toBeDefined();
|
||||
expect(spawnOptions.env!.HOME).toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
|
||||
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
|
||||
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
|
||||
// it in the spawn env.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
|
||||
});
|
||||
|
||||
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
|
||||
// Even when os.homedir() differs, the step should derive paths from env.HOME
|
||||
// so maestro (which receives env) and the step (which inspects those paths)
|
||||
// agree on where reports land.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: { HOME: '/custom/home' } }
|
||||
);
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/custom/home/.maestro/tests/junit-reports'
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/custom/home/.maestro/tests/android-maestro-junit.xml'
|
||||
);
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(
|
||||
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws SystemError when env.HOME is unset', async () => {
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws UserError on non-string entries in flow_path', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 2 as unknown as string],
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is negative', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: -1,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is a non-integer number', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 1.5,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is zero', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is negative', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is a non-integer', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('accepts shards: undefined (optional input)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
|
||||
await step.executeAsync(); // should succeed, not throw
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,9 @@ export interface MaestroFlowResult {
|
||||
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
||||
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
||||
|
||||
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
export function extractFlowKey(filename: string, prefix: string): string | null {
|
||||
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
||||
return match?.[1] ?? null;
|
||||
@@ -239,9 +242,6 @@ export async function parseMaestroResults(
|
||||
// 3. Merge: JUnit results + ai-*.json metadata
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
// Parse attempt index from filename pattern: *-attempt-N.*
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
// Group results by flow name
|
||||
const resultsByName = new Map<string, JUnitResultWithSource[]>();
|
||||
for (const entry of junitResultsWithSource) {
|
||||
@@ -322,3 +322,41 @@ async function relativizePathAsync(flowFilePath: string, projectRoot: string): P
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
||||
* After the maestro retry loop completes, this produces a single canonical
|
||||
* JUnit report at final_report_path matching the bash step's "cp latest
|
||||
* attempt" semantics.
|
||||
*
|
||||
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
||||
*/
|
||||
export async function copyLatestAttemptXml(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
||||
}
|
||||
|
||||
// Pick the file with the highest attempt index. Files without the marker are
|
||||
// treated as attempt 0. Ties are broken by sorted filename — later wins.
|
||||
let winner = xmlFiles[0];
|
||||
let winnerAttempt = (() => {
|
||||
const m = winner.match(ATTEMPT_PATTERN);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})();
|
||||
for (let i = 1; i < xmlFiles.length; i++) {
|
||||
const candidate = xmlFiles[i];
|
||||
const match = candidate.match(ATTEMPT_PATTERN);
|
||||
const attempt = match ? parseInt(match[1], 10) : 0;
|
||||
if (attempt >= winnerAttempt) {
|
||||
winner = candidate;
|
||||
winnerAttempt = attempt;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { copyLatestAttemptXml } from './maestroResultParser';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
const RetriesSchema = z.number().int().min(0).default(0);
|
||||
const ShardsSchema = z.number().int().min(1).optional();
|
||||
|
||||
function parseInput<S extends z.ZodTypeAny>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
message: string
|
||||
): z.output<S> {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
name: 'Run Maestro Tests',
|
||||
__metricsId: 'eas/maestro_tests',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retries',
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'shards',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
defaultValue: 'junit',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
|
||||
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
|
||||
const { logger, global } = stepCtx;
|
||||
const platformInput = inputs.platform.value as string | undefined;
|
||||
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
|
||||
const includeTags = inputs.include_tags.value as string | undefined;
|
||||
const excludeTags = inputs.exclude_tags.value as string | undefined;
|
||||
|
||||
const platform: 'ios' | 'android' =
|
||||
platformInput === 'ios' || platformInput === 'android'
|
||||
? platformInput
|
||||
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? 'ios'
|
||||
: 'android';
|
||||
|
||||
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
|
||||
// this env and writes debug output under $HOME/.maestro/tests; the step
|
||||
// must read from the same place or stale files leak across runs.
|
||||
const home = env.HOME;
|
||||
if (!home) {
|
||||
throw new SystemError('HOME env var is not set');
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
outputs.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
'flow_path must be a non-empty array of non-empty strings.'
|
||||
);
|
||||
const retries = parseInput(
|
||||
RetriesSchema,
|
||||
inputs.retries.value,
|
||||
'retries must be a non-negative integer.'
|
||||
);
|
||||
const shards = parseInput(
|
||||
ShardsSchema,
|
||||
inputs.shards.value,
|
||||
'shards must be a positive integer.'
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.mkdir(junitReportDirectory, { recursive: true });
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
try {
|
||||
await spawn(
|
||||
'maestro',
|
||||
buildMaestroArgs({
|
||||
flow_path: flowPaths,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
}),
|
||||
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
|
||||
);
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info('Test failed, retrying...');
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Copy the latest attempt's JUnit to the final report path so downstream
|
||||
// upload/report steps have a single canonical file.
|
||||
if (finalReportPath !== undefined) {
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure usually means maestro itself failed early
|
||||
// (bad YAML wrote no *.xml). Throwing SystemError here would mask
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
if (lastAttemptExitCode !== 0) {
|
||||
const totalAttempts = retries + 1;
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_TESTS_FAILED',
|
||||
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import childProcess from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const RUN_DIR = '/tmp/agent-device';
|
||||
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
|
||||
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
|
||||
|
||||
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
|
||||
await fs.promises.mkdir(RUN_DIR, { recursive: true });
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
await ensureCloudflaredInstalledAsync({ env, logger });
|
||||
|
||||
logger.info('Ensuring bun is installed.');
|
||||
await ensureBunInstalledAsync({ env, logger });
|
||||
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
|
||||
await spawnDetachedAsync({
|
||||
command: 'bun run src/daemon.ts',
|
||||
cwd: SRC_DIR,
|
||||
logFile: DAEMON_LOG,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon',
|
||||
});
|
||||
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
logger.info(
|
||||
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
|
||||
);
|
||||
await spawnDetachedAsync({
|
||||
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
|
||||
logFile: TUNNEL_LOG,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const tunnelUrl = await waitForMatchInLogAsync({
|
||||
logFile: TUNNEL_LOG,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
|
||||
|
||||
logger.info('Emitting agent-device credentials for the CLI to pick up:');
|
||||
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
|
||||
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCloudflaredInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
}
|
||||
|
||||
async function ensureBunInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
|
||||
}
|
||||
|
||||
async function ensureBrewPackageInstalledAsync({
|
||||
name,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
name: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn(
|
||||
'bash',
|
||||
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function spawnDetachedAsync({
|
||||
command,
|
||||
cwd,
|
||||
logFile,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
logFile: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
// Launch the process fully detached so this function returns immediately and
|
||||
// the grandchild survives the step. Stdio goes to a log file so the daemon
|
||||
// output can be polled, and we unref so Node doesn't wait on it.
|
||||
const fd = fs.openSync(logFile, 'a');
|
||||
try {
|
||||
const child = childProcess.spawn('bash', ['-c', command], {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ['ignore', fd, fd],
|
||||
});
|
||||
if (!child.pid) {
|
||||
throw new Error(`Failed to spawn detached process: ${command}`);
|
||||
}
|
||||
child.unref();
|
||||
logger.info(`Started detached process (pid ${child.pid}).`);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMatchInLogAsync({
|
||||
logFile,
|
||||
pattern,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
logFile: string;
|
||||
pattern: RegExp;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const content = await readFileOrEmptyAsync(logFile);
|
||||
const match = pattern.exec(content);
|
||||
if (match) {
|
||||
return match[1] ?? match[0];
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const tail = await readFileOrEmptyAsync(logFile);
|
||||
throw new Error(
|
||||
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFileAsync({
|
||||
filePath,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
filePath: string;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return;
|
||||
} catch {
|
||||
// not yet; keep polling
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
|
||||
}
|
||||
|
||||
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readDaemonInfo(filePath: string): { port: number; token: string } {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
|
||||
typeof (parsed as { token: unknown }).token !== 'string'
|
||||
) {
|
||||
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
|
||||
}
|
||||
const { httpPort, token } = parsed as { httpPort: number; token: string };
|
||||
return { port: httpPort, token };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.8.0"
|
||||
"@expo/steps": "18.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
+162
-94
@@ -117,6 +117,9 @@ eas --help COMMAND
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas init`](#eas-init)
|
||||
* [`eas init:onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-initonboarding-target_project_directory)
|
||||
* [`eas integrations:asc:connect`](#eas-integrationsascconnect)
|
||||
* [`eas integrations:asc:disconnect`](#eas-integrationsascdisconnect)
|
||||
* [`eas integrations:asc:status`](#eas-integrationsascstatus)
|
||||
* [`eas login`](#eas-login)
|
||||
* [`eas logout`](#eas-logout)
|
||||
* [`eas metadata:lint`](#eas-metadatalint)
|
||||
@@ -180,7 +183,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -197,7 +200,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -219,7 +222,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -236,7 +239,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -250,7 +253,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -302,7 +305,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -323,7 +326,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -343,7 +346,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -363,7 +366,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -386,7 +389,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -424,7 +427,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -444,7 +447,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -462,7 +465,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -482,7 +485,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -504,28 +507,34 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:download --fingerprint <value> [-p ios|android] [--dev-client] [--json] [--non-interactive]
|
||||
$ eas build:download [--build-id <value> | --fingerprint <value> | -p ios|android | --dev-client]
|
||||
[--all-artifacts] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--all-artifacts Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in
|
||||
addition to the application archive. Without this flag, only the application archive is
|
||||
downloaded and the command errors if it is missing.
|
||||
--build-id=<value> ID of the build to download. Mutually exclusive with --fingerprint, --platform, and
|
||||
--dev-client; the platform is derived from the build itself.
|
||||
--[no-]dev-client Filter only dev-client builds.
|
||||
--fingerprint=<value> (required) Fingerprint hash of the build to download
|
||||
--fingerprint=<value> Fingerprint hash of the build to download
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -563,7 +572,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -603,7 +612,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -633,7 +642,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -659,7 +668,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -711,7 +720,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -729,7 +738,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -747,7 +756,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -764,7 +773,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -785,7 +794,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -806,7 +815,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -828,7 +837,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
@@ -852,7 +861,7 @@ DESCRIPTION
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -872,7 +881,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -894,7 +903,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -916,7 +925,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -951,7 +960,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -974,7 +983,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -995,7 +1004,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1012,7 +1021,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1030,7 +1039,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1059,7 +1068,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1084,7 +1093,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1105,7 +1114,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1126,7 +1135,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1163,7 +1172,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1183,7 +1192,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1204,7 +1213,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1225,7 +1234,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1239,7 +1248,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1253,7 +1262,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1285,7 +1294,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1311,7 +1320,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1333,7 +1342,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1361,7 +1370,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1389,7 +1398,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1412,7 +1421,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1434,7 +1443,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1468,7 +1477,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1512,7 +1521,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1543,7 +1552,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1602,6 +1611,65 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
## `eas integrations:asc:connect`
|
||||
|
||||
connect a project to an App Store Connect app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:connect [--api-key-id <value>] [--asc-app-id <value>] [--bundle-id <value>] [--json]
|
||||
[--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--api-key-id=<value> Apple App Store Connect API Key ID
|
||||
--asc-app-id=<value> App Store Connect app identifier
|
||||
--bundle-id=<value> Filter discovered apps by bundle identifier
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
connect a project to an App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
disconnect the current project from its App Store Connect app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:disconnect [--yes] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--yes Skip confirmation prompt
|
||||
|
||||
DESCRIPTION
|
||||
disconnect the current project from its App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
show the App Store Connect app link status for the current project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:status [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
show the App Store Connect app link status for the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
log in with your Expo account
|
||||
@@ -1652,7 +1720,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1670,7 +1738,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1688,7 +1756,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1740,7 +1808,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1754,7 +1822,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1777,7 +1845,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1801,7 +1869,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1819,7 +1887,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1852,7 +1920,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1895,7 +1963,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1916,7 +1984,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1937,7 +2005,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1962,7 +2030,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
@@ -1989,7 +2057,7 @@ DESCRIPTION
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -2016,7 +2084,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2054,7 +2122,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2082,7 +2150,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2112,7 +2180,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2133,7 +2201,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2157,7 +2225,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2178,7 +2246,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2200,7 +2268,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2220,7 +2288,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2239,7 +2307,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2262,7 +2330,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2279,7 +2347,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2400,7 +2468,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2420,7 +2488,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2444,7 +2512,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2484,7 +2552,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2506,7 +2574,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2534,7 +2602,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2554,7 +2622,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2576,5 +2644,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+1980
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/steps": "18.9.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -201,6 +201,9 @@
|
||||
"env": {
|
||||
"description": "manage project and account environment variables"
|
||||
},
|
||||
"integrations": {
|
||||
"description": "manage service connections"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { CommonContext } from '../context';
|
||||
import { checkLockfileAsync } from '../validateLockfile';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/package-manager', () => ({
|
||||
NPM_LOCK_FILE: 'package-lock.json',
|
||||
YARN_LOCK_FILE: 'yarn.lock',
|
||||
PNPM_LOCK_FILE: 'pnpm-lock.yaml',
|
||||
BUN_LOCK_FILE: 'bun.lockb',
|
||||
BUN_TEXT_LOCK_FILE: 'bun.lock',
|
||||
resolveWorkspaceRoot: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const { resolveWorkspaceRoot } = jest.requireMock<{
|
||||
resolveWorkspaceRoot: jest.Mock;
|
||||
}>('@expo/package-manager');
|
||||
|
||||
const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
function createMockContext(
|
||||
overrides: Partial<{
|
||||
projectDir: string;
|
||||
requiredPackageManager: string | null;
|
||||
isFileIgnoredAsync: (filePath: string) => Promise<boolean>;
|
||||
getRootPathAsync: () => Promise<string>;
|
||||
}> = {}
|
||||
): CommonContext<Platform> {
|
||||
const vcsClient = {
|
||||
isFileIgnoredAsync: overrides.isFileIgnoredAsync ?? (async () => false),
|
||||
getRootPathAsync: overrides.getRootPathAsync ?? (async () => '/project'),
|
||||
} as unknown as Client;
|
||||
|
||||
return {
|
||||
projectDir: overrides.projectDir ?? '/project',
|
||||
requiredPackageManager: overrides.requiredPackageManager ?? null,
|
||||
vcsClient,
|
||||
} as unknown as CommonContext<Platform>;
|
||||
}
|
||||
|
||||
const PACKAGE_JSON_WITH_REACT = JSON.stringify({
|
||||
dependencies: { react: '19.0.4', 'react-native': '~0.79.0' },
|
||||
});
|
||||
|
||||
const NPM_LOCKFILE_SYNCED = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.4' },
|
||||
'node_modules/react-native': { version: '0.79.4' },
|
||||
},
|
||||
});
|
||||
|
||||
const NPM_LOCKFILE_STALE = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.0' },
|
||||
'node_modules/react-native': { version: '0.79.4' },
|
||||
},
|
||||
});
|
||||
|
||||
// bun.lock uses JSONC with trailing commas
|
||||
const BUN_LOCKFILE_STALE = `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"react": "19.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
},
|
||||
}`;
|
||||
|
||||
const BUN_LOCKFILE_SYNCED = `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"react": "19.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
},
|
||||
}`;
|
||||
|
||||
const YARN_LOCKFILE_STALE = `# yarn lockfile v1
|
||||
|
||||
[email protected]:
|
||||
version "19.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz"
|
||||
`;
|
||||
|
||||
const YARN_LOCKFILE_SYNCED = `# yarn lockfile v1
|
||||
|
||||
[email protected]:
|
||||
version "19.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
|
||||
|
||||
react-native@~0.79.0:
|
||||
version "0.79.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
|
||||
`;
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
resolveWorkspaceRoot.mockReturnValue(null);
|
||||
mockProcessExit.mockClear();
|
||||
delete process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK;
|
||||
});
|
||||
|
||||
describe(checkLockfileAsync, () => {
|
||||
it('skips all checks when EAS_BUILD_SKIP_LOCKFILE_CHECK is set', async () => {
|
||||
process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK = '1';
|
||||
// This would normally fail — no lockfile, no package manager
|
||||
vol.fromJSON({ '/project/package.json': '{}' });
|
||||
const ctx = createMockContext({ requiredPackageManager: null });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when no package manager is detected', async () => {
|
||||
vol.fromJSON({ '/project/package.json': '{}' });
|
||||
const ctx = createMockContext({ requiredPackageManager: null });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when npm is detected but package-lock.json is missing', async () => {
|
||||
vol.fromJSON({ '/project/package.json': '{}' });
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when lockfile exists and is in sync', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when lockfile exists but is gitignored', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': '{}',
|
||||
'/project/yarn.lock': '# yarn lockfile',
|
||||
});
|
||||
const ctx = createMockContext({
|
||||
requiredPackageManager: 'yarn',
|
||||
isFileIgnoredAsync: async () => true,
|
||||
});
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when conflicting lockfiles from multiple managers exist', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': '{}',
|
||||
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
|
||||
'/project/yarn.lock': '# yarn lockfile',
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when npm lockfile version does not satisfy package.json specifier', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
'/project/package-lock.json': NPM_LOCKFILE_STALE,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when bun.lock version does not satisfy package.json specifier', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/bun.lock': BUN_LOCKFILE_STALE,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when bun.lock is in sync', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when yarn lockfile version does not satisfy package.json specifier', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/yarn.lock': YARN_LOCKFILE_STALE,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when yarn lockfile is in sync', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
'/project/yarn.lock': YARN_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds lockfile at workspace root for monorepos', async () => {
|
||||
vol.fromJSON({
|
||||
'/monorepo/yarn.lock': YARN_LOCKFILE_SYNCED,
|
||||
'/monorepo/packages/app/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
});
|
||||
resolveWorkspaceRoot.mockReturnValue('/monorepo');
|
||||
|
||||
const ctx = createMockContext({
|
||||
projectDir: '/monorepo/packages/app',
|
||||
requiredPackageManager: 'yarn',
|
||||
getRootPathAsync: async () => '/monorepo',
|
||||
});
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report a conflict when both bun.lock and bun.lockb are present', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/bun.lockb': 'binary',
|
||||
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips sync check for non-registry specifiers', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: {
|
||||
'my-local-pkg': 'file:../local',
|
||||
'my-workspace-pkg': 'workspace:*',
|
||||
react: '19.0.4',
|
||||
},
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.4' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('peer dependency conflicts', () => {
|
||||
it('exits when a direct dependency has an unsatisfied peer (npm)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.0' },
|
||||
'node_modules/react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when a direct dependency has an unsatisfied peer (bun)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
|
||||
}),
|
||||
'/project/bun.lock': `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": { "": { "dependencies": { "react": "19.0.0", "react-dom": "19.2.4" } } },
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
|
||||
},
|
||||
}`,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when peer dependencies are satisfied', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { react: '19.2.4', 'react-dom': '19.2.4' },
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.2.4' },
|
||||
'node_modules/react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips optional peer dependencies (npm peerDependenciesMeta)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { '@0no-co/graphql.web': '1.2.0' },
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/@0no-co/graphql.web': {
|
||||
version: '1.2.0',
|
||||
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
|
||||
peerDependenciesMeta: { graphql: { optional: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips optional peer dependencies (bun optionalPeers)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { '@0no-co/graphql.web': '1.2.0' },
|
||||
}),
|
||||
'/project/bun.lock': `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": { "": { "dependencies": { "@0no-co/graphql.web": "1.2.0" } } },
|
||||
"packages": {
|
||||
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-abc"],
|
||||
},
|
||||
}`,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { LocalBuildMode, LocalBuildOptions } from './local';
|
||||
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
|
||||
import { checkLockfileAsync } from './validateLockfile';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureRepoIsCleanAsync } from './utils/repository';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
@@ -84,6 +85,7 @@ import { Client } from '../vcs/vcs';
|
||||
|
||||
let metroConfigValidated = false;
|
||||
let sdkVersionChecked = false;
|
||||
let lockfileChecked = false;
|
||||
let hasWarnedAboutUsageOverages = false;
|
||||
|
||||
export interface BuildFlags {
|
||||
@@ -414,6 +416,11 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
});
|
||||
|
||||
if (!lockfileChecked) {
|
||||
await checkLockfileAsync(buildCtx);
|
||||
lockfileChecked = true;
|
||||
}
|
||||
|
||||
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
|
||||
hasWarnedAboutUsageOverages = true;
|
||||
Log.newLine();
|
||||
|
||||
@@ -64,6 +64,24 @@ export function getWorkflowRunUrl(
|
||||
).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
|
||||
* or the feature-specific dashboard) that gives users more context. Use this only for internal
|
||||
* tooling where no richer URL exists.
|
||||
*/
|
||||
export function getBareJobRunUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
jobRunId: string
|
||||
): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/job-runs/${encodeURIComponent(jobRunId)}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
BUN_LOCK_FILE,
|
||||
BUN_TEXT_LOCK_FILE,
|
||||
NPM_LOCK_FILE,
|
||||
PNPM_LOCK_FILE,
|
||||
YARN_LOCK_FILE,
|
||||
resolveWorkspaceRoot,
|
||||
} from '@expo/package-manager';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
LockfileData,
|
||||
findLockfileMismatches,
|
||||
findPeerDependencyConflicts,
|
||||
parseLockfile,
|
||||
} from '@expo/lockfile-utils';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { CommonContext } from './context';
|
||||
import Log from '../log';
|
||||
|
||||
const SKIP_HINT =
|
||||
'To skip this check, set the EAS_BUILD_SKIP_LOCKFILE_CHECK=1 environment variable.';
|
||||
|
||||
interface LockfileInfo {
|
||||
/** Filename of the lockfile (e.g. 'yarn.lock') */
|
||||
filename: string;
|
||||
/** Package manager name for display (e.g. 'yarn') */
|
||||
managerName: string;
|
||||
}
|
||||
|
||||
const LOCKFILE_BY_MANAGER: Record<string, LockfileInfo> = {
|
||||
npm: { filename: NPM_LOCK_FILE, managerName: 'npm' },
|
||||
yarn: { filename: YARN_LOCK_FILE, managerName: 'yarn' },
|
||||
pnpm: { filename: PNPM_LOCK_FILE, managerName: 'pnpm' },
|
||||
bun: { filename: BUN_LOCK_FILE, managerName: 'bun' },
|
||||
};
|
||||
|
||||
const ALL_LOCKFILES: { filename: string; manager: string }[] = [
|
||||
{ filename: NPM_LOCK_FILE, manager: 'npm' },
|
||||
{ filename: YARN_LOCK_FILE, manager: 'yarn' },
|
||||
{ filename: PNPM_LOCK_FILE, manager: 'pnpm' },
|
||||
{ filename: BUN_LOCK_FILE, manager: 'bun' },
|
||||
{ filename: BUN_TEXT_LOCK_FILE, manager: 'bun' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Find the lockfile for the given filename by checking the workspace root first
|
||||
* (for monorepos), then the project directory.
|
||||
*/
|
||||
async function findLockfileAsync(
|
||||
projectDir: string,
|
||||
filename: string
|
||||
): Promise<string | null> {
|
||||
const workspaceRoot = resolveWorkspaceRoot(projectDir);
|
||||
if (workspaceRoot) {
|
||||
const workspaceLockfile = path.join(workspaceRoot, filename);
|
||||
if (await fs.pathExists(workspaceLockfile)) {
|
||||
return workspaceLockfile;
|
||||
}
|
||||
}
|
||||
|
||||
const projectLockfile = path.join(projectDir, filename);
|
||||
if (await fs.pathExists(projectLockfile)) {
|
||||
return projectLockfile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readLockfileDataAsync(
|
||||
projectDir: string,
|
||||
lockfilePath: string,
|
||||
manager: string
|
||||
): Promise<{ lockfileData: LockfileData; packageJson: Record<string, unknown> } | null> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
if (!(await fs.pathExists(packageJsonPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
|
||||
const lockfileData = parseLockfile(lockfileContent, manager, lockfilePath);
|
||||
if (!lockfileData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { lockfileData, packageJson };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight lockfile validation for `eas build`. Catches common lockfile
|
||||
* problems before uploading to the builder.
|
||||
*/
|
||||
export async function checkLockfileAsync<T extends Platform>(
|
||||
ctx: CommonContext<T>
|
||||
): Promise<void> {
|
||||
if (process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
|
||||
Log.warn('Skipping lockfile validation (EAS_BUILD_SKIP_LOCKFILE_CHECK is set).');
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectDir, requiredPackageManager, vcsClient } = ctx;
|
||||
|
||||
// --- 1. Lockfile existence ---
|
||||
if (!requiredPackageManager) {
|
||||
Log.error('No lockfile found in the project directory.');
|
||||
Log.error(
|
||||
'A lockfile is required to ensure deterministic dependency installation on the EAS builder.'
|
||||
);
|
||||
Log.error(
|
||||
'Run your package manager\'s install command (e.g. "npm install") to generate one, then commit it to version control.'
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const lockInfo = LOCKFILE_BY_MANAGER[requiredPackageManager];
|
||||
if (!lockInfo) {
|
||||
// Unknown package manager — nothing we can validate
|
||||
return;
|
||||
}
|
||||
|
||||
// For bun, also check the text lockfile variant
|
||||
const lockfilePath =
|
||||
requiredPackageManager === 'bun'
|
||||
? (await findLockfileAsync(projectDir, BUN_LOCK_FILE)) ??
|
||||
(await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE))
|
||||
: await findLockfileAsync(projectDir, lockInfo.filename);
|
||||
|
||||
if (!lockfilePath) {
|
||||
Log.error(
|
||||
`${lockInfo.managerName} lockfile (${lockInfo.filename}) not found.`
|
||||
);
|
||||
Log.error(
|
||||
`Run "${requiredPackageManager} install" to generate a lockfile, then commit it to version control.`
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 2. Lockfile tracked in VCS ---
|
||||
const rootDir = path.normalize(await vcsClient.getRootPathAsync());
|
||||
const relativeLockfilePath = path.relative(rootDir, lockfilePath);
|
||||
if (await vcsClient.isFileIgnoredAsync(relativeLockfilePath)) {
|
||||
Log.error(
|
||||
`${lockInfo.managerName} lockfile (${relativeLockfilePath}) is ignored by your version control system and won't be uploaded to the EAS builder.`
|
||||
);
|
||||
Log.error(
|
||||
'Remove the lockfile from .gitignore (and .easignore, if applicable), then commit it.'
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 3. Conflicting lockfiles ---
|
||||
const foundManagers = new Set<string>();
|
||||
for (const { filename, manager } of ALL_LOCKFILES) {
|
||||
if (await findLockfileAsync(projectDir, filename)) {
|
||||
foundManagers.add(manager);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundManagers.size > 1) {
|
||||
const managerList = [...foundManagers].sort().join(', ');
|
||||
Log.error(`Conflicting lockfiles detected from multiple package managers: ${managerList}.`);
|
||||
Log.error(
|
||||
'Having lockfiles from multiple package managers can cause install failures on the EAS builder. Remove the lockfiles you do not need and commit the change.'
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 4 & 5. Parse lockfile for dependency sync and peer dep checks ---
|
||||
// For bun, prefer the text lockfile for parsing (bun.lockb is binary)
|
||||
const parsableLockfilePath =
|
||||
requiredPackageManager === 'bun'
|
||||
? (await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE)) ?? lockfilePath
|
||||
: lockfilePath;
|
||||
|
||||
const parsed = await readLockfileDataAsync(
|
||||
projectDir,
|
||||
parsableLockfilePath,
|
||||
requiredPackageManager
|
||||
);
|
||||
|
||||
if (!parsed) {
|
||||
return; // Can't parse — skip remaining checks
|
||||
}
|
||||
|
||||
// --- 4. Lockfile out of sync ---
|
||||
const mismatches = findLockfileMismatches(parsed.packageJson, parsed.lockfileData);
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
Log.error('Lockfile is out of sync with package.json:');
|
||||
for (const m of mismatches) {
|
||||
Log.error(
|
||||
` ${m.name}: package.json requires ${m.specifier}, but lockfile has ${m.lockedVersion}`
|
||||
);
|
||||
}
|
||||
Log.error(
|
||||
`Run "${requiredPackageManager} install" to update your lockfile, then commit the changes.`
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 5. Peer dependency conflicts ---
|
||||
const peerConflicts = findPeerDependencyConflicts(parsed.packageJson, parsed.lockfileData);
|
||||
|
||||
if (peerConflicts.length > 0) {
|
||||
Log.error('Peer dependency conflicts detected:');
|
||||
for (const c of peerConflicts) {
|
||||
Log.error(
|
||||
` ${c.source}@${c.sourceVersion} requires peer ${c.peer}@"${c.peerRange}", but ${c.peer}@${c.installedVersion} is installed`
|
||||
);
|
||||
}
|
||||
Log.error(
|
||||
'Update the conflicting packages to compatible versions, then run ' +
|
||||
`"${requiredPackageManager} install" and commit the changes.`
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform, BuildFragment } from '../../../graphql/generated';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Download from '../download';
|
||||
|
||||
jest.mock('../../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockByIdAsync = jest.mocked(BuildQuery.byIdAsync);
|
||||
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
|
||||
return {
|
||||
id: 'build-123',
|
||||
platform: AppPlatform.Ios,
|
||||
project: { id: 'project-1' },
|
||||
artifacts: {
|
||||
applicationArchiveUrl: 'https://example.com/app.tar.gz',
|
||||
buildArtifactsUrl: null,
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as BuildFragment;
|
||||
}
|
||||
|
||||
describe(Download, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): Download {
|
||||
const command = new Download(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
// Stub the file-system-touching helpers so we don't hit disk.
|
||||
jest
|
||||
.spyOn(command as any, 'getPathToBuildArtifactAsync')
|
||||
.mockResolvedValue('/cache/path/to/build.app');
|
||||
jest.spyOn(command as any, 'downloadExtraArtifactsAsync').mockResolvedValue({});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('errors when neither --build-id nor --fingerprint is provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Either --build-id or --fingerprint is required.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches build by ID when --build-id is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
|
||||
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches build by ID when --id alias is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand(['--id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
|
||||
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
|
||||
const command = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockViewBuildsOnAppAsync.mock.calls[0][1].filter?.fingerprintHash).toBe('fp-abc');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses platform from the build itself when --build-id is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild({ platform: AppPlatform.Android }));
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
const getPathSpy = jest.spyOn(command as any, 'getPathToBuildArtifactAsync');
|
||||
// The spy was set up after instantiation; just ensure runAsync used the build's platform
|
||||
// by checking that no platform prompt was needed.
|
||||
expect(getPathSpy).toBeDefined();
|
||||
});
|
||||
|
||||
it('errors with a helpful message when applicationArchiveUrl is missing and other artifacts exist', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
|
||||
xcodeBuildLogsUrl: 'https://example.com/xcode-logs.tar.gz',
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/Other artifacts are available \(buildArtifacts, xcodeBuildLogs\); re-run with --all-artifacts/
|
||||
);
|
||||
});
|
||||
|
||||
it('errors with the basic message when applicationArchiveUrl is missing and no other artifacts exist', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: null,
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Build does not have an application archive url');
|
||||
});
|
||||
|
||||
it('does not throw when applicationArchiveUrl is missing but --all-artifacts is set', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123', '--all-artifacts']);
|
||||
const downloadExtrasSpy = jest
|
||||
.spyOn(command as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/cache/build-123-artifacts/build-artifacts.tar.gz' });
|
||||
|
||||
await expect(command.runAsync()).resolves.not.toThrow();
|
||||
expect(downloadExtrasSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads extras only when --all-artifacts is set, regardless of --build-id vs --fingerprint', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
|
||||
const commandWithoutFlag = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
|
||||
const downloadExtrasSpyWithout = jest
|
||||
.spyOn(commandWithoutFlag as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({});
|
||||
await commandWithoutFlag.runAsync();
|
||||
expect(downloadExtrasSpyWithout).not.toHaveBeenCalled();
|
||||
|
||||
const commandWithFlag = createCommand([
|
||||
'--fingerprint',
|
||||
'fp-abc',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--all-artifacts',
|
||||
]);
|
||||
const downloadExtrasSpyWith = jest
|
||||
.spyOn(commandWithFlag as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/path/to/extras' });
|
||||
await commandWithFlag.runAsync();
|
||||
expect(downloadExtrasSpyWith).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with path and extras when --json --all-artifacts are set', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand([
|
||||
'--build-id',
|
||||
'build-123',
|
||||
'--all-artifacts',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
jest
|
||||
.spyOn(command as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/cache/extras/build-artifacts.tar.gz' });
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
path: '/cache/path/to/build.app',
|
||||
buildArtifacts: '/cache/extras/build-artifacts.tar.gz',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -15,24 +16,44 @@ import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getEasBuildRunCachedAppPath } from '../../run/run';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
|
||||
import {
|
||||
downloadAndMaybeExtractAppAsync,
|
||||
downloadFileWithProgressTrackerAsync,
|
||||
} from '../../utils/download';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
|
||||
|
||||
export default class Download extends EasCommand {
|
||||
static override description = 'download simulator/emulator builds for a given fingerprint hash';
|
||||
static override description =
|
||||
'download a simulator/emulator build by build ID or fingerprint hash';
|
||||
|
||||
|
||||
static override flags = {
|
||||
'build-id': Flags.string({
|
||||
aliases: ['id'],
|
||||
description:
|
||||
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
|
||||
exclusive: ['fingerprint', 'platform', 'dev-client'],
|
||||
}),
|
||||
fingerprint: Flags.string({
|
||||
description: 'Fingerprint hash of the build to download',
|
||||
required: true,
|
||||
exclusive: ['build-id'],
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
options: [Platform.IOS, Platform.ANDROID] as const,
|
||||
exclusive: ['build-id'],
|
||||
})(),
|
||||
'dev-client': Flags.boolean({
|
||||
description: 'Filter only dev-client builds.',
|
||||
allowNo: true,
|
||||
exclusive: ['build-id'],
|
||||
}),
|
||||
'all-artifacts': Flags.boolean({
|
||||
description:
|
||||
'Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in addition to the application archive. Without this flag, only the application archive is downloaded and the command errors if it is missing.',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -45,14 +66,20 @@ export default class Download extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
'build-id': buildId,
|
||||
platform,
|
||||
fingerprint,
|
||||
'dev-client': developmentClient,
|
||||
'all-artifacts': allArtifacts,
|
||||
...rawFlags
|
||||
},
|
||||
} = await this.parse(Download);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
|
||||
|
||||
if (!buildId && !fingerprint) {
|
||||
throw new Error('Either --build-id or --fingerprint is required.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
@@ -64,24 +91,113 @@ export default class Download extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
const build = await this.getBuildAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint,
|
||||
developmentClient,
|
||||
});
|
||||
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
let build: BuildFragment;
|
||||
let selectedPlatform: AppPlatform;
|
||||
if (buildId) {
|
||||
build = await this.getBuildByIdAsync({ graphqlClient, buildId });
|
||||
selectedPlatform = build.platform;
|
||||
Log.succeed(`🎯 Found build ${chalk.bold(buildId)} on EAS servers.`);
|
||||
} else {
|
||||
selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
build = await this.getBuildByFingerprintAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint!,
|
||||
developmentClient,
|
||||
});
|
||||
}
|
||||
|
||||
let buildArtifactPath: string | null = null;
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
} else if (!allArtifacts) {
|
||||
const availableArtifacts = listAvailableExtraArtifactNames(build);
|
||||
if (availableArtifacts.length > 0) {
|
||||
throw new Error(
|
||||
`Build does not have an application archive url. Other artifacts are available (${availableArtifacts.join(', ')}); re-run with --all-artifacts to download them.`
|
||||
);
|
||||
}
|
||||
throw new Error('Build does not have an application archive url');
|
||||
}
|
||||
|
||||
let extraArtifactPaths: Record<string, string> = {};
|
||||
if (allArtifacts) {
|
||||
extraArtifactPaths = await this.downloadExtraArtifactsAsync(build);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
const jsonResults = { path: buildArtifactPath };
|
||||
const jsonResults = {
|
||||
...(buildArtifactPath != null && { path: buildArtifactPath }),
|
||||
...extraArtifactPaths,
|
||||
};
|
||||
printJsonOnlyOutput(jsonResults);
|
||||
} else {
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
if (buildArtifactPath != null) {
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
}
|
||||
for (const [name, filePath] of Object.entries(extraArtifactPaths)) {
|
||||
Log.log(`${name} downloaded to ${chalk.bold(filePath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getBuildAsync({
|
||||
private async downloadExtraArtifactsAsync(build: BuildFragment): Promise<Record<string, string>> {
|
||||
const artifacts = build.artifacts;
|
||||
if (!artifacts) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const extraArtifacts: Array<{ name: string; url: string }> = [];
|
||||
if (artifacts.buildArtifactsUrl) {
|
||||
extraArtifacts.push({ name: 'buildArtifacts', url: artifacts.buildArtifactsUrl });
|
||||
}
|
||||
if (artifacts.xcodeBuildLogsUrl) {
|
||||
extraArtifacts.push({ name: 'xcodeBuildLogs', url: artifacts.xcodeBuildLogsUrl });
|
||||
}
|
||||
if (artifacts.buildUrl && artifacts.buildUrl !== artifacts.applicationArchiveUrl) {
|
||||
extraArtifacts.push({ name: 'build', url: artifacts.buildUrl });
|
||||
}
|
||||
|
||||
if (extraArtifacts.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const outputDir = path.join(getEasBuildRunCacheDirectoryPath(), `${build.id}-artifacts`);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
const downloaded: Record<string, string> = {};
|
||||
for (const { name, url } of extraArtifacts) {
|
||||
const fileName = getFileNameFromUrl(url, name);
|
||||
const outputPath = path.join(outputDir, fileName);
|
||||
await downloadFileWithProgressTrackerAsync(
|
||||
url,
|
||||
outputPath,
|
||||
(ratio, total) =>
|
||||
`Downloading ${name} (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
|
||||
`Successfully downloaded ${name}`
|
||||
);
|
||||
downloaded[name] = outputPath;
|
||||
}
|
||||
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
private async getBuildByIdAsync({
|
||||
graphqlClient,
|
||||
buildId,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
buildId: string;
|
||||
}): Promise<BuildFragment> {
|
||||
try {
|
||||
return await BuildQuery.byIdAsync(graphqlClient, buildId);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Could not find build with ID ${buildId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBuildByFingerprintAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
@@ -108,7 +224,7 @@ export default class Download extends EasCommand {
|
||||
limit: 1,
|
||||
});
|
||||
if (builds.length === 0) {
|
||||
throw new Errors.CLIError(
|
||||
throw new Error(
|
||||
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
|
||||
);
|
||||
}
|
||||
@@ -123,7 +239,7 @@ export default class Download extends EasCommand {
|
||||
build.id,
|
||||
platform
|
||||
);
|
||||
if (await pathExists(cachedBuildArtifactPath)) {
|
||||
if (await fs.pathExists(cachedBuildArtifactPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached build...`);
|
||||
return cachedBuildArtifactPath;
|
||||
@@ -167,3 +283,33 @@ async function resolvePlatformAsync({
|
||||
});
|
||||
return selectedPlatform;
|
||||
}
|
||||
|
||||
function listAvailableExtraArtifactNames(build: BuildFragment): string[] {
|
||||
const names: string[] = [];
|
||||
if (build.artifacts?.buildArtifactsUrl) {
|
||||
names.push('buildArtifacts');
|
||||
}
|
||||
if (build.artifacts?.xcodeBuildLogsUrl) {
|
||||
names.push('xcodeBuildLogs');
|
||||
}
|
||||
if (
|
||||
build.artifacts?.buildUrl &&
|
||||
build.artifacts.buildUrl !== build.artifacts.applicationArchiveUrl
|
||||
) {
|
||||
names.push('build');
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function getFileNameFromUrl(url: string, fallbackName: string): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const basename = path.basename(pathname);
|
||||
if (basename) {
|
||||
return basename;
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
@@ -215,7 +215,6 @@ export default class BuildResign extends EasCommand {
|
||||
},
|
||||
},
|
||||
secrets: prepareCredentialsToResign(credentialsResult.credentials),
|
||||
builderEnvironment: { image: 'default' },
|
||||
};
|
||||
const newBuild = await BuildMutation.retryIosBuildAsync(graphqlClient, {
|
||||
buildId: build.id,
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../../commandUtils/errors';
|
||||
import { selectOrCreateAscApiKeyIdAsync } from '../../../../integrations/asc/ascApiKey';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import IntegrationsAscConnect from '../connect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
|
||||
jest.mock('../../../../integrations/asc/ascApiKey');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
const mockRemoteApps = [
|
||||
{
|
||||
ascAppIdentifier: '9876543210',
|
||||
bundleIdentifier: 'com.test.newapp',
|
||||
name: 'New App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe(IntegrationsAscConnect, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const actor = { id: 'actor-id' } as any;
|
||||
const analytics = {} as any;
|
||||
const vcsClient = {} as any;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fails when already connected', async () => {
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(EasCommandError);
|
||||
});
|
||||
|
||||
it('connects successfully in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'key-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Apple key identifier in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'eas-key-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires --api-key-id in non-interactive mode', async () => {
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('--api-key-id is required');
|
||||
});
|
||||
|
||||
it('requires --asc-app-id in non-interactive mode', async () => {
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required');
|
||||
});
|
||||
|
||||
it('fails when asc-app-id is not found among discovered apps', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps');
|
||||
});
|
||||
|
||||
it('fails when passing EAS key id in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No App Store Connect API key found with Apple key identifier'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when multiple keys match Apple key identifier', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid-1',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
{
|
||||
id: 'eas-key-uuid-2',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Multiple App Store Connect API keys match Apple key identifier'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when no accessible apps are discovered', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No accessible apps found on App Store Connect'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when app discovery throws', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync)
|
||||
.mockRejectedValueOnce(new Error('discovery failed'));
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('discovery failed');
|
||||
});
|
||||
|
||||
it('creates or selects key when no api-key-id is provided', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]);
|
||||
jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id');
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
|
||||
const command = new IntegrationsAscConnect(['--asc-app-id', '9876543210'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled();
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'generated-key-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import { toggleConfirmAsync } from '../../../../prompts';
|
||||
import IntegrationsAscDisconnect from '../disconnect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe(IntegrationsAscDisconnect, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('disconnects successfully with --yes', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataConnected)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'asc-app-link-id'
|
||||
);
|
||||
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
{
|
||||
useCache: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('no-op when already disconnected', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints json output when already disconnected in json mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes', '--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action": "disconnect"')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key gracefully', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('revoked or is no longer valid')
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels disconnection when confirmation is rejected', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as never);
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false);
|
||||
|
||||
const command = new IntegrationsAscDisconnect([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit called');
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import IntegrationsAscStatus from '../status';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe(IntegrationsAscStatus, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays connected status', async () => {
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays disconnected status', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints json output in json mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscStatus(['--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action": "status"')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key gracefully', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('revoked or is no longer valid')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key with json output', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscStatus(['--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"status": "invalid"')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when fetching status fails', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockRejectedValueOnce(new Error('status failed'));
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('status failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { CredentialsContext } from '../../../credentials/context';
|
||||
import { buildJsonOutput, formatAscAppLinkStatus } from '../../../integrations/asc/utils';
|
||||
import { selectOrCreateAscApiKeyIdAsync } from '../../../integrations/asc/ascApiKey';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
type DiscoveredAscApps = Awaited<ReturnType<typeof AscAppLinkQuery.discoverAccessibleAppsAsync>>;
|
||||
|
||||
export default class IntegrationsAscConnect extends EasCommand {
|
||||
static override description = 'connect a project to an App Store Connect app';
|
||||
|
||||
static override flags = {
|
||||
'api-key-id': Flags.string({
|
||||
description: 'Apple App Store Connect API Key ID',
|
||||
}),
|
||||
'asc-app-id': Flags.string({
|
||||
description: 'App Store Connect app identifier',
|
||||
}),
|
||||
'bundle-id': Flags.string({
|
||||
description: 'Filter discovered apps by bundle identifier',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscConnect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
if (!flags['api-key-id']) {
|
||||
throw new EasCommandError('--api-key-id is required in non-interactive mode.');
|
||||
}
|
||||
if (!flags['asc-app-id']) {
|
||||
throw new EasCommandError('--asc-app-id is required in non-interactive mode.');
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
projectDir,
|
||||
loggedIn: { actor, graphqlClient },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(IntegrationsAscConnect, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// Step 1: Check current status
|
||||
const statusSpinner = ora('Checking current App Store Connect app link status').start();
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
statusSpinner.succeed('Checked current status');
|
||||
|
||||
if (metadata.appStoreConnectApp) {
|
||||
throw new EasCommandError(
|
||||
`Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas integrations:asc:disconnect')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Get ASC API key
|
||||
const keysSpinner = ora('Fetching App Store Connect API keys').start();
|
||||
const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync(
|
||||
graphqlClient,
|
||||
metadata.ownerAccount.name
|
||||
);
|
||||
keysSpinner.succeed(`Found ${keys.length} API key(s)`);
|
||||
|
||||
let apiKeyId = flags['api-key-id'];
|
||||
if (!apiKeyId) {
|
||||
const credentialsContext = new CredentialsContext({
|
||||
projectInfo: null,
|
||||
nonInteractive,
|
||||
projectDir,
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
apiKeyId = await selectOrCreateAscApiKeyIdAsync({
|
||||
credentialsContext,
|
||||
existingKeys: keys,
|
||||
ownerAccount: metadata.ownerAccount,
|
||||
});
|
||||
} else {
|
||||
const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId);
|
||||
if (keysByAppleId.length > 1) {
|
||||
throw new EasCommandError(
|
||||
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
} else if (keysByAppleId.length === 1) {
|
||||
apiKeyId = keysByAppleId[0].id;
|
||||
} else {
|
||||
throw new EasCommandError(
|
||||
`No App Store Connect API key found with Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!apiKeyId) {
|
||||
throw new EasCommandError('No App Store Connect API key selected.');
|
||||
}
|
||||
|
||||
// Step 3: Discover remote apps
|
||||
const discoverSpinner = ora('Discovering App Store Connect apps').start();
|
||||
let remoteApps: DiscoveredAscApps;
|
||||
try {
|
||||
remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync(
|
||||
graphqlClient,
|
||||
apiKeyId,
|
||||
flags['bundle-id']
|
||||
);
|
||||
discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`);
|
||||
} catch (err) {
|
||||
discoverSpinner.fail('Failed to discover apps');
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (remoteApps.length === 0) {
|
||||
throw new EasCommandError(
|
||||
'No accessible apps found on App Store Connect for the selected API key.' +
|
||||
(flags['bundle-id']
|
||||
? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Select remote app
|
||||
let selectedApp: DiscoveredAscApps[number];
|
||||
if (flags['asc-app-id']) {
|
||||
const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']);
|
||||
if (!match) {
|
||||
throw new EasCommandError(
|
||||
`App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas integrations:asc:connect')} interactively to discover available apps.`
|
||||
);
|
||||
}
|
||||
selectedApp = match;
|
||||
} else {
|
||||
selectedApp = await selectAsync<DiscoveredAscApps[number]>(
|
||||
'Select an App Store Connect app:',
|
||||
remoteApps.map(app => ({
|
||||
title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`,
|
||||
value: app,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Create link
|
||||
const createSpinner = ora('Connecting project to App Store Connect app').start();
|
||||
try {
|
||||
await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, {
|
||||
appId: metadata.id,
|
||||
ascAppIdentifier: selectedApp.ascAppIdentifier,
|
||||
appStoreConnectApiKeyId: apiKeyId,
|
||||
});
|
||||
createSpinner.succeed('Connected project to App Store Connect app');
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to connect project');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 6: Refetch and display
|
||||
const refetchSpinner = ora('Verifying connection').start();
|
||||
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
refetchSpinner.succeed('Verified connection');
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(updatedMetadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../../../integrations/asc/utils';
|
||||
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsAscDisconnect extends EasCommand {
|
||||
static override description = 'disconnect the current project from its App Store Connect app';
|
||||
|
||||
static override flags = {
|
||||
yes: Flags.boolean({
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscDisconnect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsAscDisconnect, {
|
||||
nonInteractive: nonInteractive || flags.yes,
|
||||
});
|
||||
|
||||
// Step 1: Check current status
|
||||
const metadata = await this.fetchCurrentStatusAsync(graphqlClient, projectId);
|
||||
if (!metadata) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildInvalidJsonOutput('disconnect', projectId));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
'The App Store Connect API key linked to this project has been revoked or is no longer valid.\nThe connection cannot be resolved from the CLI. Please update the API key on the Expo dashboard.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.appStoreConnectApp) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('disconnect', metadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Confirm
|
||||
if (!flags.yes && !nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(metadata));
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.'
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: 'Are you sure you wish to proceed?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error('Canceled disconnection');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete
|
||||
const deleteSpinner = ora('Disconnecting App Store Connect app').start();
|
||||
try {
|
||||
await AscAppLinkMutation.deleteAppStoreConnectAppAsync(
|
||||
graphqlClient,
|
||||
metadata.appStoreConnectApp.id
|
||||
);
|
||||
deleteSpinner.succeed('Disconnected App Store Connect app');
|
||||
} catch (err) {
|
||||
deleteSpinner.fail('Failed to disconnect App Store Connect app');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 4: Refetch and display
|
||||
const refetchSpinner = ora('Verifying disconnection').start();
|
||||
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, {
|
||||
useCache: false,
|
||||
});
|
||||
refetchSpinner.succeed('Verified disconnection');
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(updatedMetadata));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCurrentStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
|
||||
const spinner = ora('Checking current App Store Connect app link status').start();
|
||||
try {
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
spinner.succeed('Checked current status');
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (isAscAuthenticationError(err)) {
|
||||
spinner.fail('App Store Connect connection is invalid');
|
||||
return null;
|
||||
}
|
||||
spinner.fail('Failed to check current status');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../../../integrations/asc/utils';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsAscStatus extends EasCommand {
|
||||
static override description =
|
||||
'show the App Store Connect app link status for the current project';
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscStatus);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsAscStatus, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const metadata = await this.fetchStatusAsync(graphqlClient, projectId);
|
||||
if (!metadata) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildInvalidJsonOutput('status', projectId));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
'The App Store Connect API key linked to this project has been revoked or is no longer valid.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('status', metadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(metadata));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
|
||||
const spinner = ora('Fetching App Store Connect app link status').start();
|
||||
try {
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
spinner.succeed('Fetched App Store Connect app link status');
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (isAscAuthenticationError(err)) {
|
||||
spinner.fail('App Store Connect connection is invalid');
|
||||
return null;
|
||||
}
|
||||
spinner.fail('Failed to fetch App Store Connect app link status');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
|
||||
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
|
||||
// so adding a new enum value in codegen fails the build until it is wired up here.
|
||||
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
|
||||
[DeviceRunSessionType.AgentDevice]: 'agent-device',
|
||||
};
|
||||
|
||||
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
|
||||
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
|
||||
([type, value]) => [value, type]
|
||||
)
|
||||
) as Record<string, DeviceRunSessionType>;
|
||||
|
||||
type ReadinessResult = { ready: true; message: string } | { ready: false };
|
||||
type ReadinessChecker = (logMessages: readonly string[]) => ReadinessResult;
|
||||
|
||||
export default class SimulatorStart extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get the credentials to connect to it with the CLI tool of your choice';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Device platform',
|
||||
options: ['android', 'ios'] as const,
|
||||
required: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Type of device run session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
'package-version': Flags.string({
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStart);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStart, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
|
||||
|
||||
const createSpinner = ora('🚀 Creating device run session').start();
|
||||
let deviceRunSessionId: string;
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
const jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
|
||||
createSpinner.succeed(
|
||||
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
|
||||
);
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create device run session');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const checkReadiness = getReadinessCheckerForType(flags.type);
|
||||
|
||||
const pollSpinner = ora(`⏳ Waiting for ${flags.type} daemon to start`).start();
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let result: ReadinessResult = { ready: false };
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
|
||||
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
session.status === DeviceRunSessionStatus.Stopped
|
||||
) {
|
||||
throw new Error(
|
||||
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} daemon was ready.`
|
||||
);
|
||||
}
|
||||
|
||||
const jobRunStatus = session.turtleJobRun?.status;
|
||||
if (
|
||||
jobRunStatus === JobRunStatus.Errored ||
|
||||
jobRunStatus === JobRunStatus.Canceled ||
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
throw new Error(
|
||||
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} daemon was ready.`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessages = await fetchLogMessagesAsync(session.turtleJobRun?.logFileUrls ?? []);
|
||||
result = checkReadiness(logMessages);
|
||||
if (result.ready) {
|
||||
pollSpinner.succeed(`🎉 ${flags.type} daemon is ready`);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleepAsync(POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
pollSpinner.fail(`Failed while polling for ${flags.type} daemon logs`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!result.ready) {
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} daemon to start`);
|
||||
throw new Error(
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} daemon to start.`
|
||||
);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`);
|
||||
Log.newLine();
|
||||
Log.log(result.message);
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getReadinessCheckerForType(type: string): ReadinessChecker {
|
||||
switch (type) {
|
||||
case DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice]:
|
||||
return checkAgentDeviceReadiness;
|
||||
default:
|
||||
throw new Error(`Unsupported device run session type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const AGENT_DEVICE_BASE_URL_ENV_VAR = 'AGENT_DEVICE_DAEMON_BASE_URL';
|
||||
const AGENT_DEVICE_AUTH_TOKEN_ENV_VAR = 'AGENT_DEVICE_DAEMON_AUTH_TOKEN';
|
||||
|
||||
function checkAgentDeviceReadiness(logMessages: readonly string[]): ReadinessResult {
|
||||
let baseUrl: string | undefined;
|
||||
let authToken: string | undefined;
|
||||
for (const msg of logMessages) {
|
||||
baseUrl = baseUrl ?? extractExportedEnvValue(msg, AGENT_DEVICE_BASE_URL_ENV_VAR);
|
||||
authToken = authToken ?? extractExportedEnvValue(msg, AGENT_DEVICE_AUTH_TOKEN_ENV_VAR);
|
||||
if (baseUrl && authToken) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (baseUrl && authToken) {
|
||||
return {
|
||||
ready: true,
|
||||
message: [
|
||||
`export ${AGENT_DEVICE_BASE_URL_ENV_VAR}='${baseUrl}'`,
|
||||
`export ${AGENT_DEVICE_AUTH_TOKEN_ENV_VAR}='${authToken}'`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
async function fetchLogMessagesAsync(logUrls: readonly string[]): Promise<string[]> {
|
||||
const messages: string[] = [];
|
||||
for (const url of logUrls) {
|
||||
const text = await fetchLogTextAsync(url);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
messages.push(extractLogMessage(line));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function fetchLogTextAsync(url: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
return await response.text();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLogMessage(line: string): string {
|
||||
// Turtle job run logs are JSONL (bunyan-shaped), e.g.
|
||||
// {"msg":"export FOO=\"bar\"","time":"...","logId":"..."}
|
||||
// Fall back to the raw line if it's not JSON or doesn't have a string msg.
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && 'msg' in parsed) {
|
||||
const msg = (parsed as { msg: unknown }).msg;
|
||||
if (typeof msg === 'string') {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not JSON, fall through
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function extractExportedEnvValue(text: string, varName: string): string | undefined {
|
||||
// Matches: export NAME=value | export NAME="value" | export NAME='value'
|
||||
const pattern = new RegExp(`export\\s+${escapeRegExp(varName)}=(?:"([^"]*)"|'([^']*)'|(\\S+))`);
|
||||
const match = pattern.exec(text);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1] ?? match[2] ?? match[3];
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { ora } from '../../ora';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStop);
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStop, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.stopDeviceRunSessionAsync(
|
||||
graphqlClient,
|
||||
flags.id
|
||||
);
|
||||
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
|
||||
} catch (err) {
|
||||
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey
|
||||
|
||||
export enum AppStoreApiKeyPurpose {
|
||||
SUBMISSION_SERVICE = 'EAS Submit',
|
||||
ASC_APP_CONNECTION = 'EAS Connect',
|
||||
}
|
||||
|
||||
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
|
||||
@@ -99,12 +101,27 @@ async function generateAscApiKeyAsync(
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<MinimalAscApiKey> {
|
||||
await ctx.appStore.ensureAuthenticatedAsync();
|
||||
const role = await selectRoleForGeneratedAscApiKeyAsync();
|
||||
const ascApiKey = await ctx.appStore.createAscApiKeyAsync(ctx.analytics, {
|
||||
nickname: getAscApiKeyName(purpose),
|
||||
roles: [role],
|
||||
});
|
||||
return await getMinimalAscApiKeyAsync(ascApiKey);
|
||||
}
|
||||
|
||||
async function selectRoleForGeneratedAscApiKeyAsync(): Promise<UserRole> {
|
||||
return await selectAsync<UserRole>('Select role for the generated API key:', [
|
||||
{
|
||||
title: 'ADMIN (default)',
|
||||
value: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
title: 'APP_MANAGER (least privilege for app management)',
|
||||
value: UserRole.APP_MANAGER,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
|
||||
const nameParts = ['[Expo]', purpose, nanoid(10)];
|
||||
return nameParts.join(' ');
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
import { testAscApiKey } from '../../../__tests__/fixtures-ios';
|
||||
import { getCredentialsFromUserAsync } from '../../../utils/promptForCredentials';
|
||||
import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../../utils/promptForCredentials';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
getAscApiKeyName,
|
||||
promptForAscApiKeyPathAsync,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
} from '../AscApiKeyUtils';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../utils/promptForCredentials');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
return Object.keys(obj) as K[];
|
||||
@@ -18,7 +26,10 @@ function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
|
||||
afterEach(() => {
|
||||
jest.mocked(promptAsync).mockClear();
|
||||
jest.mocked(selectAsync).mockClear();
|
||||
jest.mocked(getCredentialsFromUserAsync).mockClear();
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
});
|
||||
|
||||
describe(getAscApiKeyName, () => {
|
||||
@@ -87,3 +98,84 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
|
||||
});
|
||||
});
|
||||
|
||||
describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
it('creates a key with ADMIN role by default', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
|
||||
jest.mocked(selectAsync).mockResolvedValue(UserRole.ADMIN);
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
|
||||
|
||||
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
|
||||
nickname: expect.any(String),
|
||||
roles: [UserRole.ADMIN],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows creating a key with APP_MANAGER role', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
|
||||
jest.mocked(selectAsync).mockResolvedValue(UserRole.APP_MANAGER);
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
|
||||
|
||||
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
|
||||
nickname: expect.any(String),
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not apply role override when using a user-provided key', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(false);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ keyP8Path: '/asc-api-key.p8' });
|
||||
jest.mocked(fs.readFile).mockImplementation(async () => 'test-key-p8' as any);
|
||||
jest
|
||||
.mocked(getCredentialsFromUserAsync)
|
||||
.mockResolvedValueOnce({ keyId: 'test-key-id' })
|
||||
.mockResolvedValueOnce({ issuerId: 'test-issuer-id' });
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provideOrGenerateAscApiKeyAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
keyP8: expect.any(String),
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProfileType } from '@expo/app-store';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import {
|
||||
AscApiKey,
|
||||
@@ -195,10 +196,10 @@ export default class AppStoreApi {
|
||||
|
||||
public async createAscApiKeyAsync(
|
||||
analytics: Analytics,
|
||||
{ nickname }: { nickname: string }
|
||||
{ nickname, roles }: { nickname: string; roles?: UserRole[] }
|
||||
): Promise<AscApiKey> {
|
||||
const userCtx = await this.ensureUserAuthenticatedAsync();
|
||||
return await createAscApiKeyAsync(analytics, userCtx, { nickname });
|
||||
return await createAscApiKeyAsync(analytics, userCtx, { nickname, roles });
|
||||
}
|
||||
|
||||
public async revokeAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo> {
|
||||
|
||||
@@ -90,6 +90,23 @@ test(`createAscApiKeyAsync`, async () => {
|
||||
expect(result).toEqual({ ...mockAscApiKeyInfo, keyP8: 'super secret' });
|
||||
});
|
||||
|
||||
test(`createAscApiKeyAsync forwards explicit roles`, async () => {
|
||||
const analytics = instance(mock<Analytics>());
|
||||
const mockRequestContext = {};
|
||||
jest.spyOn(ApiKey, 'createAsync').mockImplementation(async () => mockApiKey);
|
||||
jest.mocked(getRequestContext).mockImplementation(() => mockRequestContext);
|
||||
await createAscApiKeyAsync(analytics, mockAuthCtx, {
|
||||
nickname: 'test-name',
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
});
|
||||
expect(ApiKey.createAsync).toHaveBeenLastCalledWith(mockRequestContext, {
|
||||
nickname: 'test-name',
|
||||
allAppsVisible: true,
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
keyType: ApiKeyType.PUBLIC_API,
|
||||
});
|
||||
});
|
||||
|
||||
test(`revokeAscApiKeyAsync`, async () => {
|
||||
const mockRequestContext = {};
|
||||
jest.spyOn(ApiKey, 'infoAsync').mockImplementation(async () => mockApiKey);
|
||||
|
||||
+286
@@ -1399,6 +1399,7 @@ export type App = Project & {
|
||||
*/
|
||||
buildsReleaseChannels: Array<Scalars['String']['output']>;
|
||||
channelsPaginated: AppChannelsConnection;
|
||||
convexProject?: Maybe<ConvexProject>;
|
||||
deployment?: Maybe<Deployment>;
|
||||
/** Deployments associated with this app */
|
||||
deployments: DeploymentsConnection;
|
||||
@@ -2086,6 +2087,9 @@ export type AppNotificationPreferenceInput = {
|
||||
export type AppObserve = {
|
||||
__typename?: 'AppObserve';
|
||||
appVersions: Array<AppObserveAppVersion>;
|
||||
customEventCounts: AppObserveCustomEventCounts;
|
||||
customEventList: AppObserveCustomEventListConnection;
|
||||
customEventNames: AppObserveCustomEventNames;
|
||||
environments: Array<Scalars['String']['output']>;
|
||||
events: AppObserveEventsConnection;
|
||||
timeSeries: AppObserveTimeSeries;
|
||||
@@ -2098,6 +2102,28 @@ export type AppObserveAppVersionsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveCustomEventCountsArgs = {
|
||||
input: AppObserveCustomEventCountsInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveCustomEventListArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<AppObserveCustomEventListFilter>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveCustomEventNamesArgs = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveEnvironmentsArgs = {
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
@@ -2163,6 +2189,96 @@ export type AppObserveAppVersionMetric = {
|
||||
statistics: AppObserveVersionMarkerStatistics;
|
||||
};
|
||||
|
||||
export type AppObserveCustomEvent = {
|
||||
__typename?: 'AppObserveCustomEvent';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
appEasBuildId?: Maybe<Scalars['String']['output']>;
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
deviceModel: Scalars['String']['output'];
|
||||
deviceOs: Scalars['String']['output'];
|
||||
deviceOsVersion: Scalars['String']['output'];
|
||||
easClientId: Scalars['String']['output'];
|
||||
environment?: Maybe<Scalars['String']['output']>;
|
||||
eventName: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
properties: Array<AppObserveEventProperty>;
|
||||
sessionId?: Maybe<Scalars['String']['output']>;
|
||||
severityNumber?: Maybe<Scalars['Int']['output']>;
|
||||
severityText?: Maybe<Scalars['String']['output']>;
|
||||
timestamp: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventCountBucket = {
|
||||
__typename?: 'AppObserveCustomEventCountBucket';
|
||||
bucket: Scalars['DateTime']['output'];
|
||||
count: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventCounts = {
|
||||
__typename?: 'AppObserveCustomEventCounts';
|
||||
buckets: Array<AppObserveCustomEventCountBucket>;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventCountsInput = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
eventName: Scalars['String']['input'];
|
||||
platform: AppObservePlatform;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventEdge = {
|
||||
__typename?: 'AppObserveCustomEventEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: AppObserveCustomEvent;
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventListConnection = {
|
||||
__typename?: 'AppObserveCustomEventListConnection';
|
||||
edges: Array<AppObserveCustomEventEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventListFilter = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
eventName?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventName = {
|
||||
__typename?: 'AppObserveCustomEventName';
|
||||
count: Scalars['Int']['output'];
|
||||
eventName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventNames = {
|
||||
__typename?: 'AppObserveCustomEventNames';
|
||||
isTruncated: Scalars['Boolean']['output'];
|
||||
names: Array<AppObserveCustomEventName>;
|
||||
};
|
||||
|
||||
export type AppObserveCustomEventPropertyFilter = {
|
||||
key: Scalars['String']['input'];
|
||||
value: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveEvent = {
|
||||
__typename?: 'AppObserveEvent';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
@@ -2202,6 +2318,13 @@ export type AppObserveEventEdge = {
|
||||
node: AppObserveEvent;
|
||||
};
|
||||
|
||||
export type AppObserveEventProperty = {
|
||||
__typename?: 'AppObserveEventProperty';
|
||||
key: Scalars['String']['output'];
|
||||
type: AppObservePropertyType;
|
||||
value: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveEventsConnection = {
|
||||
__typename?: 'AppObserveEventsConnection';
|
||||
edges: Array<AppObserveEventEdge>;
|
||||
@@ -2242,6 +2365,13 @@ export enum AppObservePlatform {
|
||||
Ios = 'IOS'
|
||||
}
|
||||
|
||||
export enum AppObservePropertyType {
|
||||
Boolean = 'BOOLEAN',
|
||||
Json = 'JSON',
|
||||
Number = 'NUMBER',
|
||||
String = 'STRING'
|
||||
}
|
||||
|
||||
export type AppObserveReleasesInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2850,6 +2980,23 @@ export type AppleDeviceRegistrationRequestMutationCreateAppleDeviceRegistrationR
|
||||
appleTeamId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
/** Publicly visible data for an AppleDeviceRegistrationRequest. */
|
||||
export type AppleDeviceRegistrationRequestPublicData = {
|
||||
__typename?: 'AppleDeviceRegistrationRequestPublicData';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type AppleDeviceRegistrationRequestPublicDataQuery = {
|
||||
__typename?: 'AppleDeviceRegistrationRequestPublicDataQuery';
|
||||
/** Get AppleDeviceRegistrationRequest public data by ID */
|
||||
byId?: Maybe<AppleDeviceRegistrationRequestPublicData>;
|
||||
};
|
||||
|
||||
|
||||
export type AppleDeviceRegistrationRequestPublicDataQueryByIdArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AppleDeviceRegistrationRequestQuery = {
|
||||
__typename?: 'AppleDeviceRegistrationRequestQuery';
|
||||
byId: AppleDeviceRegistrationRequest;
|
||||
@@ -4072,6 +4219,18 @@ export type ConvexIntegrationQuery = {
|
||||
clientIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ConvexProject = {
|
||||
__typename?: 'ConvexProject';
|
||||
app: App;
|
||||
convexProjectIdentifier: Scalars['String']['output'];
|
||||
convexProjectName: Scalars['String']['output'];
|
||||
convexProjectSlug: Scalars['String']['output'];
|
||||
convexTeamConnection: ConvexTeamConnection;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type ConvexTeamConnection = {
|
||||
__typename?: 'ConvexTeamConnection';
|
||||
account: Account;
|
||||
@@ -4173,6 +4332,17 @@ export type CreateConvexTeamConnectionInput = {
|
||||
deploymentRegion: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
/**
|
||||
* The version of the package backing the device run session (e.g. "0.1.3-alpha.3").
|
||||
* If omitted, consumers treat the session as pinned to "latest".
|
||||
*/
|
||||
packageVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
platform: AppPlatform;
|
||||
type: DeviceRunSessionType;
|
||||
};
|
||||
|
||||
export type CreateEchoChatInput = {
|
||||
agentMetadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
agentType?: InputMaybe<EchoAgentType>;
|
||||
@@ -4770,6 +4940,65 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
|
||||
deploymentIdentifier: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSession = {
|
||||
__typename?: 'DeviceRunSession';
|
||||
app: App;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
finishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
initiatingActor?: Maybe<Actor>;
|
||||
/**
|
||||
* The version of the package backing the device run session. Null means the session is
|
||||
* pinned to "latest" at the consumer side.
|
||||
*/
|
||||
packageVersion?: Maybe<Scalars['String']['output']>;
|
||||
platform: AppPlatform;
|
||||
startedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
status: DeviceRunSessionStatus;
|
||||
turtleJobRun?: Maybe<JobRun>;
|
||||
type: DeviceRunSessionType;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionMutation = {
|
||||
__typename?: 'DeviceRunSessionMutation';
|
||||
/** Create a device run session */
|
||||
createDeviceRunSession: DeviceRunSession;
|
||||
/** Stop a device run session */
|
||||
stopDeviceRunSession: DeviceRunSession;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationStopDeviceRunSessionArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionQuery = {
|
||||
__typename?: 'DeviceRunSessionQuery';
|
||||
byId: DeviceRunSession;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionQueryByIdArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum DeviceRunSessionStatus {
|
||||
Errored = 'ERRORED',
|
||||
InProgress = 'IN_PROGRESS',
|
||||
New = 'NEW',
|
||||
Stopped = 'STOPPED'
|
||||
}
|
||||
|
||||
export enum DeviceRunSessionType {
|
||||
AgentDevice = 'AGENT_DEVICE'
|
||||
}
|
||||
|
||||
export type DiscordUser = {
|
||||
__typename?: 'DiscordUser';
|
||||
discordIdentifier: Scalars['String']['output'];
|
||||
@@ -5523,6 +5752,8 @@ export enum EntityTypeName {
|
||||
BillingContractEntity = 'BillingContractEntity',
|
||||
BranchEntity = 'BranchEntity',
|
||||
ChannelEntity = 'ChannelEntity',
|
||||
ConvexProjectEntity = 'ConvexProjectEntity',
|
||||
ConvexTeamConnectionEntity = 'ConvexTeamConnectionEntity',
|
||||
CustomerEntity = 'CustomerEntity',
|
||||
EchoProjectEntity = 'EchoProjectEntity',
|
||||
EchoVersionEntity = 'EchoVersionEntity',
|
||||
@@ -7610,6 +7841,8 @@ export type RootMutation = {
|
||||
deployments: DeploymentsMutation;
|
||||
/** Mutations that assign or modify DevDomainNames for apps */
|
||||
devDomainName: AppDevDomainNameMutation;
|
||||
/** Mutations that create and stop device run sessions */
|
||||
deviceRunSession: DeviceRunSessionMutation;
|
||||
/** Mutations for Discord users */
|
||||
discordUser: DiscordUserMutation;
|
||||
/** Mutations for Echo chats */
|
||||
@@ -7745,6 +7978,8 @@ export type RootQuery = {
|
||||
appStoreConnectApiKey: AppStoreConnectApiKeyQuery;
|
||||
/** Top-level query object for querying Apple Device registration requests. */
|
||||
appleDeviceRegistrationRequest: AppleDeviceRegistrationRequestQuery;
|
||||
/** Top-level query object for querying AppleDeviceRegistrationRequest publicly. */
|
||||
appleDeviceRegistrationRequestPublicData: AppleDeviceRegistrationRequestPublicDataQuery;
|
||||
/** Top-level query object for querying Apple distribution certificates. */
|
||||
appleDistributionCertificate?: Maybe<AppleDistributionCertificateQuery>;
|
||||
/** Top-level query object for querying Apple provisioning profiles. */
|
||||
@@ -7768,6 +8003,7 @@ export type RootQuery = {
|
||||
convexIntegration: ConvexIntegrationQuery;
|
||||
/** Top-level query object for querying Deployments. */
|
||||
deployments: DeploymentQuery;
|
||||
deviceRunSessions: DeviceRunSessionQuery;
|
||||
/** Top-level query object for querying Echo chats. */
|
||||
echoChat: EchoChatQuery;
|
||||
/** Top-level query object for querying Echo messages. */
|
||||
@@ -11618,6 +11854,20 @@ export type CreateAppVersionMutationVariables = Exact<{
|
||||
|
||||
export type CreateAppVersionMutation = { __typename?: 'RootMutation', appVersion: { __typename?: 'AppVersionMutation', createAppVersion: { __typename?: 'AppVersion', id: string } } };
|
||||
|
||||
export type CreateAppStoreConnectAppMutationVariables = Exact<{
|
||||
appStoreConnectAppInput: AppStoreConnectAppInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', createAppStoreConnectApp: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string } } };
|
||||
|
||||
export type DeleteAppStoreConnectAppMutationVariables = Exact<{
|
||||
appStoreConnectAppId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', deleteAppStoreConnectApp: { __typename?: 'DeleteAppStoreConnectAppResult', id: string } } };
|
||||
|
||||
export type CreateAndroidBuildMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
job: AndroidJobInput;
|
||||
@@ -11654,6 +11904,20 @@ export type RetryIosBuildMutationVariables = Exact<{
|
||||
|
||||
export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
|
||||
|
||||
export type CreateDeviceRunSessionMutationVariables = Exact<{
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', createDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string } | null } } };
|
||||
|
||||
export type StopDeviceRunSessionMutationVariables = Exact<{
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type StopDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', stopDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus } } };
|
||||
|
||||
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
|
||||
input: CreateEnvironmentSecretInput;
|
||||
accountId: Scalars['String']['input'];
|
||||
@@ -11963,6 +12227,21 @@ export type LatestAppVersionQueryVariables = Exact<{
|
||||
|
||||
export type LatestAppVersionQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, latestAppVersionByPlatformAndApplicationIdentifier?: { __typename?: 'AppVersion', id: string, storeVersion: string, buildVersion: string } | null } } };
|
||||
|
||||
export type AscAppLinkAppMetadataQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AscAppLinkAppMetadataQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, appStoreConnectApp?: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string, remoteAppStoreConnectApp: { __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null } } | null } } };
|
||||
|
||||
export type DiscoverAccessibleAppStoreConnectAppsQueryVariables = Exact<{
|
||||
appStoreConnectApiKeyId: Scalars['ID']['input'];
|
||||
bundleIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type DiscoverAccessibleAppStoreConnectAppsQuery = { __typename?: 'RootQuery', appStoreConnectApiKey: { __typename?: 'AppStoreConnectApiKeyQuery', byId: { __typename?: 'AppStoreConnectApiKey', id: string, remoteAppStoreConnectApps: Array<{ __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null }> } } };
|
||||
|
||||
export type GetAssetSignedUrlsQueryVariables = Exact<{
|
||||
updateId: Scalars['ID']['input'];
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
@@ -12106,6 +12385,13 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
|
||||
|
||||
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
|
||||
export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, logFileUrls: Array<string> } | null } } };
|
||||
|
||||
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
|
||||
export const AscAppLinkMutation = {
|
||||
async createAppStoreConnectAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appStoreConnectAppInput: {
|
||||
appId: string;
|
||||
ascAppIdentifier: string;
|
||||
appStoreConnectApiKeyId: string;
|
||||
}
|
||||
): Promise<{ id: string; ascAppIdentifier: string }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<{
|
||||
appStoreConnectApp: {
|
||||
createAppStoreConnectApp: {
|
||||
id: string;
|
||||
ascAppIdentifier: string;
|
||||
};
|
||||
};
|
||||
}>(
|
||||
gql`
|
||||
mutation CreateAppStoreConnectApp($appStoreConnectAppInput: AppStoreConnectAppInput!) {
|
||||
appStoreConnectApp {
|
||||
createAppStoreConnectApp(appStoreConnectAppInput: $appStoreConnectAppInput) {
|
||||
id
|
||||
ascAppIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appStoreConnectAppInput }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.appStoreConnectApp.createAppStoreConnectApp;
|
||||
},
|
||||
|
||||
async deleteAppStoreConnectAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appStoreConnectAppId: string
|
||||
): Promise<void> {
|
||||
await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<{
|
||||
appStoreConnectApp: {
|
||||
deleteAppStoreConnectApp: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
}>(
|
||||
gql`
|
||||
mutation DeleteAppStoreConnectApp($appStoreConnectAppId: ID!) {
|
||||
appStoreConnectApp {
|
||||
deleteAppStoreConnectApp(appStoreConnectAppId: $appStoreConnectAppId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appStoreConnectAppId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
CreateDeviceRunSessionInput,
|
||||
CreateDeviceRunSessionMutation,
|
||||
CreateDeviceRunSessionMutationVariables,
|
||||
StopDeviceRunSessionMutation,
|
||||
StopDeviceRunSessionMutationVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const DeviceRunSessionMutation = {
|
||||
async createDeviceRunSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput
|
||||
): Promise<CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CreateDeviceRunSessionMutation, CreateDeviceRunSessionMutationVariables>(
|
||||
gql`
|
||||
mutation CreateDeviceRunSessionMutation($deviceRunSessionInput: CreateDeviceRunSessionInput!) {
|
||||
deviceRunSession {
|
||||
createDeviceRunSession(deviceRunSessionInput: $deviceRunSessionInput) {
|
||||
id
|
||||
status
|
||||
app {
|
||||
id
|
||||
slug
|
||||
ownerAccount {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionInput },
|
||||
{ noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSession.createDeviceRunSession;
|
||||
},
|
||||
async stopDeviceRunSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
): Promise<StopDeviceRunSessionMutation['deviceRunSession']['stopDeviceRunSession']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<StopDeviceRunSessionMutation, StopDeviceRunSessionMutationVariables>(
|
||||
gql`
|
||||
mutation StopDeviceRunSessionMutation($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
stopDeviceRunSession(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
{ noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSession.stopDeviceRunSession;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AscAppLinkAppMetadataQuery,
|
||||
AscAppLinkAppMetadataQueryVariables,
|
||||
DiscoverAccessibleAppStoreConnectAppsQuery,
|
||||
DiscoverAccessibleAppStoreConnectAppsQueryVariables,
|
||||
} from '../generated';
|
||||
import { AccountFragmentNode } from '../types/Account';
|
||||
|
||||
export const AscAppLinkQuery = {
|
||||
async getAppMetadataAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
}
|
||||
): Promise<AscAppLinkAppMetadataQuery['app']['byId']> {
|
||||
const useCache = options?.useCache ?? true;
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AscAppLinkAppMetadataQuery, AscAppLinkAppMetadataQueryVariables>(
|
||||
gql`
|
||||
query AscAppLinkAppMetadata($appId: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
fullName
|
||||
ownerAccount {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
appStoreConnectApp {
|
||||
id
|
||||
ascAppIdentifier
|
||||
remoteAppStoreConnectApp {
|
||||
ascAppIdentifier
|
||||
bundleIdentifier
|
||||
name
|
||||
appStoreIconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AccountFragmentNode)}
|
||||
`,
|
||||
{ appId },
|
||||
{
|
||||
requestPolicy: useCache ? 'cache-first' : 'network-only',
|
||||
additionalTypenames: ['App', 'AppStoreConnectApp'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId;
|
||||
},
|
||||
|
||||
async discoverAccessibleAppsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appStoreConnectApiKeyId: string,
|
||||
bundleIdentifier?: string
|
||||
): Promise<
|
||||
DiscoverAccessibleAppStoreConnectAppsQuery['appStoreConnectApiKey']['byId']['remoteAppStoreConnectApps']
|
||||
> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<
|
||||
DiscoverAccessibleAppStoreConnectAppsQuery,
|
||||
DiscoverAccessibleAppStoreConnectAppsQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query DiscoverAccessibleAppStoreConnectApps(
|
||||
$appStoreConnectApiKeyId: ID!
|
||||
$bundleIdentifier: String
|
||||
) {
|
||||
appStoreConnectApiKey {
|
||||
byId(id: $appStoreConnectApiKeyId) {
|
||||
id
|
||||
remoteAppStoreConnectApps(bundleIdentifier: $bundleIdentifier) {
|
||||
ascAppIdentifier
|
||||
bundleIdentifier
|
||||
name
|
||||
appStoreIconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appStoreConnectApiKeyId, bundleIdentifier },
|
||||
{ additionalTypenames: ['AppStoreConnectApp'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.appStoreConnectApiKey.byId?.remoteAppStoreConnectApps ?? [];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
|
||||
|
||||
export const DeviceRunSessionQuery = {
|
||||
async byIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
): Promise<DeviceRunSessionByIdQuery['deviceRunSessions']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
turtleJobRun {
|
||||
id
|
||||
status
|
||||
logFileUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../utils';
|
||||
|
||||
const mockMetadataConnected = {
|
||||
id: 'app-id',
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: 'app-id',
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe('buildJsonOutput', () => {
|
||||
it('returns connected output', () => {
|
||||
const output = buildJsonOutput('status', mockMetadataConnected);
|
||||
expect(output.action).toBe('status');
|
||||
expect(output.project).toBe('@testuser/testapp');
|
||||
expect(output.status).toBe('connected');
|
||||
expect(output.appStoreConnectApp).not.toBeNull();
|
||||
expect(output.appStoreConnectApp!.ascAppIdentifier).toBe('1234567890');
|
||||
expect(output.appStoreConnectApp!.name).toBe('Test App');
|
||||
expect(output.appStoreConnectApp!.bundleIdentifier).toBe('com.test.app');
|
||||
});
|
||||
|
||||
it('returns disconnected output', () => {
|
||||
const output = buildJsonOutput('status', mockMetadataDisconnected);
|
||||
expect(output.status).toBe('not-connected');
|
||||
expect(output.appStoreConnectApp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInvalidJsonOutput', () => {
|
||||
it('returns invalid output', () => {
|
||||
const output = buildInvalidJsonOutput('status', 'project-id');
|
||||
expect(output.status).toBe('invalid');
|
||||
expect(output.project).toBe('project-id');
|
||||
expect(output.appStoreConnectApp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAscAuthenticationError', () => {
|
||||
it('returns true for ASC authentication errors', () => {
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
expect(isAscAuthenticationError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for other CombinedErrors', () => {
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [new GraphQLError('Some other error')],
|
||||
});
|
||||
expect(isAscAuthenticationError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-CombinedError errors', () => {
|
||||
expect(isAscAuthenticationError(new Error('random error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAscAppLinkStatus', () => {
|
||||
it('formats connected status', () => {
|
||||
const status = formatAscAppLinkStatus(mockMetadataConnected);
|
||||
expect(status).toContain('Connected');
|
||||
expect(status).toContain('1234567890');
|
||||
expect(status).toContain('Test App');
|
||||
expect(status).toContain('com.test.app');
|
||||
});
|
||||
|
||||
it('formats disconnected status', () => {
|
||||
const status = formatAscAppLinkStatus(mockMetadataDisconnected);
|
||||
expect(status).toContain('Not connected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
formatAscApiKey,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
sortAscApiKeysByUpdatedAtDesc,
|
||||
} from '../../credentials/ios/actions/AscApiKeyUtils';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../graphql/generated';
|
||||
|
||||
export async function selectOrCreateAscApiKeyIdAsync({
|
||||
credentialsContext,
|
||||
existingKeys,
|
||||
ownerAccount,
|
||||
}: {
|
||||
credentialsContext: CredentialsContext;
|
||||
existingKeys: AppStoreConnectApiKeyFragment[];
|
||||
ownerAccount: AccountFragment;
|
||||
}): Promise<string> {
|
||||
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys);
|
||||
const createKeyOption = {
|
||||
title: '[Create or upload a new API key]',
|
||||
value: '__create_new_key__',
|
||||
};
|
||||
|
||||
const selectedValue =
|
||||
sortedKeys.length === 0
|
||||
? createKeyOption.value
|
||||
: await selectAsync<string>('Select an App Store Connect API key:', [
|
||||
...sortedKeys.map(key => ({
|
||||
title: formatAscApiKey(key),
|
||||
value: key.id,
|
||||
})),
|
||||
createKeyOption,
|
||||
]);
|
||||
|
||||
if (selectedValue !== createKeyOption.value) {
|
||||
return selectedValue;
|
||||
}
|
||||
|
||||
const newKey = await credentialsContext.ios.createAscApiKeyAsync(
|
||||
credentialsContext.graphqlClient,
|
||||
ownerAccount,
|
||||
await provideOrGenerateAscApiKeyAsync(
|
||||
credentialsContext,
|
||||
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
|
||||
)
|
||||
);
|
||||
return newKey.id;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AscAppLinkQuery } from '../../graphql/queries/AscAppLinkQuery';
|
||||
|
||||
type AscAppLinkMetadata = Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>>;
|
||||
|
||||
export type AscAppLinkStatus = 'not-connected' | 'connected' | 'invalid';
|
||||
|
||||
export interface AscAppLinkJsonOutput {
|
||||
action: string;
|
||||
project: string;
|
||||
status: AscAppLinkStatus;
|
||||
appStoreConnectApp: {
|
||||
id: string;
|
||||
ascAppIdentifier: string;
|
||||
name: string | null;
|
||||
bundleIdentifier: string | null;
|
||||
appleUrl: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function buildJsonOutput(
|
||||
action: string,
|
||||
metadata: AscAppLinkMetadata
|
||||
): AscAppLinkJsonOutput {
|
||||
const link = metadata.appStoreConnectApp;
|
||||
return {
|
||||
action,
|
||||
project: metadata.fullName,
|
||||
status: link ? 'connected' : 'not-connected',
|
||||
appStoreConnectApp: link
|
||||
? {
|
||||
id: link.id,
|
||||
ascAppIdentifier: link.ascAppIdentifier,
|
||||
name: link.remoteAppStoreConnectApp.name ?? null,
|
||||
bundleIdentifier: link.remoteAppStoreConnectApp.bundleIdentifier ?? null,
|
||||
appleUrl: getAppleAppUrl(link.ascAppIdentifier),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInvalidJsonOutput(action: string, projectId: string): AscAppLinkJsonOutput {
|
||||
return {
|
||||
action,
|
||||
project: projectId,
|
||||
status: 'invalid',
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAscAuthenticationError(error: unknown): error is CombinedError {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(e => e.message.includes('App Store Connect rejected this API key'))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatAscAppLinkStatus(metadata: AscAppLinkMetadata): string {
|
||||
const link = metadata.appStoreConnectApp;
|
||||
if (!link) {
|
||||
return `Project ${chalk.bold(metadata.fullName)}: ${chalk.yellow('Not connected')} to App Store Connect.`;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Project ${chalk.bold(metadata.fullName)}: ${chalk.green('Connected')} to App Store Connect.`,
|
||||
` ASC App ID: ${chalk.bold(link.ascAppIdentifier)}`,
|
||||
];
|
||||
|
||||
const remote = link.remoteAppStoreConnectApp;
|
||||
lines.push(` Name: ${remote.name}`);
|
||||
lines.push(` Bundle ID: ${remote.bundleIdentifier}`);
|
||||
lines.push(` Apple URL: ${getAppleAppUrl(link.ascAppIdentifier)}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getAppleAppUrl(ascAppIdentifier: string): string {
|
||||
return `https://appstoreconnect.apple.com/apps/${encodeURIComponent(ascAppIdentifier)}/distribution`;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function wrapFetchWithProgress(): (
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFileWithProgressTrackerAsync(
|
||||
export async function downloadFileWithProgressTrackerAsync(
|
||||
url: string,
|
||||
outputPath: string,
|
||||
progressTrackerMessage: string | ((ratio: number, total: number) => string),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.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": "18.8.0",
|
||||
"@expo/build-tools": "18.9.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: 'src',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,5 @@
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error("NODE_ENV environment variable must be set to 'test'.");
|
||||
}
|
||||
|
||||
// Always mock:
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@expo/lockfile-utils",
|
||||
"version": "18.0.1",
|
||||
"description": "Parse npm, yarn, pnpm, and bun lockfiles and validate them against package.json for version mismatches and peer dependency conflicts",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/lockfile-utils"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/semver": "^7.5.8",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
parseLockfile,
|
||||
parseNpmLock,
|
||||
parseYarnLock,
|
||||
parsePnpmLock,
|
||||
parseBunLock,
|
||||
} from '../parsers';
|
||||
|
||||
const NPM_LOCKFILE = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.4' },
|
||||
'node_modules/react-native': { version: '0.79.4' },
|
||||
'node_modules/react/node_modules/loose-envify': { version: '1.4.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const NPM_LOCKFILE_WITH_PEERS = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.0' },
|
||||
'node_modules/react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
'node_modules/@0no-co/graphql.web': {
|
||||
version: '1.2.0',
|
||||
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
|
||||
peerDependenciesMeta: { graphql: { optional: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const YARN_LOCKFILE = `# yarn lockfile v1
|
||||
|
||||
[email protected]:
|
||||
version "19.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
|
||||
|
||||
react-native@~0.79.0:
|
||||
version "0.79.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
|
||||
`;
|
||||
|
||||
const BUN_LOCKFILE = `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"react": "19.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
|
||||
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-ghi"],
|
||||
},
|
||||
}`;
|
||||
|
||||
const PNPM_LOCKFILE = `lockfileVersion: '9.0'
|
||||
|
||||
packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-abc}
|
||||
@types/[email protected]:
|
||||
resolution: {integrity: sha512-def}
|
||||
`;
|
||||
|
||||
describe('parseNpmLock', () => {
|
||||
it('parses top-level packages', () => {
|
||||
const result = parseNpmLock(NPM_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
|
||||
});
|
||||
|
||||
it('skips nested node_modules (transitive deps)', () => {
|
||||
const result = parseNpmLock(NPM_LOCKFILE);
|
||||
expect(result!.packages.has('loose-envify')).toBe(false);
|
||||
});
|
||||
|
||||
it('parses peer dependencies and optional peers', () => {
|
||||
const result = parseNpmLock(NPM_LOCKFILE_WITH_PEERS);
|
||||
const reactDom = result!.packages.get('react-dom');
|
||||
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
|
||||
expect(reactDom?.optionalPeers).toBeUndefined();
|
||||
|
||||
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
|
||||
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
expect(parseNpmLock('not json')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseYarnLock', () => {
|
||||
it('parses package versions', () => {
|
||||
const result = parseYarnLock(YARN_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePnpmLock', () => {
|
||||
it('parses package versions including scoped packages', () => {
|
||||
const result = parsePnpmLock(PNPM_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
expect(result!.packages.get('@types/react')?.version).toBe('19.0.4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBunLock', () => {
|
||||
it('parses package versions from JSONC format', () => {
|
||||
const result = parseBunLock(BUN_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
});
|
||||
|
||||
it('parses peer dependencies and optional peers', () => {
|
||||
const result = parseBunLock(BUN_LOCKFILE);
|
||||
const reactDom = result!.packages.get('react-dom');
|
||||
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
|
||||
|
||||
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
|
||||
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for invalid content', () => {
|
||||
expect(parseBunLock('not json at all {')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLockfile', () => {
|
||||
it('dispatches to the correct parser by manager', () => {
|
||||
expect(parseLockfile(NPM_LOCKFILE, 'npm', 'package-lock.json')).not.toBeNull();
|
||||
expect(parseLockfile(YARN_LOCKFILE, 'yarn', 'yarn.lock')).not.toBeNull();
|
||||
expect(parseLockfile(PNPM_LOCKFILE, 'pnpm', 'pnpm-lock.yaml')).not.toBeNull();
|
||||
expect(parseLockfile(BUN_LOCKFILE, 'bun', 'bun.lock')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for binary bun.lockb', () => {
|
||||
expect(parseLockfile('binary', 'bun', 'bun.lockb')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown manager', () => {
|
||||
expect(parseLockfile('{}', 'deno', 'deno.lock')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { findLockfileMismatches, findPeerDependencyConflicts } from '../validators';
|
||||
import { LockfileData, PackageInfo } from '../types';
|
||||
|
||||
function makeLockfileData(packages: Record<string, PackageInfo>): LockfileData {
|
||||
return { packages: new Map(Object.entries(packages)) };
|
||||
}
|
||||
|
||||
describe('findLockfileMismatches', () => {
|
||||
it('returns empty when lockfile is in sync', () => {
|
||||
const packageJson = { dependencies: { react: '19.0.4', 'react-native': '~0.79.0' } };
|
||||
const lockfile = makeLockfileData({
|
||||
react: { version: '19.0.4' },
|
||||
'react-native': { version: '0.79.4' },
|
||||
});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects version mismatch', () => {
|
||||
const packageJson = { dependencies: { react: '19.0.4' } };
|
||||
const lockfile = makeLockfileData({ react: { version: '19.0.0' } });
|
||||
const mismatches = findLockfileMismatches(packageJson, lockfile);
|
||||
expect(mismatches).toHaveLength(1);
|
||||
expect(mismatches[0]).toEqual({
|
||||
name: 'react',
|
||||
specifier: '19.0.4',
|
||||
lockedVersion: '19.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks devDependencies and optionalDependencies', () => {
|
||||
const packageJson = {
|
||||
devDependencies: { typescript: '^5.5.0' },
|
||||
optionalDependencies: { fsevents: '^2.3.0' },
|
||||
};
|
||||
const lockfile = makeLockfileData({
|
||||
typescript: { version: '5.5.4' },
|
||||
fsevents: { version: '2.3.3' },
|
||||
});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips non-registry specifiers', () => {
|
||||
const packageJson = {
|
||||
dependencies: {
|
||||
'my-local': 'file:../local',
|
||||
'my-workspace': 'workspace:*',
|
||||
'my-git': 'git+https://github.com/foo/bar.git',
|
||||
'my-github': 'github:foo/bar',
|
||||
'my-latest': 'latest',
|
||||
},
|
||||
};
|
||||
const lockfile = makeLockfileData({});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips packages not found in lockfile', () => {
|
||||
const packageJson = { dependencies: { 'new-dep': '^1.0.0' } };
|
||||
const lockfile = makeLockfileData({});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPeerDependencyConflicts', () => {
|
||||
it('returns empty when peers are satisfied', () => {
|
||||
const packageJson = { dependencies: { react: '19.2.4', 'react-dom': '19.2.4' } };
|
||||
const lockfile = makeLockfileData({
|
||||
react: { version: '19.2.4' },
|
||||
'react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
});
|
||||
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects unsatisfied peer dependency', () => {
|
||||
const packageJson = { dependencies: { react: '19.0.0', 'react-dom': '19.2.4' } };
|
||||
const lockfile = makeLockfileData({
|
||||
react: { version: '19.0.0' },
|
||||
'react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
});
|
||||
const conflicts = findPeerDependencyConflicts(packageJson, lockfile);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0]).toEqual({
|
||||
source: 'react-dom',
|
||||
sourceVersion: '19.2.4',
|
||||
peer: 'react',
|
||||
peerRange: '^19.2.4',
|
||||
installedVersion: '19.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips optional peers', () => {
|
||||
const packageJson = { dependencies: { '@0no-co/graphql.web': '1.2.0' } };
|
||||
const lockfile = makeLockfileData({
|
||||
'@0no-co/graphql.web': {
|
||||
version: '1.2.0',
|
||||
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
|
||||
optionalPeers: new Set(['graphql']),
|
||||
},
|
||||
});
|
||||
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips peers not installed in lockfile', () => {
|
||||
const packageJson = { dependencies: { 'react-dom': '19.2.4' } };
|
||||
const lockfile = makeLockfileData({
|
||||
'react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
});
|
||||
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { parseLockfile, parseNpmLock, parseYarnLock, parsePnpmLock, parseBunLock } from './parsers';
|
||||
export { findLockfileMismatches, findPeerDependencyConflicts } from './validators';
|
||||
export type {
|
||||
PackageInfo,
|
||||
LockfileData,
|
||||
DependencyMismatch,
|
||||
PeerDependencyConflict,
|
||||
} from './types';
|
||||
@@ -0,0 +1,154 @@
|
||||
import { LockfileData, PackageInfo } from './types';
|
||||
|
||||
export function parseNpmLock(content: string): LockfileData | null {
|
||||
try {
|
||||
const lockfile = JSON.parse(content);
|
||||
const rawPackages: Record<
|
||||
string,
|
||||
{
|
||||
version?: string;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
}
|
||||
> = lockfile.packages ?? {};
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
for (const [key, value] of Object.entries(rawPackages)) {
|
||||
if (!key.startsWith('node_modules/')) {
|
||||
continue;
|
||||
}
|
||||
const rest = key.slice('node_modules/'.length);
|
||||
// Skip nested node_modules (transitive deps)
|
||||
if (rest.includes('node_modules/')) {
|
||||
continue;
|
||||
}
|
||||
if (value.version) {
|
||||
const optionalPeers = new Set<string>();
|
||||
if (value.peerDependenciesMeta) {
|
||||
for (const [peer, meta] of Object.entries(value.peerDependenciesMeta)) {
|
||||
if (meta.optional) {
|
||||
optionalPeers.add(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
packages.set(rest, {
|
||||
version: value.version,
|
||||
peerDependencies: value.peerDependencies,
|
||||
optionalPeers: optionalPeers.size > 0 ? optionalPeers : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseYarnLock(content: string): LockfileData | null {
|
||||
try {
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
const lines = content.split('\n');
|
||||
let currentPackage: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Entry header: not indented, not a comment, ends with ':'
|
||||
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':')) {
|
||||
// Examples:
|
||||
// yarn v1: react@^19.0.0: or "react@^19.0.0":
|
||||
// yarn v2+: "react@npm:^19.0.0":
|
||||
const entry = line.replace(/^"/, '').replace(/:$/, '').replace(/"$/, '');
|
||||
const atIndex = entry.indexOf('@', entry.startsWith('@') ? 1 : 0);
|
||||
if (atIndex > 0) {
|
||||
currentPackage = entry.slice(0, atIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Version line (indented): ` version "19.0.0"` or ` version: 19.0.0`
|
||||
if (currentPackage) {
|
||||
const versionMatch = line.match(/^\s+version:?\s+"?([^"\s]+)"?/);
|
||||
if (versionMatch) {
|
||||
if (!packages.has(currentPackage)) {
|
||||
packages.set(currentPackage, { version: versionMatch[1] });
|
||||
}
|
||||
currentPackage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// yarn.lock doesn't include peer dependency info
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePnpmLock(content: string): LockfileData | null {
|
||||
try {
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
// Match entries in the packages section like:
|
||||
// [email protected]: or @types/[email protected]:
|
||||
const packageRegex = /^ {2}((?:@[\w.-]+\/)?[\w.-]+)@(\d+[^:\s]*?):/gm;
|
||||
let match;
|
||||
while ((match = packageRegex.exec(content)) !== null) {
|
||||
if (!packages.has(match[1])) {
|
||||
packages.set(match[1], { version: match[2] });
|
||||
}
|
||||
}
|
||||
// pnpm-lock.yaml doesn't readily expose peer deps in a parseable way without YAML
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBunLock(content: string): LockfileData | null {
|
||||
try {
|
||||
// bun.lock is JSONC (has trailing commas) — strip them for JSON.parse
|
||||
const jsonContent = content.replace(/,(\s*[}\]])/g, '$1');
|
||||
const lockfile = JSON.parse(jsonContent);
|
||||
const rawPackages: Record<string, unknown[]> = lockfile.packages ?? {};
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
for (const [name, value] of Object.entries(rawPackages)) {
|
||||
// Format: "react": ["[email protected]", "", { peerDependencies: {...}, optionalPeers: [...] }, "sha512-..."]
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||
const entry: string = value[0];
|
||||
const atIndex = entry.lastIndexOf('@');
|
||||
if (atIndex > 0) {
|
||||
const meta = (typeof value[2] === 'object' && value[2] !== null ? value[2] : {}) as {
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalPeers?: string[];
|
||||
};
|
||||
packages.set(name, {
|
||||
version: entry.slice(atIndex + 1),
|
||||
peerDependencies: meta.peerDependencies,
|
||||
optionalPeers: meta.optionalPeers ? new Set(meta.optionalPeers) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLockfile(
|
||||
content: string,
|
||||
manager: string,
|
||||
lockfilePath: string
|
||||
): LockfileData | null {
|
||||
switch (manager) {
|
||||
case 'npm':
|
||||
return parseNpmLock(content);
|
||||
case 'yarn':
|
||||
return parseYarnLock(content);
|
||||
case 'pnpm':
|
||||
return parsePnpmLock(content);
|
||||
case 'bun':
|
||||
// bun.lockb is binary — can only parse the text variant
|
||||
if (lockfilePath.endsWith('.lockb')) {
|
||||
return null;
|
||||
}
|
||||
return parseBunLock(content);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface PackageInfo {
|
||||
version: string;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalPeers?: Set<string>;
|
||||
}
|
||||
|
||||
export interface LockfileData {
|
||||
packages: Map<string, PackageInfo>;
|
||||
}
|
||||
|
||||
export interface DependencyMismatch {
|
||||
name: string;
|
||||
specifier: string;
|
||||
lockedVersion: string;
|
||||
}
|
||||
|
||||
export interface PeerDependencyConflict {
|
||||
/** Package that declares the peer dependency */
|
||||
source: string;
|
||||
sourceVersion: string;
|
||||
/** The peer dependency that is not satisfied */
|
||||
peer: string;
|
||||
peerRange: string;
|
||||
/** The actual version of the peer in the lockfile */
|
||||
installedVersion: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import semver from 'semver';
|
||||
|
||||
import { DependencyMismatch, LockfileData, PeerDependencyConflict } from './types';
|
||||
|
||||
function isNonRegistrySpecifier(specifier: string): boolean {
|
||||
return (
|
||||
specifier.startsWith('file:') ||
|
||||
specifier.startsWith('link:') ||
|
||||
specifier.startsWith('workspace:') ||
|
||||
specifier.startsWith('git:') ||
|
||||
specifier.startsWith('git+') ||
|
||||
specifier.startsWith('github:') ||
|
||||
specifier.startsWith('http:') ||
|
||||
specifier.startsWith('https:') ||
|
||||
specifier.startsWith('portal:') ||
|
||||
specifier === '*' ||
|
||||
specifier === 'latest' ||
|
||||
specifier === 'next'
|
||||
);
|
||||
}
|
||||
|
||||
export function findLockfileMismatches(
|
||||
packageJson: Record<string, unknown>,
|
||||
lockfileData: LockfileData
|
||||
): DependencyMismatch[] {
|
||||
const allDeps: Record<string, string> = {
|
||||
...(packageJson.dependencies as Record<string, string> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, string> | undefined),
|
||||
...(packageJson.optionalDependencies as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
const mismatches: DependencyMismatch[] = [];
|
||||
for (const [name, specifier] of Object.entries(allDeps)) {
|
||||
if (isNonRegistrySpecifier(specifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pkg = lockfileData.packages.get(name);
|
||||
if (!pkg) {
|
||||
continue; // Package not found in lockfile — may be a new dep or hoisted differently
|
||||
}
|
||||
|
||||
if (!semver.satisfies(pkg.version, specifier)) {
|
||||
mismatches.push({ name, specifier, lockedVersion: pkg.version });
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
export function findPeerDependencyConflicts(
|
||||
packageJson: Record<string, unknown>,
|
||||
lockfileData: LockfileData
|
||||
): PeerDependencyConflict[] {
|
||||
// Check peer deps of direct dependencies only (most common source of build failures)
|
||||
const directDeps: Record<string, string> = {
|
||||
...(packageJson.dependencies as Record<string, string> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
const conflicts: PeerDependencyConflict[] = [];
|
||||
for (const depName of Object.keys(directDeps)) {
|
||||
const pkg = lockfileData.packages.get(depName);
|
||||
if (!pkg?.peerDependencies) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [peer, peerRange] of Object.entries(pkg.peerDependencies)) {
|
||||
// Skip optional peers
|
||||
if (pkg.optionalPeers?.has(peer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerPkg = lockfileData.packages.get(peer);
|
||||
if (!peerPkg) {
|
||||
continue; // Peer not installed — might be optional or provided by a parent
|
||||
}
|
||||
|
||||
if (!semver.satisfies(peerPkg.version, peerRange)) {
|
||||
conflicts.push({
|
||||
source: depName,
|
||||
sourceVersion: pkg.version,
|
||||
peer,
|
||||
peerRange,
|
||||
installedVersion: peerPkg.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
|
||||
import { interpolateJobContext } from '../interpolation';
|
||||
|
||||
describe(interpolateJobContext, () => {
|
||||
it('interpolates multiple ${{ }} segments when the string starts with ${{ and ends with }}', () => {
|
||||
const context = {
|
||||
github: {
|
||||
event: {
|
||||
pull_request: {
|
||||
html_url: 'https://github.com/acme/app/pull/42',
|
||||
title: 'Fix crash',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as JobInterpolationContext;
|
||||
|
||||
expect(
|
||||
interpolateJobContext({
|
||||
target: '${{ github.event.pull_request.html_url }}: ${{ github.event.pull_request.title }}',
|
||||
context,
|
||||
})
|
||||
).toBe('https://github.com/acme/app/pull/42: Fix crash');
|
||||
});
|
||||
|
||||
it('still preserves non-string types for a single whole-string interpolation', () => {
|
||||
const context = {
|
||||
build: { profile: 'production', id: '123' },
|
||||
} as unknown as JobInterpolationContext;
|
||||
|
||||
expect(
|
||||
interpolateJobContext({
|
||||
target: '${{ build }}',
|
||||
context,
|
||||
})
|
||||
).toEqual({ profile: 'production', id: '123' });
|
||||
});
|
||||
|
||||
it("handles a single interpolation when the expression contains '}}' in a string literal", () => {
|
||||
const context = {
|
||||
build: { profile: 'production}}' },
|
||||
} as unknown as JobInterpolationContext;
|
||||
|
||||
expect(
|
||||
interpolateJobContext({
|
||||
target: "${{ build.profile == 'production}}' }}",
|
||||
context,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
|
||||
import { jsepEval } from './utils/jsepEval';
|
||||
|
||||
function isSingleWholeStringInterpolation(value: string): boolean {
|
||||
return value.startsWith('${{') && value.endsWith('}}') && !value.includes('${{', 3);
|
||||
}
|
||||
|
||||
export function interpolateJobContext({
|
||||
target,
|
||||
context,
|
||||
@@ -12,7 +16,7 @@ export function interpolateJobContext({
|
||||
if (typeof target === 'string') {
|
||||
// If the value is e.g. `build: ${{ inputs.build }}`, we will interpolate the value
|
||||
// without changing `inputs.build` type, i.e. if it is an object it'll be like `build: {...inputs.build}`.
|
||||
if (target.startsWith('${{') && target.endsWith('}}')) {
|
||||
if (isSingleWholeStringInterpolation(target)) {
|
||||
return jsepEval(target.slice(3, -2), context);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) {
|
||||
|
||||
// Always mock:
|
||||
jest.mock('../src/CacheManager');
|
||||
jest.mock('../src/utils/turtleFetch');
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.8.0",
|
||||
"version": "18.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.8.0",
|
||||
"@expo/build-tools": "18.9.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "18.5.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/steps": "18.9.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -70,6 +70,7 @@ jest.mock('../config', () => {
|
||||
default: {
|
||||
...config.default,
|
||||
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
|
||||
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ jest.mock('../config', () => {
|
||||
return {
|
||||
...config,
|
||||
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
|
||||
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ jest.mock('../config', () => {
|
||||
return {
|
||||
...config,
|
||||
buildId: 'f38532aa-81a8-4db7-915f-6e7afe46e22f',
|
||||
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ jest.mock('../config', () => {
|
||||
return {
|
||||
...config,
|
||||
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
|
||||
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
|
||||
};
|
||||
});
|
||||
jest.mock('../upload', () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ describe(getBuildEnv.name, () => {
|
||||
expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn);
|
||||
});
|
||||
|
||||
it('passes through EXPO_USE_PRECOMPILED_MODULES for flagged iOS jobs', () => {
|
||||
it('does not add precompiled modules env vars to flagged iOS jobs', () => {
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
@@ -54,7 +54,9 @@ describe(getBuildEnv.name, () => {
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
|
||||
expect(env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBeUndefined();
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
Platform,
|
||||
Workflow,
|
||||
errors,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { build } from '../build';
|
||||
import { createBuildContext } from '../context';
|
||||
import BuildService, { getExpoPackageVersionAsync } from '../service';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../build', () => ({
|
||||
build: jest.fn(),
|
||||
}));
|
||||
jest.mock('../context', () => ({
|
||||
createBuildContext: jest.fn(),
|
||||
}));
|
||||
jest.mock('../external/analytics', () => ({
|
||||
Analytics: jest.fn().mockImplementation(() => ({
|
||||
flushEventsAsync: jest.fn(),
|
||||
logEvent: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../logger', () => {
|
||||
const logger = {
|
||||
child: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
level: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
logger.child.mockReturnValue(logger);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: logger,
|
||||
createBuildLoggerWithSecretsFilter: jest.fn(async () => ({
|
||||
cleanUp: jest.fn(),
|
||||
logBuffer: { getLogs: jest.fn(() => []), getPhaseLogs: jest.fn(() => []) },
|
||||
logger,
|
||||
})),
|
||||
};
|
||||
});
|
||||
jest.mock('../sentry', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../utils/turtleFetch', () => ({
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
jest.mock('../config', () => {
|
||||
const config = jest.requireActual('../config').default;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
...config,
|
||||
buildId: 'build-id',
|
||||
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe(getExpoPackageVersionAsync, () => {
|
||||
const projectRoot = '/test-project';
|
||||
const expoPackageJsonPath = path.join(projectRoot, 'node_modules/expo/package.json');
|
||||
const buildContext = {
|
||||
env: { PATH: '/usr/bin' },
|
||||
getReactNativeProjectDirectory: () => projectRoot,
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the exact installed expo package version', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: Buffer.from(expoPackageJsonPath) } as any);
|
||||
vol.fromJSON({
|
||||
[expoPackageJsonPath]: JSON.stringify({ version: '55.0.17' }),
|
||||
});
|
||||
|
||||
await expect(getExpoPackageVersionAsync(buildContext)).resolves.toBe('55.0.17');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['--print', "require.resolve('expo/package.json')"],
|
||||
expect.objectContaining({
|
||||
cwd: projectRoot,
|
||||
env: buildContext.env,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a user error when expo package version resolution fails', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
|
||||
|
||||
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toMatchObject({
|
||||
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
|
||||
});
|
||||
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
|
||||
});
|
||||
|
||||
it('throws a user error when the installed expo package version is not valid semver', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: Buffer.from(expoPackageJsonPath) } as any);
|
||||
vol.fromJSON({
|
||||
[expoPackageJsonPath]: JSON.stringify({ version: 'invalid-version' }),
|
||||
});
|
||||
|
||||
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toMatchObject({
|
||||
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
|
||||
});
|
||||
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(BuildService, () => {
|
||||
const projectRoot = '/test-project';
|
||||
const job = {
|
||||
appId: 'app-id',
|
||||
cache: {
|
||||
clear: false,
|
||||
disabled: false,
|
||||
paths: [],
|
||||
},
|
||||
mode: BuildMode.BUILD,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://example.com/project.tar.gz',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
robotAccessToken: 'robot-token',
|
||||
},
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
type: Workflow.GENERIC,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(path.join(projectRoot, 'node_modules/expo/package.json')),
|
||||
} as any);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as any);
|
||||
jest.mocked(build).mockRejectedValue(new Error('raw build failure'));
|
||||
jest.mocked(createBuildContext).mockReturnValue({
|
||||
env: { PATH: '/usr/bin' },
|
||||
getReactNativeProjectDirectory: () => projectRoot,
|
||||
job,
|
||||
logger: {},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('sends expo_package_version to Datadog error logs without changing sdk_version', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(projectRoot, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: '55.0.17',
|
||||
}),
|
||||
});
|
||||
const service = new BuildService();
|
||||
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).startBuildInternal({
|
||||
initiatingUserId: 'user-id',
|
||||
job,
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
reactNativeVersion: '0.83.0',
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
projectId: 'project-id',
|
||||
});
|
||||
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://api.expo.test/v2/turtle-builds/error-logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
expo_package_version: '55.0.17',
|
||||
sdk_version: '55.0.0',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends null expo_package_version to Datadog error logs when expo package version resolution fails', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
|
||||
const service = new BuildService();
|
||||
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).startBuildInternal({
|
||||
initiatingUserId: 'user-id',
|
||||
job,
|
||||
metadata: {
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
projectId: 'project-id',
|
||||
});
|
||||
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://api.expo.test/v2/turtle-builds/error-logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
expo_package_version: null,
|
||||
sdk_version: '55.0.0',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not try to resolve expo_package_version when build context setup fails', async () => {
|
||||
jest.mocked(createBuildContext).mockImplementation(() => {
|
||||
throw new Error('context setup failure');
|
||||
});
|
||||
const service = new BuildService();
|
||||
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).startBuildInternal({
|
||||
initiatingUserId: 'user-id',
|
||||
job,
|
||||
metadata: {
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
projectId: 'project-id',
|
||||
});
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://api.expo.test/v2/turtle-builds/error-logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
expo_package_version: null,
|
||||
sdk_version: '55.0.0',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,9 +53,6 @@ export function getBuildEnv({
|
||||
if (runnerPlatform === Platform.IOS) {
|
||||
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
|
||||
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
|
||||
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
|
||||
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
|
||||
}
|
||||
|
||||
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
|
||||
setEnv(env, 'USE_CCACHE', '1');
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
errors,
|
||||
} from '@expo/eas-build-job';
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import { setTimeout as setTimeoutAsync } from 'timers/promises';
|
||||
|
||||
import { build } from './build';
|
||||
@@ -332,7 +335,12 @@ export default class BuildService {
|
||||
});
|
||||
|
||||
const robotAccessToken = job.secrets?.robotAccessToken;
|
||||
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
|
||||
if (robotAccessToken) {
|
||||
const expoPackageVersionResult = this.buildContext
|
||||
? await asyncResult(getExpoPackageVersionAsync(this.buildContext))
|
||||
: null;
|
||||
const expoPackageVersion =
|
||||
expoPackageVersionResult?.ok === true ? expoPackageVersionResult.value : null;
|
||||
let rawErrorMessage: string = '';
|
||||
if (maybeRawError?.stderr) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
|
||||
@@ -340,23 +348,43 @@ export default class BuildService {
|
||||
if (maybeRawError?.stdout) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
|
||||
}
|
||||
if (!rawErrorMessage) {
|
||||
rawErrorMessage = maybeRawError?.message ?? err.message;
|
||||
}
|
||||
|
||||
await turtleFetch(
|
||||
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
buildId: this.buildId,
|
||||
message: rawErrorMessage,
|
||||
buildPhase: err.buildPhase ?? null,
|
||||
errorCode: err.errorCode,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
);
|
||||
try {
|
||||
await turtleFetch(
|
||||
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
buildId: this.buildId,
|
||||
message: rawErrorMessage,
|
||||
buildPhase: err.buildPhase ?? null,
|
||||
errorCode: err.errorCode,
|
||||
tags: {
|
||||
platform: job.platform,
|
||||
workflow: job.type,
|
||||
sdk_version: metadata?.sdkVersion ?? null,
|
||||
expo_package_version: expoPackageVersion,
|
||||
react_native_version: metadata?.reactNativeVersion ?? null,
|
||||
app_id: job.appId ?? null,
|
||||
build_profile: metadata?.buildProfile ?? null,
|
||||
app_name: metadata?.appName ?? null,
|
||||
app_identifier: metadata?.appIdentifier ?? null,
|
||||
distribution: metadata?.distribution ?? null,
|
||||
cli_version: metadata?.cliVersion ?? null,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
);
|
||||
} catch (fetchError: any) {
|
||||
logger.warn({ err: fetchError }, 'Failed to send build error log');
|
||||
}
|
||||
}
|
||||
|
||||
await this.finishError(err, maybeArtifacts);
|
||||
@@ -382,3 +410,41 @@ function getLastNLines(numberOfLines: number, stream: string): string {
|
||||
return lines.slice(lines.length - numberOfLines, lines.length).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExpoPackageVersionAsync(ctx: BuildContext<Job>): Promise<string> {
|
||||
const reactNativeProjectDirectory = ctx.getReactNativeProjectDirectory();
|
||||
const expoPackageJsonPathResult = await asyncResult(
|
||||
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: reactNativeProjectDirectory,
|
||||
env: ctx.env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
if (!expoPackageJsonPathResult.ok) {
|
||||
throw new errors.UserError(
|
||||
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
|
||||
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
|
||||
{ cause: expoPackageJsonPathResult.reason }
|
||||
);
|
||||
}
|
||||
|
||||
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
|
||||
const expoPackageJsonResult = await asyncResult(fs.readJson(expoPackageJsonPath));
|
||||
if (!expoPackageJsonResult.ok) {
|
||||
throw new errors.UserError(
|
||||
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
|
||||
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
|
||||
{ cause: expoPackageJsonResult.reason }
|
||||
);
|
||||
}
|
||||
|
||||
const expoPackageVersion = expoPackageJsonResult.value.version;
|
||||
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
|
||||
throw new errors.UserError(
|
||||
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
|
||||
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
|
||||
);
|
||||
}
|
||||
|
||||
return expoPackageVersion;
|
||||
}
|
||||
@@ -2856,7 +2856,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:18.8.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:18.9.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
@@ -2871,7 +2871,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.4.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.8.0"
|
||||
"@expo/steps": "npm:18.9.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -3270,6 +3270,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/lockfile-utils@npm:18.0.1, @expo/lockfile-utils@workspace:packages/lockfile-utils":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/lockfile-utils@workspace:packages/lockfile-utils"
|
||||
dependencies:
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
"@types/semver": "npm:^7.5.8"
|
||||
jest: "npm:^29.7.0"
|
||||
semver: "npm:^7.6.0"
|
||||
ts-jest: "npm:^29.1.4"
|
||||
typescript: "npm:^5.5.4"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/logger@npm:18.0.1, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:18.5.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
@@ -3481,7 +3496,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:18.8.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:18.9.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
@@ -3554,7 +3569,7 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:18.8.0"
|
||||
"@expo/build-tools": "npm:18.9.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
@@ -3563,7 +3578,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:18.8.0"
|
||||
"@expo/steps": "npm:18.9.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -10331,7 +10346,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:18.8.0"
|
||||
"@expo/steps": "npm:18.9.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10944,7 +10959,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:18.8.0"
|
||||
"@expo/build-tools": "npm:18.9.0"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
@@ -11006,6 +11021,7 @@ __metadata:
|
||||
"@expo/eas-json": "npm:18.8.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/lockfile-utils": "npm:18.0.1"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
@@ -11018,7 +11034,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:18.8.0"
|
||||
"@expo/steps": "npm:18.9.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:5.0.0"
|
||||
"@graphql-codegen/introspection": "npm:4.0.0"
|
||||
|
||||
Reference in new issue
Block a user