Compare commits

...
11 Commits
Author SHA1 Message Date
Brent Vatne aac5ebff73 v0.3.1 2021-01-25 12:05:30 -08:00
Wojciech KozyraandDominik Sokal 61cc5b0a4a [eas-cli] support canceled build status (#217)
* [eas-cli] support `canceled` build status

* Update packages/eas-cli/src/build/create.ts

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-01-22 11:00:56 +01:00
Wojciech Kozyra 1383783c33 [eas-cli] check if tty is atched on prompt (#216) 2021-01-21 15:24:25 +01:00
Dominik Sokal 971129be6b [eas-cli] improve monorepo support - fix configuring expo-updates (#214)
* [eas-cli] improve monorepo support - fix configuring expo-updates

* [eas-cli] bump @expo/config-plugins to 1.0.15-alpha.0
2021-01-21 12:08:30 +01:00
Quinlan Jung f4fb10483a [eas-cli] filter stale profiles (#215)
* [eas-cli] filter stale profiles

* pr feedback
2021-01-20 15:30:54 -08:00
Wojciech Kozyra 256a0e1f22 [eas-cli] return non-zero exit code if prompt is displayed on CI (#208) 2021-01-20 14:57:08 +01:00
William Schurman b82f0b04fe v0.3.0 2021-01-19 11:57:24 -08:00
Quinlan Jung 9714420f90 [eas-cli] select dist certs (#205)
* [eas-cli] select dist certs

* remove validity unknown

* pr feedback

* tests
2021-01-19 11:50:02 -08:00
Dominik Sokal 0054f0bc26 update release instructions (#212) 2021-01-19 20:42:54 +01:00
Will Schurman 38f565d849 [eas-cli] 2FA support (#204) 2021-01-19 09:37:45 -08:00
Dominik Sokal 1aff7ae2a3 fix typo 2021-01-19 16:22:20 +01:00
29 changed files with 974 additions and 70 deletions

No files matched your search

+1
View File
@@ -6,3 +6,4 @@
1. Wait until the `release` GitHub workflow has finished.
1. Once the release ready to go, you can go to
https://github.com/expo/eas-cli/releases and mark it as not draft.
1. Run `yarn lerna publish from-git` in the repository root folder. This will publish the packages to npm.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.2.1"
"version": "0.3.1"
}
+15 -16
View File
@@ -21,7 +21,7 @@ $ npm install -g eas-cli
$ eas COMMAND
running command...
$ eas (-v|--version|version)
eas-cli/0.2.1 darwin-x64 node-v12.18.4
eas-cli/0.3.1 darwin-x64 node-v14.15.2
$ eas --help [COMMAND]
USAGE
$ eas COMMAND
@@ -58,7 +58,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/account/login.ts)_
## `eas account:logout`
@@ -72,7 +72,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/account/logout.ts)_
## `eas account:view`
@@ -86,7 +86,7 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/account/view.ts)_
## `eas build`
@@ -105,7 +105,7 @@ OPTIONS
--wait Wait for build(s) to complete
```
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/build/index.ts)_
## `eas build:configure`
@@ -120,7 +120,7 @@ OPTIONS
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/build/configure.ts)_
## `eas build:list`
@@ -133,10 +133,10 @@ USAGE
OPTIONS
--limit=limit
--platform=(all|android|ios)
--status=(in-queue|in-progress|errored|finished)
--status=(in-queue|in-progress|errored|finished|canceled)
```
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/build/list.ts)_
## `eas build:view [BUILDID]`
@@ -147,7 +147,7 @@ USAGE
$ eas build:view [BUILDID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/build/view.ts)_
## `eas credentials`
@@ -158,7 +158,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/credentials.ts)_
## `eas device:create`
@@ -169,7 +169,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/device/create.ts)_
## `eas device:list`
@@ -183,7 +183,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -194,7 +194,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -237,8 +237,7 @@ OPTIONS
iOS specific options=asc-app-id App Store Connect unique application Apple ID
number. Providing this param results in skipping
app creation step. Learn more:
https://expo.fyi/asc-app-id
app creation step. Learn more.
iOS specific options=bundle-identifier Your iOS Bundle Identifier (default:
expo.ios.bundleIdentifier from app config)
@@ -291,5 +290,5 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.2.1/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.3.1/build/commands/submit.ts)_
<!-- commandsstop -->
+4 -3
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.2.1",
"version": "0.3.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -10,9 +10,9 @@
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.17",
"@expo/config": "~3.3.19",
"@expo/config-plugins": "^1.0.14",
"@expo/config-plugins": "1.0.15-alpha.0",
"@expo/eas-build-job": "^0.2.0",
"@expo/eas-json": "^0.2.0",
"@expo/eas-json": "^0.3.0",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
@@ -91,6 +91,7 @@
"babel-jest": "^26.3.0",
"memfs": "^3.2.0",
"mockdate": "^3.0.2",
"nock": "^13.0.5",
"ts-node": "^8",
"typescript": "^3.9.7",
"wonka": "^4.0.14"
+27
View File
@@ -0,0 +1,27 @@
import { JSONValue } from '@expo/json-file';
import { HTTPError, RequestError } from 'got/dist/source';
export default class ApiV2Error extends RequestError {
readonly name = 'ApiV2Error';
readonly expoApiV2ErrorCode: string;
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
constructor(
originalError: HTTPError,
response: {
message: string;
code: string;
stack?: string;
details?: JSONValue;
metadata?: object;
}
) {
super(response.message, originalError, originalError.request);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
this.expoApiV2ErrorMetadata = response.metadata;
}
}
@@ -0,0 +1,53 @@
import assert from 'assert';
import { RequestError } from 'got/dist/source';
import nock from 'nock';
import ApiV2Error from '../ApiV2Error';
import { apiClient, getExpoApiBaseUrl } from '../api';
describe('apiClient', () => {
it('converts Expo APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl())
.post('/--/api/v2/test')
.reply(400, {
errors: [
{
message: 'hellomessage',
code: 'TEST_CODE',
stack: 'line 1: hello',
details: { who: 'world' },
metadata: { an: 'object' },
},
],
});
let error: Error;
try {
await apiClient.post('test');
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(ApiV2Error);
assert(error instanceof ApiV2Error);
expect(error.message).toEqual('hellomessage');
expect(error.expoApiV2ErrorCode).toEqual('TEST_CODE');
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
});
it('does not convert non-APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl()).post('/--/api/v2/test').reply(500, 'Something went wrong');
let error: Error;
try {
await apiClient.post('test');
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(RequestError);
expect(error).not.toBeInstanceOf(ApiV2Error);
});
});
+18 -1
View File
@@ -1,5 +1,6 @@
import got, { NormalizedOptions } from 'got';
import got, { HTTPError, NormalizedOptions, RequestError } from 'got';
import ApiV2Error from './ApiV2Error';
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
export const apiClient = got.extend({
@@ -18,6 +19,22 @@ export const apiClient = got.extend({
}
},
],
beforeError: [
(error: RequestError): RequestError => {
if (error instanceof HTTPError) {
let result: { [key: string]: any };
try {
result = JSON.parse(error.response.body as string);
} catch (e2) {
return error;
}
if (result.errors?.length) {
return new ApiV2Error(error, result.errors[0]);
}
}
return error;
},
],
},
});
@@ -11,15 +11,15 @@ export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig)
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
await fs.writeFile(
buildGradlePath,
`${buildGradleContent}\n// Integration with Expo updates\n${gradleScriptApply}\n`
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, exp, buildGradleContents)) {
const updatedBuildGradleContents = AndroidConfig.Updates.ensureBuildGradleContainsConfigurationScript(
projectDir,
exp,
buildGradleContents
);
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
@@ -31,7 +31,7 @@ export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig)
);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
const result = await AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
const result = AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
}
@@ -72,9 +72,9 @@ export async function syncUpdatesConfigurationAsync(
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradle(projectDir);
const buildGradleContent = await fs.readFile(buildGradlePath, 'utf8');
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(buildGradleContent, projectDir, exp)) {
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, exp, buildGradleContents)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir, exp);
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
}
+12 -2
View File
@@ -95,6 +95,10 @@ async function waitForBuildEndAsync(
case BuildStatus.IN_QUEUE:
spinner.text = 'Build queued...';
break;
case BuildStatus.CANCELED:
spinner.text = 'Build canceled';
spinner.stopAndPersist();
break;
case BuildStatus.IN_PROGRESS:
spinner.text = 'Build in progress...';
break;
@@ -111,20 +115,26 @@ async function waitForBuildEndAsync(
return builds;
} else if (
builds.filter(build =>
build?.status ? [BuildStatus.FINISHED, BuildStatus.ERRORED].includes(build.status) : false
build?.status
? [BuildStatus.FINISHED, BuildStatus.ERRORED, BuildStatus.CANCELED].includes(
build.status
)
: false
).length === builds.length
) {
spinner.fail('Some of the builds failed');
spinner.fail('Some of the builds were canceled or failed.');
return builds;
} else {
const inQueue = builds.filter(build => build?.status === BuildStatus.IN_QUEUE).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.IN_PROGRESS).length;
const errored = builds.filter(build => build?.status === BuildStatus.ERRORED).length;
const finished = builds.filter(build => build?.status === BuildStatus.FINISHED).length;
const canceled = builds.filter(build => build?.status === BuildStatus.CANCELED).length;
const unknownState = builds.length - inQueue - inProgress - errored - finished;
spinner.text = [
inQueue && `Builds in queue: ${inQueue}`,
inProgress && `Builds in progress: ${inProgress}`,
canceled && `Builds canceled: ${canceled}`,
errored && chalk.red(`Builds failed: ${errored}`),
finished && chalk.green(`Builds finished: ${finished}`),
unknownState && chalk.red(`Builds in unknown state: ${unknownState}`),
@@ -55,7 +55,7 @@ export async function syncUpdatesConfigurationAsync(
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
log.warn(
'Native project configuration is not synced with values present in you app.json, run "eas build:configure" to make sure all values are applied in the native project'
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
+1
View File
@@ -19,6 +19,7 @@ export enum BuildStatus {
IN_PROGRESS = 'in-progress',
ERRORED = 'errored',
FINISHED = 'finished',
CANCELED = 'canceled',
}
export type TrackingContext = Record<string, string | number>;
@@ -24,6 +24,8 @@ export default function formatBuild(build: Build, { accountName }: Options) {
return chalk.blue('in queue');
case BuildStatus.IN_PROGRESS:
return chalk.blue('in progress');
case BuildStatus.CANCELED:
return chalk.gray('canceled');
case BuildStatus.FINISHED:
return chalk.green('finished');
case BuildStatus.ERRORED:
@@ -41,16 +43,21 @@ export default function formatBuild(build: Build, { accountName }: Options) {
label: 'Artifact',
get value() {
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
return '<in progress>';
}
const url = build.artifacts?.buildUrl;
if (!url) {
return chalk.red('not found');
switch (build.status) {
case BuildStatus.IN_QUEUE:
case BuildStatus.IN_PROGRESS:
return '<in progress>';
case BuildStatus.CANCELED:
case BuildStatus.ERRORED:
return '---------';
case BuildStatus.FINISHED: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
}
default:
return 'unknown';
}
return url;
},
},
{ label: 'Started at', value: new Date(build.createdAt).toLocaleString() },
+1 -1
View File
@@ -18,7 +18,7 @@ export default class BuildList extends Command {
static flags = {
platform: flags.enum({ options: ['all', 'android', 'ios'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished'] }),
status: flags.enum({ options: ['in-queue', 'in-progress', 'errored', 'finished', 'canceled'] }),
limit: flags.integer(),
};
@@ -1,23 +1,134 @@
import { AppleTeamFragment } from '../../../../graphql/generated';
import { AppleDistributionCertificateQueryResult } from '../../api/graphql/queries/AppleDistributionCertificateQuery';
import assert from 'assert';
import chalk from 'chalk';
import dateformat from 'dateformat';
export function formatDistributionCertificate({
developerPortalIdentifier,
serialNumber,
appleTeam,
validityNotBefore,
validityNotAfter,
}: AppleDistributionCertificateQueryResult): string {
import {
AppleDistributionCertificateFragment,
AppleTeamFragment,
} from '../../../../graphql/generated';
import log from '../../../../log';
import { promptAsync } from '../../../../prompts';
import { Account } from '../../../../user/Account';
import { fromNow } from '../../../../utils/date';
import { Context } from '../../../context';
import { AppLookupParams } from '../../api/GraphqlClient';
import { filterRevokedDistributionCerts } from '../../appstore/CredentialsUtilsBeta';
export function formatDistributionCertificate(
distributionCertificate: AppleDistributionCertificateFragment,
validSerialNumbers?: string[]
): string {
const {
serialNumber,
developerPortalIdentifier,
appleTeam,
validityNotBefore,
validityNotAfter,
} = distributionCertificate;
let line: string = '';
if (developerPortalIdentifier) {
line += `Cert ID: ${developerPortalIdentifier}`;
}
line += `${line === '' ? '' : ', '}Serial number: ${serialNumber}${
appleTeam ? `, ${formatAppleTeam(appleTeam)}` : ''
}, Created: ${validityNotBefore}, Expires: ${validityNotAfter}`;
}`;
line += chalk.gray(
`\n Created: ${fromNow(new Date(validityNotBefore))} ago, Expires: ${dateformat(
validityNotAfter,
'expiresHeaderFormat'
)}`
);
if (validSerialNumbers?.includes(serialNumber)) {
line += chalk.gray("\n ✅ Currently valid on Apple's servers.");
} else {
line += '';
}
return line;
}
function formatAppleTeam({ appleTeamIdentifier, appleTeamName }: AppleTeamFragment): string {
return `Team ID: ${appleTeamIdentifier}${appleTeamName ? `, Team name: ${appleTeamName}` : ''}`;
}
async function _selectDistributionCertificateAsync(
distCerts: AppleDistributionCertificateFragment[],
validDistributionCertificates?: AppleDistributionCertificateFragment[]
): Promise<AppleDistributionCertificateFragment | null> {
const validDistCertSerialNumbers = validDistributionCertificates?.map(
distCert => distCert.serialNumber
);
const { chosenDistCert } = await promptAsync({
type: 'select',
name: 'chosenDistCert',
message: 'Select certificate from the list.',
choices: distCerts.map(distCert => ({
title: formatDistributionCertificate(distCert, validDistCertSerialNumbers),
value: distCert,
})),
});
return chosenDistCert;
}
/**
* select a distribution certificate from an account (validity status shown on a best effort basis)
* */
export async function selectDistributionCertificateAsync(
ctx: Context,
account: Account
): Promise<AppleDistributionCertificateFragment | null> {
const distCertsForAccount = await ctx.newIos.getDistributionCertificatesForAccountAsync(account);
if (distCertsForAccount.length === 0) {
log.warn(`There are no Distribution Certificates available in your EAS account.`);
return null;
}
if (!ctx.appStore.authCtx) {
return _selectDistributionCertificateAsync(distCertsForAccount);
}
// get valid certs on the developer portal
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
const validDistCerts = await filterRevokedDistributionCerts(
distCertsForAccount,
certInfoFromApple
);
return _selectDistributionCertificateAsync(distCertsForAccount, validDistCerts);
}
/**
* select a distribution certificate from a valid set (curated on a best effort basis)
* */
export async function selectValidDistributionCertificateAsync(
ctx: Context,
appLookupParams: AppLookupParams
): Promise<AppleDistributionCertificateFragment | null> {
const distCertsForAccount = await ctx.newIos.getDistributionCertificatesForAccountAsync(
appLookupParams.account
);
if (distCertsForAccount.length === 0) {
log.warn(`There are no Distribution Certificates available in your EAS account.`);
return null;
}
if (!ctx.appStore.authCtx) {
return _selectDistributionCertificateAsync(distCertsForAccount);
}
// filter by apple team
assert(ctx.appStore.authCtx, 'authentication to the Apple App Store is required');
const appleTeamIdentifier = ctx.appStore.authCtx.team.id;
const distCertsForAppleTeam = distCertsForAccount.filter(distCert => {
return !distCert.appleTeam || distCert.appleTeam.appleTeamIdentifier === appleTeamIdentifier;
});
// filter by valid certs on the developer portal
const certInfoFromApple = await ctx.appStore.listDistributionCertificatesAsync();
const validDistCerts = await filterRevokedDistributionCerts(
distCertsForAppleTeam,
certInfoFromApple
);
log(
`${validDistCerts.length}/${distCertsForAccount.length} Distribution Certificates are currently valid for Apple Team ${ctx.appStore.authCtx?.team.id}.`
);
return _selectDistributionCertificateAsync(validDistCerts);
}
@@ -146,7 +146,7 @@ export class SetupDistributionCertificate implements Action {
const validDistCertSerialNumberSet = new Set(validDistCertSerialNumbers);
const distCertsForAccount = await ctx.newIos.getDistributionCertificatesForAccountAsync(
this.app
this.app.account
);
const distCertsForAppleTeam = distCertsForAccount.filter(distCert => {
return (
@@ -0,0 +1,24 @@
import mockdate from 'mockdate';
import { AppleDistributionCertificateQuery } from '../../../api/graphql/queries/AppleDistributionCertificateQuery';
import { formatDistributionCertificate } from '../DistributionCertificateUtils';
jest.mock('../../../api/graphql/queries/AppleDistributionCertificateQuery');
jest.mock('chalk', () => {
return {
__esModule: true, // this property makes it work
default: { gray: jest.fn(log => log) },
};
});
mockdate.set(new Date('4/20/2021'));
describe('select credentials', () => {
it('select an AppleDistributionCertificate fragment', async () => {
const testDistCerts = (
await AppleDistributionCertificateQuery.getAllForAccount('quinAccount')
).sort((a, b) => (a.serialNumber > b.serialNumber ? 1 : -1));
const loggedSoFar = testDistCerts
.map(cert => formatDistributionCertificate(cert))
.reduce((acc, certLog) => acc + certLog);
expect(loggedSoFar).toMatchSnapshot();
});
});
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`select credentials select an AppleDistributionCertificate fragment 1`] = `
"Cert ID: 5URSS525PL, Serial number: 3F9CDD6CFC6E52C1, Team ID: 77KQ969CHE
Created: 6 months ago, Expires: Wed, 13 Oct 2021 18:28:44 GMT+0000Cert ID: JRH7292L8R, Serial number: 5544BE191B9F949E, Team ID: 77KQ969CHE
Created: 10 months ago, Expires: Tue, 22 Jun 2021 20:46:07 GMT+0000"
`;
@@ -258,9 +258,9 @@ export async function getDistributionCertificateForAppAsync(
});
}
export async function getDistributionCertificatesForAccountAsync({
account,
}: AppLookupParams): Promise<AppleDistributionCertificateQueryResult[]> {
export async function getDistributionCertificatesForAccountAsync(
account: Account
): Promise<AppleDistributionCertificateQueryResult[]> {
return await AppleDistributionCertificateQuery.getAllForAccount(account.name);
}
@@ -0,0 +1,40 @@
const AppleDistributionCertificateQuery = {
getAllForAccount: jest.fn().mockImplementation(() => {
return [
{
id: '6a7422cc-392b-451d-984c-b0b4b027680a',
certificateP12: 'test-cert-p12-1',
certificatePassword: 'test-cert-password-1',
serialNumber: '5544BE191B9F949E',
developerPortalIdentifier: 'JRH7292L8R',
validityNotBefore: '2020-06-22T20:46:07.000Z',
validityNotAfter: '2021-06-22T20:46:07.000Z',
appleTeam: {
id: '6d39dbfe-a30a-44a4-bc36-d562e5963fa3',
appleTeamIdentifier: '77KQ969CHE',
appleTeamName: null,
__typename: 'AppleTeam',
},
__typename: 'AppleDistributionCertificate',
},
{
id: '8b3baa53-b149-4a1b-842b-75ff6e0bcb1e',
certificateP12: 'test-cert-p12-2',
certificatePassword: 'test-cert-password-2',
serialNumber: '3F9CDD6CFC6E52C1',
developerPortalIdentifier: '5URSS525PL',
validityNotBefore: '2020-10-13T18:28:44.000Z',
validityNotAfter: '2021-10-13T18:28:44.000Z',
appleTeam: {
id: '6d39dbfe-a30a-44a4-bc36-d562e5963fa3',
appleTeamIdentifier: '77KQ969CHE',
appleTeamName: null,
__typename: 'AppleTeam',
},
__typename: 'AppleDistributionCertificate',
},
];
}),
};
export { AppleDistributionCertificateQuery };
@@ -0,0 +1,22 @@
import { AppleDistributionCertificateQueryResult } from '../api/graphql/queries/AppleDistributionCertificateQuery';
import { DistributionCertificateStoreInfo } from './Credentials.types';
export function filterRevokedDistributionCerts(
distributionCerts: AppleDistributionCertificateQueryResult[],
certInfoFromApple: DistributionCertificateStoreInfo[]
): AppleDistributionCertificateQueryResult[] {
if (distributionCerts.length === 0) {
return [];
}
// if the cert is valid, check it against apple to make sure it hasnt been revoked
const validCertSerialsOnAppleServer = certInfoFromApple
.filter(
// remove expired certs
cert => cert.expires > Math.floor(Date.now() / 1000)
)
.map(cert => cert.serialNumber);
return distributionCerts.filter(cert =>
validCertSerialsOnAppleServer.includes(cert.serialNumber)
);
}
@@ -1,6 +1,6 @@
import { BundleId, Profile, RequestContext } from '@expo/apple-utils';
export async function getProfilesForBundleIdAsync(
async function getProfilesForBundleIdDangerousAsync(
context: RequestContext,
bundleIdentifier: string
): Promise<Profile[]> {
@@ -11,6 +11,35 @@ export async function getProfilesForBundleIdAsync(
return [];
}
export async function getProfilesForBundleIdAsync(
context: RequestContext,
bundleIdentifier: string
): Promise<Profile[]> {
const profiles = await getProfilesForBundleIdDangerousAsync(context, bundleIdentifier);
// users sometimes have a poisoned Apple cache and receive stale data from the API
// we call an arbitrary method, `getBundleIdAsync` on each profile
// if it errors, the profile was stale, so we remove it
const validProfileIds = new Set();
await Promise.all(
profiles.map(async profile => {
try {
await profile.getBundleIdAsync();
validProfileIds.add(profile.id);
} catch (e) {
if (
e.name === 'UnexpectedAppleResponse' &&
e.message.includes('The specified resource does not exist - There is no resource of type')
) {
// TODO: add tracking analytics here
return;
}
throw e;
}
})
);
return profiles.filter(profile => validProfileIds.has(profile.id));
}
export async function getBundleIdForIdentifierAsync(
context: RequestContext,
bundleIdentifier: string
+24
View File
@@ -9,6 +9,9 @@ export async function promptAsync<T extends string = string>(
questions: Question<T> | Question<T>[],
options: Options = {}
): Promise<Answers<T>> {
if (!process.stdin.isTTY && !global.test) {
throw new Error('Input is required, but stdin is not readable.');
}
return await prompts<T>(questions, {
onCancel() {
process.exit(constants.signals.SIGINT + 128); // Exit code 130 used when process is interrupted with ctrl+c.
@@ -33,6 +36,27 @@ export async function confirmAsync(
return value;
}
interface ExpoChoice<T> extends Choice {
value: T;
}
export async function selectAsync<T>(
message: string,
choices: ExpoChoice<T>[],
options?: Options
): Promise<T> {
const { value } = await promptAsync(
{
message,
choices,
name: 'value',
type: 'select',
},
options
);
return value ?? null;
}
/**
* Create a more dynamic yes/no confirmation that can be cancelled.
*
+5 -1
View File
@@ -24,11 +24,15 @@ export async function getUserAsync(): Promise<Actor | undefined> {
export async function loginAsync({
username,
password,
otp,
}: {
username: string;
password: string;
otp?: string;
}): Promise<void> {
const body = await apiClient.post('auth/loginAsync', { json: { username, password } }).json();
const body = await apiClient
.post('auth/loginAsync', { json: { username, password, otp } })
.json();
const { sessionSecret } = (body as any).data;
const result = await graphqlClient
.query(
@@ -1,16 +1,37 @@
import { Actor } from '../User';
import { ensureActorHasUsername, getActorDisplayName } from '../actions';
import ApiV2Error from '../../ApiV2Error';
import { promptAsync } from '../../prompts';
import { Actor, loginAsync } from '../User';
import { ensureActorHasUsername, getActorDisplayName, showLoginPromptAsync } from '../actions';
import { UserSecondFactorDeviceMethod, retryUsernamePasswordAuthWithOTPAsync } from '../otp';
jest.mock('../../prompts');
jest.mock('../../api');
jest.mock('../otp');
jest.mock('../User', () => ({
loginAsync: jest.fn(),
}));
beforeEach(() => {
(promptAsync as jest.Mock).mockReset();
(promptAsync as jest.Mock).mockImplementation(() => {
throw new Error('Should not be called');
});
(loginAsync as jest.Mock).mockReset();
});
const userStub: Actor = {
__typename: 'User',
id: 'userId',
username: 'username',
accounts: [],
};
const robotStub: Actor = {
__typename: 'Robot',
id: 'userId',
firstName: 'GLaDOS',
accounts: [],
};
describe('getActorDisplayName', () => {
@@ -40,3 +61,51 @@ describe('ensureActorHasUsername', () => {
expect(() => ensureActorHasUsername(robotStub)).toThrow('not supported for robot');
});
});
describe(showLoginPromptAsync, () => {
it('prompts for OTP when 2FA is enabled', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ username: 'hello', password: 'world' }))
.mockImplementationOnce(() => ({ otp: '123456' }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
(loginAsync as jest.Mock)
.mockImplementationOnce(async () => {
throw new ApiV2Error({ code: 'testcode', request: {} } as any, {
message: 'An OTP is required',
code: 'ONE_TIME_PASSWORD_REQUIRED',
metadata: {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'testphone',
},
],
smsAutomaticallySent: true,
},
});
})
.mockImplementation(() => {});
await showLoginPromptAsync();
expect(retryUsernamePasswordAuthWithOTPAsync as jest.Mock).toHaveBeenCalledWith(
'hello',
'world',
{
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'testphone',
},
],
smsAutomaticallySent: true,
}
);
});
});
@@ -0,0 +1,262 @@
import { apiClient } from '../../api';
import { promptAsync, selectAsync } from '../../prompts';
import { loginAsync } from '../User';
import { UserSecondFactorDeviceMethod, retryUsernamePasswordAuthWithOTPAsync } from '../otp';
jest.mock('../../prompts');
jest.mock('../../api');
jest.mock('../User', () => ({
loginAsync: jest.fn(),
}));
const logFn = jest.fn();
const mockLog = {
__esModule: true, // this property makes it work
default: logFn,
};
jest.mock('../../log', () => mockLog);
beforeEach(() => {
(promptAsync as jest.Mock).mockReset();
(promptAsync as jest.Mock).mockImplementation(() => {
throw new Error('Should not be called');
});
(selectAsync as jest.Mock).mockReset();
(selectAsync as jest.Mock).mockImplementation(() => {
throw new Error('Should not be called');
});
(loginAsync as jest.Mock).mockReset();
logFn.mockReset();
});
describe(retryUsernamePasswordAuthWithOTPAsync, () => {
it('shows SMS OTP prompt when SMS is primary and code was automatically sent', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: 'hello' }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'testphone',
},
],
smsAutomaticallySent: true,
});
expect(logFn).toHaveBeenCalledWith(
'One-time password was sent to the phone number ending in testphone.'
);
expect(loginAsync as jest.Mock).toHaveBeenCalledTimes(1);
});
it('shows authenticator OTP prompt when authenticator is primary', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: 'hello' }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
],
smsAutomaticallySent: false,
});
expect(logFn).toHaveBeenCalledWith('One-time password from authenticator required.');
expect(loginAsync as jest.Mock).toHaveBeenCalledTimes(1);
});
it('shows menu when user bails on primary', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: null }))
.mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
(selectAsync as any)
.mockImplementationOnce(() => -1)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
],
smsAutomaticallySent: false,
});
expect((selectAsync as any).mock.calls.length).toEqual(1);
expect(loginAsync as jest.Mock).toHaveBeenCalledTimes(1);
});
it('shows a warning when when user bails on primary and does not have any secondary set up', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: null }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
await expect(
retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
],
smsAutomaticallySent: false,
})
).rejects.toThrowError(
'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
);
});
it('prompts for authenticator OTP when user selects authenticator secondary', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: null }))
.mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
(selectAsync as any)
.mockImplementationOnce(() => -1)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
],
smsAutomaticallySent: false,
});
expect((promptAsync as jest.Mock).mock.calls.length).toBe(2); // first OTP, second OTP
});
it('requests SMS OTP and prompts for SMS OTP when user selects SMS secondary', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: null }))
.mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
(selectAsync as any)
.mockImplementationOnce(() => 0)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
(apiClient.post as jest.Mock).mockReturnValueOnce({
json: () => Promise.resolve({ data: { sessionSecret: 'SESSION_SECRET' } }),
});
await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'wat',
},
],
smsAutomaticallySent: false,
});
expect((promptAsync as jest.Mock).mock.calls.length).toBe(2); // first OTP, second OTP
expect((apiClient.post as jest.Mock).mock.calls[0]).toEqual([
'auth/send-sms-otp',
{
json: {
username: 'blah',
password: 'blah',
secondFactorDeviceID: 'p2',
},
},
]);
});
it('exits when user bails on primary and backup', async () => {
(promptAsync as jest.Mock)
.mockImplementationOnce(() => ({ otp: null }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
(selectAsync as any)
.mockImplementationOnce(() => -2)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
await expect(
retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: undefined,
},
],
smsAutomaticallySent: false,
})
).rejects.toThrowError('Cancelled login');
});
});
+18 -4
View File
@@ -1,6 +1,8 @@
import ApiV2Error from '../ApiV2Error';
import log from '../log';
import { promptAsync } from '../prompts';
import { Actor, getUserAsync, loginAsync } from './User';
import { retryUsernamePasswordAuthWithOTPAsync } from './otp';
export async function showLoginPromptAsync(): Promise<void> {
const { username, password } = await promptAsync([
@@ -15,10 +17,22 @@ export async function showLoginPromptAsync(): Promise<void> {
message: 'Password',
},
]);
await loginAsync({
username,
password,
});
try {
await loginAsync({
username,
password,
});
} catch (e) {
if (e instanceof ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
await retryUsernamePasswordAuthWithOTPAsync(
username,
password,
e.expoApiV2ErrorMetadata as any
);
} else {
throw e;
}
}
}
export async function ensureLoggedInAsync(): Promise<Actor> {
+167
View File
@@ -0,0 +1,167 @@
import assert from 'assert';
import chalk from 'chalk';
import { apiClient } from '../api';
import log from '../log';
import { promptAsync, selectAsync } from '../prompts';
import { loginAsync } from './User';
export enum UserSecondFactorDeviceMethod {
AUTHENTICATOR = 'authenticator',
SMS = 'sms',
}
export type SecondFactorDevice = {
id: string;
method: UserSecondFactorDeviceMethod;
sms_phone_number: string | null;
is_primary: boolean;
};
/**
* Prompt for an OTP with the option to cancel the question by answering empty (pressing return key).
*/
async function promptForOTPAsync(cancelBehavior: 'cancel' | 'menu'): Promise<string | null> {
const enterMessage =
cancelBehavior === 'cancel'
? `press ${chalk.bold('Enter')} to cancel`
: `press ${chalk.bold('Enter')} for more options`;
const { otp } = await promptAsync({
type: 'text',
name: 'otp',
message: `One-time password or backup code (${enterMessage}):`,
});
if (!otp) {
return null;
}
return otp;
}
/**
* Prompt for user to choose a backup OTP method. If selected method is SMS, a request
* for a new OTP will be sent to that method. Then, prompt for the OTP, and retry the user login.
*/
async function promptForBackupOTPAsync(
username: string,
password: string,
secondFactorDevices: SecondFactorDevice[]
): Promise<string | null> {
const nonPrimarySecondFactorDevices = secondFactorDevices.filter(device => !device.is_primary);
if (nonPrimarySecondFactorDevices.length === 0) {
throw new Error(
'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
);
}
const hasAuthenticatorSecondFactorDevice = nonPrimarySecondFactorDevices.find(
device => device.method === UserSecondFactorDeviceMethod.AUTHENTICATOR
);
const smsNonPrimarySecondFactorDevices = nonPrimarySecondFactorDevices.filter(
device => device.method === UserSecondFactorDeviceMethod.SMS
);
const authenticatorChoiceSentinel = -1;
const cancelChoiceSentinel = -2;
const deviceChoices = smsNonPrimarySecondFactorDevices.map((device, idx) => ({
title: device.sms_phone_number!,
value: idx,
}));
if (hasAuthenticatorSecondFactorDevice) {
deviceChoices.push({
title: 'Authenticator',
value: authenticatorChoiceSentinel,
});
}
deviceChoices.push({
title: 'Cancel',
value: cancelChoiceSentinel,
});
const selectedValue = await selectAsync('Select a second-factor device:', deviceChoices);
if (selectedValue === cancelChoiceSentinel) {
return null;
} else if (selectedValue === authenticatorChoiceSentinel) {
return await promptForOTPAsync('cancel');
}
const device = smsNonPrimarySecondFactorDevices[selectedValue];
await apiClient
.post('auth/send-sms-otp', {
json: {
username,
password,
secondFactorDeviceID: device.id,
},
})
.json();
return await promptForOTPAsync('cancel');
}
/**
* Handle the special case error indicating that a second-factor is required for
* authentication.
*
* There are three cases we need to handle:
* 1. User's primary second-factor device was SMS, OTP was automatically sent by the server to that
* device already. In this case we should just prompt for the SMS OTP (or backup code), which the
* user should be receiving shortly. We should give the user a way to cancel and the prompt and move
* to case 3 below.
* 2. User's primary second-factor device is authenticator. In this case we should prompt for authenticator
* OTP (or backup code) and also give the user a way to cancel and move to case 3 below.
* 3. User doesn't have a primary device or doesn't have access to their primary device. In this case
* we should show a picker of the SMS devices that they can have an OTP code sent to, and when
* the user picks one we show a prompt() for the sent OTP.
*/
export async function retryUsernamePasswordAuthWithOTPAsync(
username: string,
password: string,
metadata: {
secondFactorDevices?: SecondFactorDevice[];
smsAutomaticallySent?: boolean;
}
): Promise<void> {
const { secondFactorDevices, smsAutomaticallySent } = metadata;
assert(
secondFactorDevices !== undefined && smsAutomaticallySent !== undefined,
`Malformed OTP error metadata: ${metadata}`
);
const primaryDevice = secondFactorDevices.find(device => device.is_primary);
let otp: string | null = null;
if (smsAutomaticallySent) {
assert(primaryDevice, 'OTP should only automatically be sent when there is a primary device');
log(
`One-time password was sent to the phone number ending in ${primaryDevice.sms_phone_number}.`
);
otp = await promptForOTPAsync('menu');
}
if (primaryDevice?.method === UserSecondFactorDeviceMethod.AUTHENTICATOR) {
log('One-time password from authenticator required.');
otp = await promptForOTPAsync('menu');
}
// user bailed on case 1 or 2, wants to move to case 3
if (!otp) {
otp = await promptForBackupOTPAsync(username, password, secondFactorDevices);
}
if (!otp) {
throw new Error('Cancelled login');
}
await loginAsync({
username,
password,
otp,
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with the eas.json",
"version": "0.2.0",
"version": "0.3.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
+19 -4
View File
@@ -1093,10 +1093,10 @@
"@babel/preset-env" "^7.4.4"
"@babel/preset-typescript" "^7.3.3"
"@expo/config-plugins@^1.0.14":
version "1.0.14"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-1.0.14.tgz#95ad608f64fa6c66a352197d7552880d7cc9fa06"
integrity sha512-BSzRsBIKWS6bw32eOI/wJCZqschzWSgnvGZGYgDPPtHf/zMariLnnsqoIC5HT4CO1B+sqkSMstK1gy8YVtJABQ==
"@expo/[email protected]5-alpha.0":
version "1.0.15-alpha.0"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-1.0.15-alpha.0.tgz#0f6c4250fa96ec597bb890dddb3502192c705e27"
integrity sha512-gWCHnh96RoWdhF7gsdGjuw7PhNOLl6vsB2PWpbANUWA3Y96dLLTv/nVNH3bLQ8KGtlVU/mAZ1Go6AW6u08Jx7A==
dependencies:
"@expo/config-types" "^40.0.0-beta.2"
"@expo/configure-splash-screen" "0.3.2"
@@ -9130,6 +9130,16 @@ no-case@^3.0.3:
lower-case "^2.0.1"
tslib "^1.10.0"
nock@^13.0.5:
version "13.0.5"
resolved "https://registry.yarnpkg.com/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414"
integrity sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg==
dependencies:
debug "^4.1.0"
json-stringify-safe "^5.0.1"
lodash.set "^4.3.2"
propagate "^2.0.0"
node-fetch-npm@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.4.tgz#6507d0e17a9ec0be3bec516958a497cec54bf5a4"
@@ -10097,6 +10107,11 @@ prop-types@^15.7.2:
object-assign "^4.1.1"
react-is "^16.8.1"
propagate@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"