mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac5ebff73 | ||
|
|
61cc5b0a4a | ||
|
|
1383783c33 | ||
|
|
971129be6b | ||
|
|
f4fb10483a | ||
|
|
256a0e1f22 | ||
|
|
b82f0b04fe | ||
|
|
9714420f90 | ||
|
|
0054f0bc26 | ||
|
|
38f565d849 | ||
|
|
1aff7ae2a3 | ||
|
|
31020047ca | ||
|
|
26778ec52b |
No files matched your search
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.2.0"
|
||||
"version": "0.3.1"
|
||||
}
|
||||
+15
-34
@@ -21,7 +21,7 @@ $ npm install -g eas-cli
|
||||
$ eas COMMAND
|
||||
running command...
|
||||
$ eas (-v|--version|version)
|
||||
eas-cli/0.2.0 darwin-x64 node-v12.18.4
|
||||
eas-cli/0.3.1 darwin-x64 node-v14.15.2
|
||||
$ eas --help [COMMAND]
|
||||
USAGE
|
||||
$ eas COMMAND
|
||||
@@ -44,7 +44,6 @@ USAGE
|
||||
* [`eas device:list`](#eas-devicelist)
|
||||
* [`eas device:view [UDID]`](#eas-deviceview-udid)
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas release:publish`](#eas-releasepublish)
|
||||
* [`eas submit --platform=(android|ios)`](#eas-submit---platformandroidios)
|
||||
|
||||
## `eas account:login`
|
||||
@@ -59,7 +58,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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`
|
||||
|
||||
@@ -73,7 +72,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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`
|
||||
|
||||
@@ -87,7 +86,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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`
|
||||
|
||||
@@ -106,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.0/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`
|
||||
|
||||
@@ -121,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.0/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`
|
||||
|
||||
@@ -134,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.0/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]`
|
||||
|
||||
@@ -148,7 +147,7 @@ USAGE
|
||||
$ eas build:view [BUILDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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`
|
||||
|
||||
@@ -159,7 +158,7 @@ USAGE
|
||||
$ eas credentials
|
||||
```
|
||||
|
||||
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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`
|
||||
|
||||
@@ -170,7 +169,7 @@ USAGE
|
||||
$ eas device:create
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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`
|
||||
|
||||
@@ -184,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.0/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]`
|
||||
|
||||
@@ -195,7 +194,7 @@ USAGE
|
||||
$ eas device:view [UDID]
|
||||
```
|
||||
|
||||
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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]`
|
||||
|
||||
@@ -214,23 +213,6 @@ OPTIONS
|
||||
|
||||
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.0/src/commands/help.ts)_
|
||||
|
||||
## `eas release:publish`
|
||||
|
||||
Publish an update group to a release.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas release:publish
|
||||
|
||||
OPTIONS
|
||||
--input-dir=input-dir [default: dist] location of the bundle
|
||||
--json return a json with the edited release's ID and name.
|
||||
--message=message Short message describing the updates.
|
||||
--release=release current name of the release.
|
||||
```
|
||||
|
||||
_See code: [build/commands/release/publish.ts](https://github.com/expo/eas-cli/blob/v0.2.0/build/commands/release/publish.ts)_
|
||||
|
||||
## `eas submit --platform=(android|ios)`
|
||||
|
||||
Submits build artifact to app store
|
||||
@@ -255,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)
|
||||
@@ -309,5 +290,5 @@ EXAMPLES
|
||||
and provide its App ID
|
||||
```
|
||||
|
||||
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.2.0/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 -->
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.2.0",
|
||||
"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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { promptAsync } from '../../prompts';
|
||||
import { getLastCommitMessageAsync } from '../../utils/git';
|
||||
|
||||
export default class ReleasePublish extends Command {
|
||||
static hidden = true;
|
||||
static description = 'Publish an update group to a release.';
|
||||
|
||||
static flags = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+24
@@ -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();
|
||||
});
|
||||
});
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user