diff --git a/packages/expo/bin/cli.ts b/packages/expo/bin/cli.ts index fc832ea25e30b..035951689a7e5 100755 --- a/packages/expo/bin/cli.ts +++ b/packages/expo/bin/cli.ts @@ -11,6 +11,11 @@ const commands: { [command: string]: () => Promise } = { // Add a new command here prebuild: () => import('../cli/prebuild').then((i) => i.expoPrebuild), config: () => import('../cli/config').then((i) => i.expoConfig), + // Auth + login: () => import('../cli/login').then((i) => i.expoLogin), + logout: () => import('../cli/logout').then((i) => i.expoLogout), + register: () => import('../cli/register').then((i) => i.expoRegister), + whoami: () => import('../cli/whoami').then((i) => i.expoWhoami), }; const args = arg( diff --git a/packages/expo/cli/login/index.ts b/packages/expo/cli/login/index.ts new file mode 100644 index 0000000000000..53ba828a3c881 --- /dev/null +++ b/packages/expo/cli/login/index.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import chalk from 'chalk'; + +import { Command } from '../../bin/cli'; +import * as Log from '../log'; +import { assertArgs } from '../utils/args'; +import { logCmdError } from '../utils/errors'; + +export const expoLogin: Command = async (argv) => { + const args = assertArgs( + { + // Types + '--help': Boolean, + '--username': String, + '--password': String, + '--otp': String, + // Aliases + '-h': '--help', + '-u': '--username', + '-p': '--password', + }, + argv + ); + + if (args['--help']) { + Log.exit( + chalk` + {bold Description} + Log in to an Expo account + + {bold Usage} + $ npx expo login + + Options + -u, --username Username + -p, --password Password + --otp One-time password from your 2FA device + -h, --help Output usage information + `, + 0 + ); + } + + const { showLoginPromptAsync } = await import('../utils/user/actions'); + return showLoginPromptAsync({ + // Parsed options + username: args['--username'], + password: args['--password'], + otp: args['--otp'], + }).catch(logCmdError); +}; diff --git a/packages/expo/cli/logout/index.ts b/packages/expo/cli/logout/index.ts new file mode 100644 index 0000000000000..2c99b122a92b3 --- /dev/null +++ b/packages/expo/cli/logout/index.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import chalk from 'chalk'; + +import { Command } from '../../bin/cli'; +import * as Log from '../log'; +import { assertArgs } from '../utils/args'; +import { logCmdError } from '../utils/errors'; + +export const expoLogout: Command = async (argv) => { + const args = assertArgs( + { + // Types + '--help': Boolean, + // Aliases + '-h': '--help', + }, + argv + ); + + if (args['--help']) { + Log.exit( + chalk` + {bold Description} + Log out of an Expo account + + {bold Usage} + $ npx expo logout + + Options + -h, --help Output usage information + `, + 0 + ); + } + + const { logoutAsync } = await import('../utils/user/user'); + return logoutAsync().catch(logCmdError); +}; diff --git a/packages/expo/cli/register/index.ts b/packages/expo/cli/register/index.ts new file mode 100644 index 0000000000000..700f5bf7663de --- /dev/null +++ b/packages/expo/cli/register/index.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import chalk from 'chalk'; + +import { Command } from '../../bin/cli'; +import * as Log from '../log'; +import { assertArgs } from '../utils/args'; +import { logCmdError } from '../utils/errors'; + +export const expoRegister: Command = async (argv) => { + const args = assertArgs( + { + // Types + '--help': Boolean, + // Aliases + '-h': '--help', + }, + argv + ); + + if (args['--help']) { + Log.exit( + chalk` + {bold Description} + Sign up for a new Expo account + + {bold Usage} + $ npx expo register + + Options + -h, --help Output usage information + `, + 0 + ); + } + + const { registerAsync } = await import('./registerAsync'); + return registerAsync().catch(logCmdError); +}; diff --git a/packages/expo/cli/register/registerAsync.ts b/packages/expo/cli/register/registerAsync.ts new file mode 100644 index 0000000000000..372f27300a585 --- /dev/null +++ b/packages/expo/cli/register/registerAsync.ts @@ -0,0 +1,34 @@ +import openBrowserAsync from 'better-opn'; + +import { CI } from '../utils/env'; +import { CommandError } from '../utils/errors'; +import { learnMore } from '../utils/link'; +import { ora } from '../utils/ora'; + +export async function registerAsync() { + if (CI) { + throw new CommandError( + 'NON_INTERACTIVE', + `Cannot register an account in CI. Use the EXPO_TOKEN environment variable to authenticate in CI (${learnMore( + 'https://docs.expo.dev/accounts/programmatic-access/' + )})` + ); + } + + const registrationUrl = `https://expo.dev/signup`; + const failedMessage = `Unable to open a web browser. Register an account at: ${registrationUrl}`; + const spinner = ora(`Opening ${registrationUrl}`).start(); + try { + const opened = await openBrowserAsync(registrationUrl); + + if (opened) { + spinner.succeed(`Opened ${registrationUrl}`); + } else { + spinner.fail(failedMessage); + } + return; + } catch (error) { + spinner.fail(failedMessage); + throw error; + } +} diff --git a/packages/expo/cli/utils/__tests__/api-test.ts b/packages/expo/cli/utils/__tests__/api-test.ts new file mode 100644 index 0000000000000..56916f10b9044 --- /dev/null +++ b/packages/expo/cli/utils/__tests__/api-test.ts @@ -0,0 +1,48 @@ +import assert from 'assert'; +import { RequestError } from 'got/dist/source'; +import nock from 'nock'; + +import { ApiV2Error, apiClient, getExpoApiBaseUrl } from '../api'; + +it('converts Expo APIv2 error to ApiV2Error', async () => { + nock(getExpoApiBaseUrl()) + .post('/v2/test') + .reply(400, { + errors: [ + { + message: 'hellomessage', + code: 'TEST_CODE', + stack: 'line 1: hello', + details: { who: 'world' }, + metadata: { an: 'object' }, + }, + ], + }); + + expect.assertions(5); + + try { + await apiClient.post('test'); + } catch (error: any) { + 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('/v2/test').reply(500, 'Something went wrong'); + + expect.assertions(2); + + try { + await apiClient.post('test'); + } catch (error: any) { + expect(error).toBeInstanceOf(RequestError); + expect(error).not.toBeInstanceOf(ApiV2Error); + } +}); diff --git a/packages/expo/cli/utils/analytics/rudderstackClient.ts b/packages/expo/cli/utils/analytics/rudderstackClient.ts new file mode 100644 index 0000000000000..0a80d95347f35 --- /dev/null +++ b/packages/expo/cli/utils/analytics/rudderstackClient.ts @@ -0,0 +1,99 @@ +import RudderAnalytics from '@expo/rudder-sdk-node'; +import os from 'os'; +import { v4 as uuidv4 } from 'uuid'; + +import { EXPO_LOCAL, EXPO_STAGING, EXPO_NO_TELEMETRY } from '../env'; +import UserSettings from '../user/UserSettings'; + +const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = { + darwin: 'Mac', + win32: 'Windows', + linux: 'Linux', +}; + +let client: RudderAnalytics | null = null; +let identified = false; +let identifyData: { + userId: string; + deviceId: string; + traits: Record; +} | null = null; + +function getClient(): RudderAnalytics { + if (client) { + return client; + } + + client = new RudderAnalytics( + EXPO_STAGING || EXPO_LOCAL ? '24TKICqYKilXM480mA7ktgVDdea' : '24TKR7CQAaGgIrLTgu3Fp4OdOkI', // expo unified + 'https://cdp.expo.dev/v1/batch', + { + flushInterval: 300, + } + ); + + // Install flush on exit... + process.on('SIGINT', () => client?.flush?.()); + process.on('SIGTERM', () => client?.flush?.()); + + return client; +} + +export async function setUserDataAsync(userId: string, traits: Record): Promise { + if (EXPO_NO_TELEMETRY) { + return; + } + const savedDeviceId = await UserSettings.getAsync('analyticsDeviceId', null); + const deviceId = savedDeviceId ?? uuidv4(); + if (!savedDeviceId) { + await UserSettings.setAsync('analyticsDeviceId', deviceId); + } + + identifyData = { + userId, + deviceId, + traits, + }; + + ensureIdentified(); +} + +export function logEvent(event: 'action', properties: Record = {}): void { + if (EXPO_NO_TELEMETRY) { + return; + } + ensureIdentified(); + + const { userId, deviceId } = identifyData ?? {}; + const commonEventProperties = { source_version: process.env.__EXPO_VERSION, source: 'expo' }; + + const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() }; + getClient().track({ + event, + properties: { ...properties, ...commonEventProperties }, + ...identity, + context: getContext(), + }); +} + +function ensureIdentified(): void { + if (EXPO_NO_TELEMETRY || identified || !identifyData) { + return; + } + + getClient().identify({ + userId: identifyData.userId, + anonymousId: identifyData.deviceId, + traits: identifyData.traits, + }); + identified = true; +} + +function getContext(): Record { + const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform(); + return { + os: { name: platform, version: os.release() }, + device: { type: platform, model: platform }, + app: { name: 'expo', version: process.env.__EXPO_VERSION }, + }; +} diff --git a/packages/expo/cli/utils/api.ts b/packages/expo/cli/utils/api.ts new file mode 100644 index 0000000000000..c83222bd4b797 --- /dev/null +++ b/packages/expo/cli/utils/api.ts @@ -0,0 +1,75 @@ +import { JSONValue } from '@expo/json-file'; +import got, { HTTPError, NormalizedOptions, RequestError } from 'got'; + +import { EXPO_LOCAL, EXPO_STAGING } from './env'; +import { getAccessToken, getSessionSecret } from './user/sessionStorage'; + +export 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; + } +} + +export const apiClient = got.extend({ + prefixUrl: getExpoApiBaseUrl() + '/v2/', + hooks: { + beforeRequest: [ + (options: NormalizedOptions) => { + const token = getAccessToken(); + if (token) { + options.headers.authorization = `Bearer ${token}`; + return; + } + const sessionSecret = getSessionSecret(); + if (sessionSecret) { + options.headers['expo-session'] = sessionSecret; + } + }, + ], + 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; + }, + ], + }, +}); + +export function getExpoApiBaseUrl(): string { + if (EXPO_STAGING) { + return `https://staging-api.expo.dev`; + } else if (EXPO_LOCAL) { + return `http://127.0.0.1:3000`; + } else { + return `https://api.expo.dev`; + } +} diff --git a/packages/expo/cli/utils/env.ts b/packages/expo/cli/utils/env.ts index 8293a285348f1..d08a58925a1f1 100644 --- a/packages/expo/cli/utils/env.ts +++ b/packages/expo/cli/utils/env.ts @@ -8,5 +8,14 @@ export const EXPO_PROFILE = boolish('EXPO_PROFILE', false); /** Enable debug logging */ export const EXPO_DEBUG = boolish('EXPO_DEBUG', false); +/** Enable staging API environment */ +export const EXPO_STAGING = boolish('EXPO_STAGING', false); + +/** Enable local API environment */ +export const EXPO_LOCAL = boolish('EXPO_LOCAL', false); + /** Is running in non-interactive CI mode */ export const CI = boolish('CI', false); + +/** Disable telemetry (analytics) */ +export const EXPO_NO_TELEMETRY = boolish('EXPO_NO_TELEMETRY', false); diff --git a/packages/expo/cli/utils/errors.ts b/packages/expo/cli/utils/errors.ts index 2a1b4f38714ec..d3c9d0c94a246 100644 --- a/packages/expo/cli/utils/errors.ts +++ b/packages/expo/cli/utils/errors.ts @@ -1,3 +1,6 @@ +import { AssertionError } from 'assert'; +import chalk from 'chalk'; + import { exit } from '../log'; const ERROR_PREFIX = 'Error: '; @@ -48,6 +51,17 @@ export function logCmdError(error: Error): never { if (error instanceof AbortCommandError || error instanceof SilentError) { // Do nothing, this is used for prompts or other cases that were custom logged. process.exit(0); + } else if ( + error instanceof CommandError || + error instanceof AssertionError || + error.name === 'ApiV2Error' + ) { + // Print the stack trace in debug mode only. + exit( + chalk.red(error.toString()) + + (require('./env').EXPO_DEBUG ? '\n' + chalk.gray(error.stack) : '') + ); } - exit(error.toString()); + + exit(chalk.red(error.toString()) + '\n' + chalk.gray(error.stack)); } diff --git a/packages/expo/cli/utils/graphql/client.ts b/packages/expo/cli/utils/graphql/client.ts new file mode 100644 index 0000000000000..7c23eec5b30a7 --- /dev/null +++ b/packages/expo/cli/utils/graphql/client.ts @@ -0,0 +1,93 @@ +import { + cacheExchange, + Client, + CombinedError as GraphqlError, + createClient as createUrqlClient, + dedupExchange, + fetchExchange, + OperationContext, + OperationResult, + PromisifiedSource, + TypedDocumentNode, +} from '@urql/core'; +import { retryExchange } from '@urql/exchange-retry'; +import { DocumentNode } from 'graphql'; +import fetch from 'node-fetch'; + +import * as Log from '../../log'; +import { getExpoApiBaseUrl } from '../api'; +import { getAccessToken, getSessionSecret } from '../user/sessionStorage'; + +type AccessTokenHeaders = { + authorization: string; +}; + +type SessionHeaders = { + 'expo-session': string; +}; + +export const graphqlClient = createUrqlClient({ + url: getExpoApiBaseUrl() + '/graphql', + exchanges: [ + dedupExchange, + cacheExchange, + retryExchange({ + maxDelayMs: 4000, + retryIf: (err) => + !!(err && (err.networkError || err.graphQLErrors.some((e) => e?.extensions?.isTransient))), + }), + fetchExchange, + ], + // @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise'. + fetch, + fetchOptions: (): { headers?: AccessTokenHeaders | SessionHeaders } => { + const token = getAccessToken(); + if (token) { + return { + headers: { + authorization: `Bearer ${token}`, + }, + }; + } + const sessionSecret = getSessionSecret(); + if (sessionSecret) { + return { + headers: { + 'expo-session': sessionSecret, + }, + }; + } + return {}; + }, +}) as StricterClient; + +/* Please specify additionalTypenames in your Graphql queries */ +export interface StricterClient extends Client { + // eslint-disable-next-line @typescript-eslint/ban-types + query( + query: DocumentNode | TypedDocumentNode | string, + variables: Variables | undefined, + context: Partial & { additionalTypenames: string[] } + ): PromisifiedSource>; +} + +export async function withErrorHandlingAsync(promise: Promise>): Promise { + const { data, error } = await promise; + + if (error) { + if (error.graphQLErrors.some((e) => e?.extensions?.isTransient)) { + Log.error(`We've encountered a transient error, please try again shortly.`); + } + throw error; + } + + // Check for a malformed response. This only checks the root query's existence. It doesn't affect + // returning responses with an empty result set. + if (!data) { + throw new Error('Returned query result data is null!'); + } + + return data; +} + +export { GraphqlError }; diff --git a/packages/expo/cli/utils/graphql/generated.ts b/packages/expo/cli/utils/graphql/generated.ts new file mode 100644 index 0000000000000..3f3c723f4ba56 --- /dev/null +++ b/packages/expo/cli/utils/graphql/generated.ts @@ -0,0 +1,773 @@ +/** + * This file was generated using GraphQL Codegen + * Command: yarn generate-graphql-code + * Run this during development for automatic type generation when editing GraphQL documents + * For more info and docs, visit https://graphql-code-generator.com/ + */ + +type Maybe = T | null; +/** All built-in and custom scalars, mapped to their actual values */ +type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + /** Date custom scalar type */ + DateTime: any; + /** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ + JSON: any; + /** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ + JSONObject: any; + /** The `Upload` scalar type represents a file upload. */ + Upload: any; +}; + +type Update = ActivityTimelineProjectActivity & { + __typename?: 'Update'; + id: Scalars['ID']; + actor?: Maybe; + activityTimestamp: Scalars['DateTime']; + branchId: Scalars['ID']; + platform: Scalars['String']; + manifestFragment: Scalars['String']; + runtimeVersion: Scalars['String']; + group: Scalars['String']; + updatedAt: Scalars['DateTime']; + createdAt: Scalars['DateTime']; + message?: Maybe; + branch: UpdateBranch; + manifestPermalink: Scalars['String']; +}; + +type ActivityTimelineProjectActivity = { + id: Scalars['ID']; + actor?: Maybe; + activityTimestamp: Scalars['DateTime']; +}; + +/** A user or robot that can authenticate with Expo services and be a member of accounts. */ +type Actor = { + id: Scalars['ID']; + firstName?: Maybe; + created: Scalars['DateTime']; + isExpoAdmin: Scalars['Boolean']; + /** + * Best-effort human readable name for this actor for use in user interfaces during action attribution. + * For example, when displaying a sentence indicating that actor X created a build or published an update. + */ + displayName: Scalars['String']; + /** Associated accounts */ + accounts: Array; + /** Access Tokens belonging to this actor */ + accessTokens: Array; + /** + * Server feature gate values for this actor, optionally filtering by desired gates. + * Only resolves for the viewer. + */ + featureGates: Scalars['JSONObject']; +}; + +/** + * An account is a container owning projects, credentials, billing and other organization + * data and settings. Actors may own and be members of accounts. + */ +type Account = { + __typename?: 'Account'; + id: Scalars['ID']; + name: Scalars['String']; + isCurrent: Scalars['Boolean']; + pushSecurityEnabled: Scalars['Boolean']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; + /** Offers set on this account */ + offers?: Maybe>; + /** Apps associated with this account */ + apps: Array; + appCount: Scalars['Int']; + /** Build Jobs associated with this account */ + buildJobs: Array; + /** + * Coalesced Build (EAS) or BuildJob (Classic) for all apps belonging to this account. + * @deprecated Use activityTimelineProjectActivities with filterTypes instead + */ + buildOrBuildJobs: Array; + /** Coalesced project activity for all apps belonging to this account. */ + activityTimelineProjectActivities: Array; + /** Owning User of this account if personal account */ + owner?: Maybe; + /** Actors associated with this account and permissions they hold */ + users: Array; + /** Pending user invitations for this account */ + userInvitations: Array; + /** iOS credentials for account */ + appleTeams: Array; + appleAppIdentifiers: Array; + appleDistributionCertificates: Array; + applePushKeys: Array; + appleProvisioningProfiles: Array; + appleDevices: Array; + appStoreConnectApiKeys: Array; + /** Android credentials for account */ + googleServiceAccountKeys: Array; + /** Environment secrets for an account */ + environmentSecrets: Array; + /** @deprecated Legacy access tokens are deprecated */ + accessTokens: Array>; + /** @deprecated Legacy access tokens are deprecated */ + requiresAccessTokenForPushSecurity: Scalars['Boolean']; + /** @deprecated See isCurrent */ + unlimitedBuilds: Scalars['Boolean']; + /** @deprecated Build packs are no longer supported */ + availableBuilds?: Maybe; + /** @deprecated No longer needed */ + subscriptionChangesPending?: Maybe; + /** @deprecated Build packs are no longer supported */ + willAutoRenewBuilds?: Maybe; +}; + +type Offer = { + __typename?: 'Offer'; + id: Scalars['ID']; + stripeId: Scalars['ID']; + price: Scalars['Int']; + quantity?: Maybe; + trialLength?: Maybe; + type: OfferType; + features?: Maybe>>; + prerequisite?: Maybe; +}; + +enum OfferType { + /** Term subscription */ + Subscription = 'SUBSCRIPTION', + /** Advanced Purchase of Paid Resource */ + Prepaid = 'PREPAID', + /** Addon, or supplementary subscription */ + Addon = 'ADDON', +} + +enum Feature { + /** Top Tier Support */ + Support = 'SUPPORT', + /** Share access to projects */ + Teams = 'TEAMS', + /** Priority Builds */ + Builds = 'BUILDS', + /** Funds support for open source development */ + OpenSource = 'OPEN_SOURCE', +} + +type OfferPrerequisite = { + __typename?: 'OfferPrerequisite'; + type: Scalars['String']; + stripeIds: Array; +}; + +type Project = { + id: Scalars['ID']; + name: Scalars['String']; + fullName: Scalars['String']; + description: Scalars['String']; + slug: Scalars['String']; + updated: Scalars['DateTime']; + published: Scalars['Boolean']; + username: Scalars['String']; + /** @deprecated Field no longer supported */ + iconUrl?: Maybe; +}; + +/** Represents an Exponent App (or Experience in legacy terms) */ +type App = Project & { + __typename?: 'App'; + id: Scalars['ID']; + name: Scalars['String']; + fullName: Scalars['String']; + description: Scalars['String']; + slug: Scalars['String']; + ownerAccount: Account; + privacySetting: AppPrivacy; + pushSecurityEnabled: Scalars['Boolean']; + /** Whether there have been any classic update publishes */ + published: Scalars['Boolean']; + /** Time of last classic update publish */ + updated: Scalars['DateTime']; + /** ID of latest classic update release */ + latestReleaseId: Scalars['ID']; + /** Whether the latest classic update publish is using a deprecated SDK version */ + isDeprecated: Scalars['Boolean']; + /** SDK version of the latest classic update publish, 0.0.0 otherwise */ + sdkVersion: Scalars['String']; + /** Classic update release channel names (to be removed) */ + releaseChannels: Array; + /** Classic update release channel names that have at least one build */ + buildsReleaseChannels: Array; + /** githubUrl field from most recent classic update manifest */ + githubUrl?: Maybe; + /** android.playStoreUrl field from most recent classic update manifest */ + playStoreUrl?: Maybe; + /** ios.appStoreUrl field from most recent classic update manifest */ + appStoreUrl?: Maybe; + /** Info about the icon specified in the most recent classic update manifest */ + icon?: Maybe; + /** iOS app credentials for the project */ + iosAppCredentials: Array; + /** Android app credentials for the project */ + androidAppCredentials: Array; + /** Coalesced project activity for an app */ + activityTimelineProjectActivities: Array; + /** Environment secrets for an app */ + environmentSecrets: Array; + /** Webhooks for an app */ + webhooks: Array; + /** @deprecated Use ownerAccount.name instead */ + username: Scalars['String']; + /** @deprecated Field no longer supported */ + iconUrl?: Maybe; + /** @deprecated Use 'privacySetting' instead. */ + privacy: Scalars['String']; + /** @deprecated Field no longer supported */ + lastPublishedTime: Scalars['DateTime']; + /** @deprecated Field no longer supported */ + packageUsername: Scalars['String']; + /** @deprecated Field no longer supported */ + packageName: Scalars['String']; + /** @deprecated Legacy access tokens are deprecated */ + accessTokens: Array>; + /** @deprecated Legacy access tokens are deprecated */ + requiresAccessTokenForPushSecurity: Scalars['Boolean']; + /** @deprecated 'likes' have been deprecated. */ + isLikedByMe: Scalars['Boolean']; + /** @deprecated 'likes' have been deprecated. */ + likeCount: Scalars['Int']; + /** @deprecated 'likes' have been deprecated. */ + trendScore: Scalars['Float']; + /** @deprecated 'likes' have been deprecated. */ + likedBy: Array>; + /** @deprecated Field no longer supported */ + users?: Maybe>>; + /** @deprecated Field no longer supported */ + releases: Array>; + latestReleaseForReleaseChannel?: Maybe; +}; + +enum AppPrivacy { + Public = 'PUBLIC', + Unlisted = 'UNLISTED', + Hidden = 'HIDDEN', +} + +type AppIcon = { + __typename?: 'AppIcon'; + url: Scalars['String']; + primaryColor?: Maybe; + originalUrl: Scalars['String']; + /** Nullable color palette of the app icon. If null, color palette couldn't be retrieved from external service (imgix) */ + colorPalette?: Maybe; +}; + +enum AppPlatform { + Ios = 'IOS', + Android = 'ANDROID', +} + +type BuildOrBuildJob = { + id: Scalars['ID']; +}; + +/** Represents a human (not robot) actor. */ +type User = Actor & { + __typename?: 'User'; + id: Scalars['ID']; + username: Scalars['String']; + email?: Maybe; + firstName?: Maybe; + lastName?: Maybe; + fullName?: Maybe; + profilePhoto: Scalars['String']; + created: Scalars['DateTime']; + industry?: Maybe; + location?: Maybe; + appCount: Scalars['Int']; + githubUsername?: Maybe; + twitterUsername?: Maybe; + appetizeCode?: Maybe; + emailVerified: Scalars['Boolean']; + isExpoAdmin: Scalars['Boolean']; + displayName: Scalars['String']; + isSecondFactorAuthenticationEnabled: Scalars['Boolean']; + /** Get all certified second factor authentication methods */ + secondFactorDevices: Array; + /** Associated accounts */ + primaryAccount: Account; + accounts: Array; + /** Access Tokens belonging to this actor */ + accessTokens: Array; + /** Apps this user has published */ + apps: Array; + /** Whether this user has any pending user invitations. Only resolves for the viewer. */ + hasPendingUserInvitations: Scalars['Boolean']; + /** Pending UserInvitations for this user. Only resolves for the viewer. */ + pendingUserInvitations: Array; + /** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */ + activityTimelineProjectActivities: Array; + /** + * Server feature gate values for this actor, optionally filtering by desired gates. + * Only resolves for the viewer. + */ + featureGates: Scalars['JSONObject']; + /** @deprecated Field no longer supported */ + isEmailUnsubscribed: Scalars['Boolean']; + /** @deprecated Field no longer supported */ + lastPasswordReset?: Maybe; + /** @deprecated Field no longer supported */ + lastLogin?: Maybe; + /** @deprecated Field no longer supported */ + isOnboarded?: Maybe; + /** @deprecated Field no longer supported */ + isLegacy?: Maybe; + /** @deprecated Field no longer supported */ + wasLegacy?: Maybe; + /** @deprecated 'likes' have been deprecated. */ + likes?: Maybe>>; +}; + +/** A second factor device belonging to a User */ +type UserSecondFactorDevice = { + __typename?: 'UserSecondFactorDevice'; + id: Scalars['ID']; + user: User; + name: Scalars['String']; + isCertified: Scalars['Boolean']; + isPrimary: Scalars['Boolean']; + smsPhoneNumber?: Maybe; + method: SecondFactorMethod; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +enum SecondFactorMethod { + /** Google Authenticator (TOTP) */ + Authenticator = 'AUTHENTICATOR', + /** SMS */ + Sms = 'SMS', +} + +/** A method of authentication for an Actor */ +type AccessToken = { + __typename?: 'AccessToken'; + id: Scalars['ID']; + visibleTokenPrefix: Scalars['String']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; + revokedAt?: Maybe; + lastUsedAt?: Maybe; + owner: Actor; + note?: Maybe; +}; + +/** An pending invitation sent to an email granting membership on an Account. */ +type UserInvitation = { + __typename?: 'UserInvitation'; + id: Scalars['ID']; + /** Email to which this invitation was sent */ + email: Scalars['String']; + created: Scalars['DateTime']; + accountName: Scalars['String']; + /** Account permissions to be granted upon acceptance of this invitation */ + permissions: Array; + /** Role to be granted upon acceptance of this invitation */ + role: Role; +}; + +enum Permission { + Own = 'OWN', + Admin = 'ADMIN', + Publish = 'PUBLISH', + View = 'VIEW', +} + +enum Role { + Owner = 'OWNER', + Admin = 'ADMIN', + Developer = 'DEVELOPER', + ViewOnly = 'VIEW_ONLY', + Custom = 'CUSTOM', + HasAdmin = 'HAS_ADMIN', + NotAdmin = 'NOT_ADMIN', +} + +/** Represents an Standalone App build job */ +type BuildJob = ActivityTimelineProjectActivity & + BuildOrBuildJob & { + __typename?: 'BuildJob'; + id: Scalars['ID']; + actor?: Maybe; + activityTimestamp: Scalars['DateTime']; + app?: Maybe; + user?: Maybe; + release?: Maybe; + config?: Maybe; + artifacts?: Maybe; + logs?: Maybe; + created?: Maybe; + updated?: Maybe; + fullExperienceName?: Maybe; + status?: Maybe; + expirationDate?: Maybe; + platform: AppPlatform; + sdkVersion?: Maybe; + releaseChannel?: Maybe; + }; + +type AppRelease = { + __typename?: 'AppRelease'; + id: Scalars['ID']; + hash: Scalars['String']; + publishedTime: Scalars['DateTime']; + publishingUsername: Scalars['String']; + sdkVersion: Scalars['String']; + runtimeVersion?: Maybe; + version: Scalars['String']; + s3Key: Scalars['String']; + s3Url: Scalars['String']; + manifest: Scalars['JSON']; +}; + +type BuildArtifact = { + __typename?: 'BuildArtifact'; + url: Scalars['String']; + manifestPlistUrl?: Maybe; +}; + +type BuildLogs = { + __typename?: 'BuildLogs'; + url?: Maybe; + format?: Maybe; +}; + +enum BuildJobLogsFormat { + Raw = 'RAW', + Json = 'JSON', +} + +enum BuildJobStatus { + Pending = 'PENDING', + Started = 'STARTED', + InProgress = 'IN_PROGRESS', + Errored = 'ERRORED', + Finished = 'FINISHED', + SentToQueue = 'SENT_TO_QUEUE', +} + +type UpdateBranch = { + __typename?: 'UpdateBranch'; + id: Scalars['ID']; + appId: Scalars['ID']; + name: Scalars['String']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; + updates: Array; +}; + +type IosAppCredentials = { + __typename?: 'IosAppCredentials'; + id: Scalars['ID']; + app: App; + appleTeam?: Maybe; + appleAppIdentifier: AppleAppIdentifier; + iosAppBuildCredentialsList: Array; + pushKey?: Maybe; + appStoreConnectApiKeyForSubmissions?: Maybe; + /** @deprecated use iosAppBuildCredentialsList instead */ + iosAppBuildCredentialsArray: Array; +}; + +type AppleTeam = { + __typename?: 'AppleTeam'; + id: Scalars['ID']; + account: Account; + appleTeamIdentifier: Scalars['String']; + appleTeamName?: Maybe; + appleAppIdentifiers: Array; + appleDistributionCertificates: Array; + applePushKeys: Array; + appleProvisioningProfiles: Array; + appleDevices: Array; +}; + +type AppleAppIdentifier = { + __typename?: 'AppleAppIdentifier'; + id: Scalars['ID']; + account: Account; + appleTeam?: Maybe; + bundleIdentifier: Scalars['String']; + parentAppleAppIdentifier?: Maybe; +}; + +type AppleDistributionCertificate = { + __typename?: 'AppleDistributionCertificate'; + id: Scalars['ID']; + account: Account; + appleTeam?: Maybe; + serialNumber: Scalars['String']; + validityNotBefore: Scalars['DateTime']; + validityNotAfter: Scalars['DateTime']; + developerPortalIdentifier?: Maybe; + certificateP12?: Maybe; + certificatePassword?: Maybe; + certificatePrivateSigningKey?: Maybe; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; + iosAppBuildCredentialsList: Array; +}; + +type IosAppBuildCredentials = { + __typename?: 'IosAppBuildCredentials'; + id: Scalars['ID']; + distributionCertificate?: Maybe; + provisioningProfile?: Maybe; + iosDistributionType: IosDistributionType; + iosAppCredentials: IosAppCredentials; + /** @deprecated Get Apple Devices from AppleProvisioningProfile instead */ + appleDevices?: Maybe>>; +}; + +type AppleProvisioningProfile = { + __typename?: 'AppleProvisioningProfile'; + id: Scalars['ID']; + account: Account; + appleTeam?: Maybe; + expiration: Scalars['DateTime']; + appleAppIdentifier: AppleAppIdentifier; + developerPortalIdentifier?: Maybe; + provisioningProfile?: Maybe; + appleUUID: Scalars['String']; + status: Scalars['String']; + appleDevices: Array; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +type AppleDevice = { + __typename?: 'AppleDevice'; + id: Scalars['ID']; + account: Account; + appleTeam: AppleTeam; + identifier: Scalars['String']; + name?: Maybe; + model?: Maybe; + deviceClass?: Maybe; + softwareVersion?: Maybe; + enabled?: Maybe; +}; + +enum AppleDeviceClass { + Ipad = 'IPAD', + Iphone = 'IPHONE', +} + +enum IosDistributionType { + AppStore = 'APP_STORE', + Enterprise = 'ENTERPRISE', + AdHoc = 'AD_HOC', + Development = 'DEVELOPMENT', +} + +type ApplePushKey = { + __typename?: 'ApplePushKey'; + id: Scalars['ID']; + account: Account; + appleTeam?: Maybe; + keyIdentifier: Scalars['String']; + keyP8: Scalars['String']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; + iosAppCredentialsList: Array; +}; + +type AppStoreConnectApiKey = { + __typename?: 'AppStoreConnectApiKey'; + id: Scalars['ID']; + account: Account; + appleTeam?: Maybe; + issuerIdentifier: Scalars['String']; + keyIdentifier: Scalars['String']; + name?: Maybe; + roles?: Maybe>; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +enum AppStoreConnectUserRole { + Admin = 'ADMIN', + Finance = 'FINANCE', + Technical = 'TECHNICAL', + AccountHolder = 'ACCOUNT_HOLDER', + ReadOnly = 'READ_ONLY', + Sales = 'SALES', + Marketing = 'MARKETING', + AppManager = 'APP_MANAGER', + Developer = 'DEVELOPER', + AccessToReports = 'ACCESS_TO_REPORTS', + CustomerSupport = 'CUSTOMER_SUPPORT', + CreateApps = 'CREATE_APPS', + CloudManagedDeveloperId = 'CLOUD_MANAGED_DEVELOPER_ID', + CloudManagedAppDistribution = 'CLOUD_MANAGED_APP_DISTRIBUTION', + ImageManager = 'IMAGE_MANAGER', + Unknown = 'UNKNOWN', +} + +type AndroidAppCredentials = { + __typename?: 'AndroidAppCredentials'; + id: Scalars['ID']; + app: App; + applicationIdentifier?: Maybe; + androidFcm?: Maybe; + googleServiceAccountKeyForSubmissions?: Maybe; + androidAppBuildCredentialsList: Array; + isLegacy: Scalars['Boolean']; + /** @deprecated use androidAppBuildCredentialsList instead */ + androidAppBuildCredentialsArray: Array; +}; + +type AndroidFcm = { + __typename?: 'AndroidFcm'; + id: Scalars['ID']; + account: Account; + snippet: FcmSnippet; + /** + * Legacy FCM: returns the Cloud Messaging token, parses to a String + * FCM v1: returns the Service Account Key file, parses to an Object + */ + credential: Scalars['JSON']; + version: AndroidFcmVersion; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +type FcmSnippet = FcmSnippetLegacy | FcmSnippetV1; + +type FcmSnippetLegacy = { + __typename?: 'FcmSnippetLegacy'; + firstFourCharacters: Scalars['String']; + lastFourCharacters: Scalars['String']; +}; + +type FcmSnippetV1 = { + __typename?: 'FcmSnippetV1'; + projectId: Scalars['String']; + keyId: Scalars['String']; + serviceAccountEmail: Scalars['String']; + clientId?: Maybe; +}; + +enum AndroidFcmVersion { + Legacy = 'LEGACY', + V1 = 'V1', +} + +type GoogleServiceAccountKey = { + __typename?: 'GoogleServiceAccountKey'; + id: Scalars['ID']; + account: Account; + projectIdentifier: Scalars['String']; + privateKeyIdentifier: Scalars['String']; + clientEmail: Scalars['String']; + clientIdentifier: Scalars['String']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +type AndroidAppBuildCredentials = { + __typename?: 'AndroidAppBuildCredentials'; + id: Scalars['ID']; + name: Scalars['String']; + androidKeystore?: Maybe; + isDefault: Scalars['Boolean']; + isLegacy: Scalars['Boolean']; +}; + +type AndroidKeystore = { + __typename?: 'AndroidKeystore'; + id: Scalars['ID']; + account: Account; + type: AndroidKeystoreType; + keystore: Scalars['String']; + keystorePassword: Scalars['String']; + keyAlias: Scalars['String']; + keyPassword?: Maybe; + md5CertificateFingerprint?: Maybe; + sha1CertificateFingerprint?: Maybe; + sha256CertificateFingerprint?: Maybe; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +enum AndroidKeystoreType { + Jks = 'JKS', + Pkcs12 = 'PKCS12', + Unknown = 'UNKNOWN', +} + +type EnvironmentSecret = { + __typename?: 'EnvironmentSecret'; + id: Scalars['ID']; + name: Scalars['String']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +enum WebhookType { + Build = 'BUILD', + Submit = 'SUBMIT', +} + +type Webhook = { + __typename?: 'Webhook'; + id: Scalars['ID']; + appId: Scalars['ID']; + event: WebhookType; + url: Scalars['String']; + createdAt: Scalars['DateTime']; + updatedAt: Scalars['DateTime']; +}; + +type UserPermission = { + __typename?: 'UserPermission'; + permissions: Array; + role?: Maybe; + /** @deprecated User type is deprecated */ + user?: Maybe; + actor: Actor; +}; + +/** Represents a robot (not human) actor. */ +type Robot = Actor & { + __typename?: 'Robot'; + id: Scalars['ID']; + firstName?: Maybe; + created: Scalars['DateTime']; + isExpoAdmin: Scalars['Boolean']; + displayName: Scalars['String']; + /** Associated accounts */ + accounts: Array; + /** Access Tokens belonging to this actor */ + accessTokens: Array; + /** + * Server feature gate values for this actor, optionally filtering by desired gates. + * Only resolves for the viewer. + */ + featureGates: Scalars['JSONObject']; +}; + +export type CurrentUserQuery = { __typename?: 'RootQuery' } & { + meActor?: Maybe< + | ({ __typename: 'User' } & Pick & { + accounts: Array<{ __typename?: 'Account' } & Pick>; + }) + | ({ __typename: 'Robot' } & Pick & { + accounts: Array<{ __typename?: 'Account' } & Pick>; + }) + >; +}; diff --git a/packages/expo/cli/utils/graphql/queries/UserQuery.ts b/packages/expo/cli/utils/graphql/queries/UserQuery.ts new file mode 100644 index 0000000000000..cc9bb98e61011 --- /dev/null +++ b/packages/expo/cli/utils/graphql/queries/UserQuery.ts @@ -0,0 +1,40 @@ +import gql from 'graphql-tag'; + +import { graphqlClient, withErrorHandlingAsync } from '../client'; +import { CurrentUserQuery } from '../generated'; + +export const UserQuery = { + async currentUserAsync(): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query CurrentUser { + meActor { + __typename + id + ... on User { + username + } + ... on Robot { + firstName + } + accounts { + id + name + } + isExpoAdmin + } + } + `, + /* variables */ undefined, + { + additionalTypenames: ['User'], + } + ) + .toPromise() + ); + + return data.meActor; + }, +}; diff --git a/packages/expo/cli/utils/link.ts b/packages/expo/cli/utils/link.ts index 998efebad17a5..68fe723a96bf8 100644 --- a/packages/expo/cli/utils/link.ts +++ b/packages/expo/cli/utils/link.ts @@ -2,14 +2,38 @@ import chalk from 'chalk'; import terminalLink from 'terminal-link'; /** - * When linking isn't available, format the learn more link better. + * Prints a link for given URL, using text if provided, otherwise text is just the URL. + * Format links as dim (unless disabled) and with an underline. + * + * @example https://expo.dev + */ +export function link( + url: string, + { text = url, dim = true }: { text?: string; dim?: boolean } = {} +): string { + let output: string; + // Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js + if (terminalLink.isSupported) { + output = terminalLink(text, url); + } else { + output = `${text === url ? '' : text + ': '}${chalk.underline(url)}`; + } + return dim ? chalk.dim(output) : output; +} + +/** + * Provide a consistent "Learn more" link experience. + * Format links as dim (unless disabled) with an underline. * * @example [Learn more](https://expo.dev) * @example Learn more: https://expo.dev - * @param url */ -export function learnMore(url: string): string { - return terminalLink(chalk.underline('Learn more.'), url, { - fallback: (text, url) => `Learn more: ${chalk.underline(url)}`, - }); +export function learnMore( + url: string, + { + learnMoreMessage: maybeLearnMoreMessage, + dim = true, + }: { learnMoreMessage?: string; dim?: boolean } = {} +): string { + return link(url, { text: maybeLearnMoreMessage ?? 'Learn more', dim }); } diff --git a/packages/expo/cli/utils/prompts.ts b/packages/expo/cli/utils/prompts.ts index 6a69587e09c68..30494288f3f3b 100644 --- a/packages/expo/cli/utils/prompts.ts +++ b/packages/expo/cli/utils/prompts.ts @@ -1,4 +1,4 @@ -import prompts, { Options, PromptObject, PromptType } from 'prompts'; +import prompts, { Options, Choice, PromptObject, PromptType } from 'prompts'; import { CI } from './env'; import { AbortCommandError, CommandError } from './errors'; @@ -7,17 +7,22 @@ export type Question = PromptObject & { optionsPerPage?: number; }; +export interface ExpoChoice extends Choice { + value: T; +} + export { PromptType }; type PromptOptions = { nonInteractiveHelp?: string } & Options; +// TODO: rename to `promptAsync` export default function prompt( questions: Question | Question[], { nonInteractiveHelp, ...options }: PromptOptions = {} ) { questions = Array.isArray(questions) ? questions : [questions]; if (CI && questions.length !== 0) { - let message = `Input is required, but Expo CLI is in non-interactive mode.\n`; + let message = `Input is required, but 'npx expo' is in non-interactive mode.\n`; if (nonInteractiveHelp) { message += nonInteractiveHelp; } else { @@ -62,3 +67,23 @@ export async function confirmAsync( ); return value ?? null; } + +/** Select an option from a list of options. */ +export async function selectAsync( + message: string, + choices: ExpoChoice[], + options?: PromptOptions +): Promise { + const { value } = await prompt( + { + message, + choices, + name: 'value', + type: 'select', + }, + options + ); + return value ?? null; +} + +export const promptAsync = prompt; diff --git a/packages/expo/cli/utils/user/UserSettings.ts b/packages/expo/cli/utils/user/UserSettings.ts new file mode 100644 index 0000000000000..4321664a40333 --- /dev/null +++ b/packages/expo/cli/utils/user/UserSettings.ts @@ -0,0 +1,38 @@ +import JsonFile from '@expo/json-file'; +import os from 'os'; +import path from 'path'; +import process from 'process'; + +// Extracted from https://github.com/sindresorhus/env-paths/blob/main/index.js +function getConfigDirectory() { + // Share data between eas-cli and expo. + const name = 'eas-cli'; + const homedir = os.homedir(); + + if (process.platform === 'darwin') { + const library = path.join(homedir, 'Library'); + return path.join(library, 'Preferences', name); + } + + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'); + return path.join(appData, name, 'Config'); + } + + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + return path.join(process.env.XDG_CONFIG_HOME || path.join(homedir, '.config'), name); +} + +const SETTINGS_FILE_PATH = path.join(getConfigDirectory(), 'user-settings.json'); + +export type UserSettingsData = { + analyticsDeviceId?: string; +}; + +const UserSettings = new JsonFile(SETTINGS_FILE_PATH, { + jsonParseErrorDefault: {}, + cantReadFileDefault: {}, + ensureDir: true, +}); + +export default UserSettings; diff --git a/packages/expo/cli/utils/user/__mocks__/user.ts b/packages/expo/cli/utils/user/__mocks__/user.ts new file mode 100644 index 0000000000000..c9187b138b0ef --- /dev/null +++ b/packages/expo/cli/utils/user/__mocks__/user.ts @@ -0,0 +1,2 @@ +export const getUserAsync = jest.fn(); +export const loginAsync = jest.fn(); diff --git a/packages/expo/cli/utils/user/__tests__/actions-test.ts b/packages/expo/cli/utils/user/__tests__/actions-test.ts new file mode 100644 index 0000000000000..c4cb7fc64dee0 --- /dev/null +++ b/packages/expo/cli/utils/user/__tests__/actions-test.ts @@ -0,0 +1,79 @@ +import { ApiV2Error } from '../../api'; +import { promptAsync } from '../../prompts'; +import { showLoginPromptAsync } from '../actions'; +import { retryUsernamePasswordAuthWithOTPAsync, UserSecondFactorDeviceMethod } from '../otp'; +import { loginAsync } from '../user'; + +jest.mock('../../prompts'); +jest.mock('../../api', () => { + const { ApiV2Error } = jest.requireActual('../../api'); + return { + ApiV2Error, + }; +}); +jest.mock('../otp'); +jest.mock('../user'); + +const asMock = (fn: any): jest.Mock => fn as jest.Mock; + +beforeEach(() => { + asMock(promptAsync).mockReset(); + asMock(promptAsync).mockImplementation(() => { + throw new Error('Should not be called'); + }); + + asMock(loginAsync).mockReset(); +}); + +describe(showLoginPromptAsync, () => { + it('prompts for OTP when 2FA is enabled', async () => { + asMock(promptAsync) + .mockImplementationOnce(() => ({ username: 'hello', password: 'world' })) + .mockImplementationOnce(() => ({ otp: '123456' })) + .mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + asMock(loginAsync) + .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).toHaveBeenCalledWith('hello', 'world', { + secondFactorDevices: [ + { + id: 'p0', + is_primary: true, + method: UserSecondFactorDeviceMethod.SMS, + sms_phone_number: 'testphone', + }, + ], + smsAutomaticallySent: true, + }); + }); + + it('does not prompt if all required credentials are provided', async () => { + asMock(promptAsync).mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + asMock(loginAsync).mockImplementation(() => {}); + + await showLoginPromptAsync({ username: 'hello', password: 'world' }); + }); +}); diff --git a/packages/expo/cli/utils/user/__tests__/otp-test.ts b/packages/expo/cli/utils/user/__tests__/otp-test.ts new file mode 100644 index 0000000000000..16ee616c4ffc9 --- /dev/null +++ b/packages/expo/cli/utils/user/__tests__/otp-test.ts @@ -0,0 +1,256 @@ +import * as Log from '../../../log'; +import { apiClient } from '../../api'; +import { promptAsync, selectAsync } from '../../prompts'; +import { retryUsernamePasswordAuthWithOTPAsync, UserSecondFactorDeviceMethod } from '../otp'; +import { loginAsync } from '../user'; + +jest.mock('../../prompts'); +jest.mock('../../api'); +jest.mock('../user'); +jest.mock('../../../log'); + +const asMock = (fn: any): jest.Mock => fn as jest.Mock; + +beforeEach(() => { + asMock(promptAsync).mockReset(); + asMock(promptAsync).mockImplementation(() => { + throw new Error('Should not be called'); + }); + + asMock(selectAsync).mockReset(); + asMock(selectAsync).mockImplementation(() => { + throw new Error('Should not be called'); + }); + + asMock(loginAsync).mockReset(); + asMock(Log.log).mockReset(); +}); + +describe(retryUsernamePasswordAuthWithOTPAsync, () => { + it('shows SMS OTP prompt when SMS is primary and code was automatically sent', async () => { + asMock(promptAsync) + .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(Log.log).toHaveBeenCalledWith( + 'One-time password was sent to the phone number ending in testphone.' + ); + + expect(asMock(loginAsync)).toHaveBeenCalledTimes(1); + }); + + it('shows authenticator OTP prompt when authenticator is primary', async () => { + asMock(promptAsync) + .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: null, + }, + ], + smsAutomaticallySent: false, + }); + + expect(Log.log).toHaveBeenCalledWith('One-time password from authenticator required.'); + expect(asMock(loginAsync)).toHaveBeenCalledTimes(1); + }); + + it('shows menu when user bails on primary', async () => { + asMock(promptAsync) + .mockImplementationOnce(() => ({ otp: null })) + .mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code + .mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + + asMock(selectAsync) + .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: null, + }, + { + id: 'p2', + is_primary: false, + method: UserSecondFactorDeviceMethod.AUTHENTICATOR, + sms_phone_number: null, + }, + ], + smsAutomaticallySent: false, + }); + + expect(asMock(selectAsync).mock.calls.length).toEqual(1); + expect(asMock(loginAsync)).toHaveBeenCalledTimes(1); + }); + + it('shows a warning when when user bails on primary and does not have any secondary set up', async () => { + asMock(promptAsync) + .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: null, + }, + ], + 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 () => { + asMock(promptAsync) + .mockImplementationOnce(() => ({ otp: null })) + .mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code + .mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + + asMock(selectAsync) + .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: null, + }, + { + id: 'p2', + is_primary: false, + method: UserSecondFactorDeviceMethod.AUTHENTICATOR, + sms_phone_number: null, + }, + ], + smsAutomaticallySent: false, + }); + + expect(asMock(promptAsync).mock.calls.length).toBe(2); // first OTP, second OTP + }); + + it('requests SMS OTP and prompts for SMS OTP when user selects SMS secondary', async () => { + asMock(promptAsync) + .mockImplementationOnce(() => ({ otp: null })) + .mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code + .mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + + asMock(selectAsync) + .mockImplementationOnce(() => 0) + .mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + + asMock(apiClient.post).mockReturnValueOnce({ + json: () => Promise.resolve({ data: { sessionSecret: 'SESSION_SECRET' } }), + }); + + await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { + secondFactorDevices: [ + { + id: 'p0', + is_primary: true, + method: UserSecondFactorDeviceMethod.AUTHENTICATOR, + sms_phone_number: null, + }, + { + id: 'p2', + is_primary: false, + method: UserSecondFactorDeviceMethod.SMS, + sms_phone_number: 'wat', + }, + ], + smsAutomaticallySent: false, + }); + + expect(asMock(promptAsync).mock.calls.length).toBe(2); // first OTP, second OTP + expect(asMock(apiClient.post).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 () => { + asMock(promptAsync) + .mockImplementationOnce(() => ({ otp: null })) + .mockImplementation(() => { + throw new Error("shouldn't happen"); + }); + + asMock(selectAsync) + .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: null, + }, + { + id: 'p2', + is_primary: false, + method: UserSecondFactorDeviceMethod.AUTHENTICATOR, + sms_phone_number: null, + }, + ], + smsAutomaticallySent: false, + }) + ).rejects.toThrowError('Interactive prompt was cancelled.'); + }); +}); diff --git a/packages/expo/cli/utils/user/__tests__/sessionStorage-test.ts b/packages/expo/cli/utils/user/__tests__/sessionStorage-test.ts new file mode 100644 index 0000000000000..e277113008318 --- /dev/null +++ b/packages/expo/cli/utils/user/__tests__/sessionStorage-test.ts @@ -0,0 +1,66 @@ +import { getUserStatePath } from '@expo/config/build/getUserState'; +import fs from 'fs-extra'; +import { vol } from 'memfs'; +import path from 'path'; + +import { getAccessToken, getSession, getSessionSecret, setSessionAsync } from '../sessionStorage'; + +jest.mock('fs'); + +const authStub: any = { + sessionSecret: 'SESSION_SECRET', + userId: 'USER_ID', + username: 'USERNAME', + currentConnection: 'Username-Password-Authentication', +}; + +beforeEach(() => { + vol.reset(); +}); + +describe(getSession, () => { + it('returns null when session is not stored', () => { + expect(getSession()).toBeNull(); + }); + + it('returns stored session data', async () => { + await fs.mkdirp(path.dirname(getUserStatePath())); + await fs.writeJSON(getUserStatePath(), { auth: authStub }); + expect(getSession()).toMatchObject(authStub); + }); +}); + +describe(setSessionAsync, () => { + it('stores empty session data', async () => { + await setSessionAsync(); + expect(await fs.pathExists(getUserStatePath())).toBeTruthy(); + }); + + it('stores actual session data', async () => { + await setSessionAsync(authStub); + expect(await fs.readJSON(getUserStatePath())).toMatchObject({ auth: authStub }); + }); +}); + +describe(getAccessToken, () => { + it('returns null when envvar is undefined', () => { + expect(getAccessToken()).toBeNull(); + }); + + it('returns token when envar is defined', () => { + process.env.EXPO_TOKEN = 'mytesttoken'; + expect(getAccessToken()).toBe('mytesttoken'); + process.env.EXPO_TOKEN = undefined; + }); +}); + +describe(getSessionSecret, () => { + it('returns null when session is not stored', () => { + expect(getSessionSecret()).toBeNull(); + }); + + it('returns secret when session is stored', async () => { + await setSessionAsync(authStub); + expect(getSessionSecret()).toBe(authStub.sessionSecret); + }); +}); diff --git a/packages/expo/cli/utils/user/__tests__/user-test.ts b/packages/expo/cli/utils/user/__tests__/user-test.ts new file mode 100644 index 0000000000000..d81a19f2eb1cf --- /dev/null +++ b/packages/expo/cli/utils/user/__tests__/user-test.ts @@ -0,0 +1,120 @@ +import { getUserStatePath } from '@expo/config/build/getUserState'; +import fs from 'fs-extra'; +import { vol } from 'memfs'; + +import { + Actor, + getActorDisplayName, + getSessionSecret, + getUserAsync, + loginAsync, + logoutAsync, +} from '../user'; + +jest.mock('fs'); +jest.mock('../../api', () => ({ + apiClient: { + post: jest.fn(() => { + return { + json: () => Promise.resolve({ data: { sessionSecret: 'SESSION_SECRET' } }), + }; + }), + }, +})); +jest.mock('../../graphql/client', () => ({ + graphqlClient: { + query: () => { + return { + toPromise: () => + Promise.resolve({ data: { viewer: { id: 'USER_ID', username: 'USERNAME' } } }), + }; + }, + }, +})); +jest.mock('../../graphql/queries/UserQuery', () => ({ + UserQuery: { + currentUserAsync: async () => ({ __typename: 'User', username: 'USERNAME', id: 'USER_ID' }), + }, +})); + +beforeEach(() => { + vol.reset(); +}); + +const userStub: Actor = { + __typename: 'User', + id: 'userId', + username: 'username', + accounts: [], + isExpoAdmin: false, +}; + +const robotStub: Actor = { + __typename: 'Robot', + id: 'userId', + firstName: 'GLaDOS', + accounts: [], + isExpoAdmin: false, +}; + +describe(getUserAsync, () => { + it('skips fetching user without access token or session secret', async () => { + expect(await getUserAsync()).toBeUndefined(); + }); + + it('fetches user when access token is defined', async () => { + process.env.EXPO_TOKEN = 'accesstoken'; + expect(await getUserAsync()).toMatchObject({ __typename: 'User' }); + }); + + it('fetches user when session secret is defined', async () => { + await loginAsync({ username: 'USERNAME', password: 'PASSWORD' }); + expect(await getUserAsync()).toMatchObject({ __typename: 'User' }); + }); +}); + +describe(loginAsync, () => { + it('saves user data to ~/.expo/state.json', async () => { + await loginAsync({ username: 'USERNAME', password: 'PASSWORD' }); + + expect(await fs.readFile(getUserStatePath(), 'utf8')).toMatchInlineSnapshot(` + "{ + \\"auth\\": { + \\"sessionSecret\\": \\"SESSION_SECRET\\", + \\"userId\\": \\"USER_ID\\", + \\"username\\": \\"USERNAME\\", + \\"currentConnection\\": \\"Username-Password-Authentication\\" + } + } + " + `); + }); +}); + +describe(logoutAsync, () => { + it('removes the session secret', async () => { + await loginAsync({ username: 'USERNAME', password: 'PASSWORD' }); + expect(getSessionSecret()).toBe('SESSION_SECRET'); + + await logoutAsync(); + expect(getSessionSecret()).toBe(null); + }); +}); + +describe(getActorDisplayName, () => { + it('returns anonymous for unauthenticated users', () => { + expect(getActorDisplayName()).toBe('anonymous'); + }); + + it('returns username for user actors', () => { + expect(getActorDisplayName(userStub)).toBe(userStub.username); + }); + + it('returns firstName with robot prefix for robot actors', () => { + expect(getActorDisplayName(robotStub)).toBe(`${robotStub.firstName} (robot)`); + }); + + it('returns robot prefix only for robot actors without firstName', () => { + expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot'); + }); +}); diff --git a/packages/expo/cli/utils/user/actions.ts b/packages/expo/cli/utils/user/actions.ts new file mode 100644 index 0000000000000..80a162bc6e43a --- /dev/null +++ b/packages/expo/cli/utils/user/actions.ts @@ -0,0 +1,88 @@ +import assert from 'assert'; +import chalk from 'chalk'; + +import * as Log from '../../log'; +import { ApiV2Error } from '../api'; +import { learnMore } from '../link'; +import promptAsync, { Question } from '../prompts'; +import { retryUsernamePasswordAuthWithOTPAsync } from './otp'; +import { Actor, getUserAsync, loginAsync } from './user'; + +/** Show login prompt while prompting for missing credentials. */ +export async function showLoginPromptAsync({ + printNewLine = false, + otp, + ...options +}: { + printNewLine?: boolean; + username?: string; + password?: string; + otp?: string; +} = {}): Promise { + const hasCredentials = options.username && options.password; + + if (printNewLine) { + Log.log(); + } + + Log.log(hasCredentials ? 'Logging in to EAS' : 'Log in to EAS'); + + let username = options.username; + let password = options.password; + + if (!hasCredentials) { + const resolved = await promptAsync( + [ + !options.username && { + type: 'text', + name: 'username', + message: 'Email or username', + }, + !options.password && { + type: 'password', + name: 'password', + message: 'Password', + }, + ].filter(Boolean) as Question[], + { + nonInteractiveHelp: `Use the EXPO_TOKEN environment variable to authenticate in CI (${learnMore( + 'https://docs.expo.dev/accounts/programmatic-access/' + )})`, + } + ); + username = resolved.username ?? options.username; + password = resolved.password ?? options.password; + } + + try { + await loginAsync({ + username, + password, + otp, + }); + } catch (e) { + if (e instanceof ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') { + await retryUsernamePasswordAuthWithOTPAsync( + username, + password, + e.expoApiV2ErrorMetadata as any + ); + } else { + throw e; + } + } +} + +/** Ensure the user is logged in, if not, prompt to login. */ +export async function ensureLoggedInAsync(): Promise { + let user = await getUserAsync().catch(() => null); + + if (!user) { + Log.warn(chalk.yellow`An Expo user account is required to proceed.`); + await showLoginPromptAsync({ printNewLine: true }); + user = await getUserAsync(); + } + + assert(user, 'User should be logged in'); + return user; +} diff --git a/packages/expo/cli/utils/user/otp.ts b/packages/expo/cli/utils/user/otp.ts new file mode 100644 index 0000000000000..936c4333c2831 --- /dev/null +++ b/packages/expo/cli/utils/user/otp.ts @@ -0,0 +1,175 @@ +import assert from 'assert'; +import chalk from 'chalk'; + +import * as Log from '../../log'; +import { apiClient } from '../api'; +import { AbortCommandError, CommandError } from '../errors'; +import { learnMore } from '../link'; +import { promptAsync, selectAsync } from '../prompts'; +import { loginAsync } from './user'; + +export enum UserSecondFactorDeviceMethod { + AUTHENTICATOR = 'authenticator', + SMS = 'sms', +} + +/** Device properties for 2FA */ +export type SecondFactorDevice = { + id: string; + method: UserSecondFactorDeviceMethod; + sms_phone_number: string | null; + is_primary: boolean; +}; + +const nonInteractiveHelp = `Use the EXPO_TOKEN environment variable to authenticate in CI (${learnMore( + 'https://docs.expo.dev/accounts/programmatic-access/' +)})`; + +/** + * Prompt for an OTP with the option to cancel the question by answering empty (pressing return key). + */ +async function promptForOTPAsync(cancelBehavior: 'cancel' | 'menu'): Promise { + const enterMessage = + cancelBehavior === 'cancel' + ? chalk`press {bold Enter} to cancel` + : chalk`press {bold Enter} for more options`; + const { otp } = await promptAsync( + { + type: 'text', + name: 'otp', + message: `One-time password or backup code (${enterMessage}):`, + }, + { nonInteractiveHelp } + ); + return otp || null; +} + +/** + * 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 { + const nonPrimarySecondFactorDevices = secondFactorDevices.filter((device) => !device.is_primary); + + if (nonPrimarySecondFactorDevices.length === 0) { + throw new CommandError( + '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, { + nonInteractiveHelp, + }); + 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 { + 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.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.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 AbortCommandError(); + } + + await loginAsync({ + username, + password, + otp, + }); +} diff --git a/packages/expo/cli/utils/user/sessionStorage.ts b/packages/expo/cli/utils/user/sessionStorage.ts new file mode 100644 index 0000000000000..1510a0e4f648d --- /dev/null +++ b/packages/expo/cli/utils/user/sessionStorage.ts @@ -0,0 +1,42 @@ +import { getUserStatePath } from '@expo/config/build/getUserState'; +import JsonFile from '@expo/json-file'; + +type UserSettingsData = { + auth?: SessionData; +}; + +type SessionData = { + sessionSecret: string; + + // These fields are potentially used by Expo CLI. + userId: string; + username: string; + currentConnection: 'Username-Password-Authentication'; +}; + +export function getSession(): SessionData | null { + try { + return JsonFile.read(getUserStatePath())?.auth ?? null; + } catch (error: any) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } +} + +export async function setSessionAsync(sessionData?: SessionData): Promise { + await JsonFile.setAsync(getUserStatePath(), 'auth', sessionData, { + default: {}, + ensureDir: true, + }); +} + +export function getAccessToken(): string | null { + // TODO: Move to env + return process.env.EXPO_TOKEN ?? null; +} + +export function getSessionSecret(): string | null { + return getSession()?.sessionSecret ?? null; +} diff --git a/packages/expo/cli/utils/user/user.ts b/packages/expo/cli/utils/user/user.ts new file mode 100644 index 0000000000000..2a9d5882eb800 --- /dev/null +++ b/packages/expo/cli/utils/user/user.ts @@ -0,0 +1,92 @@ +import gql from 'graphql-tag'; + +import * as Log from '../../log'; +import * as Analytics from '../analytics/rudderstackClient'; +import { apiClient } from '../api'; +import { graphqlClient } from '../graphql/client'; +import { CurrentUserQuery } from '../graphql/generated'; +import { UserQuery } from '../graphql/queries/UserQuery'; +import { getAccessToken, getSessionSecret, setSessionAsync } from './sessionStorage'; + +// Re-export, but keep in separate file to avoid dependency cycle +export { getSessionSecret, getAccessToken }; + +export type Actor = NonNullable; + +let currentUser: Actor | undefined; + +/** + * Resolve the name of the actor, either normal user or robot user. + * This should be used whenever the "current user" needs to be displayed. + * The display name CANNOT be used as project owner. + */ +export function getActorDisplayName(user?: Actor): string { + switch (user?.__typename) { + case 'User': + return user.username; + case 'Robot': + return user.firstName ? `${user.firstName} (robot)` : 'robot'; + default: + return 'anonymous'; + } +} + +export async function getUserAsync(): Promise { + if (!currentUser && (getAccessToken() || getSessionSecret())) { + const user = await UserQuery.currentUserAsync(); + currentUser = user ?? undefined; + if (user) { + await Analytics.setUserDataAsync(user.id, { + username: getActorDisplayName(user), + user_id: user.id, + user_type: user.__typename, + }); + } + } + return currentUser; +} + +export async function loginAsync(json: { + username: string; + password: string; + otp?: string; +}): Promise { + const body = await apiClient.post('auth/loginAsync', { json }).json(); + const { sessionSecret } = (body as any).data; + const result = await graphqlClient + .query( + gql` + query UserQuery { + viewer { + id + username + } + } + `, + {}, + { + fetchOptions: { + headers: { + 'expo-session': sessionSecret, + }, + }, + additionalTypenames: [] /* UserQuery has immutable fields */, + } + ) + .toPromise(); + const { + data: { viewer }, + } = result; + await setSessionAsync({ + sessionSecret, + userId: viewer.id, + username: viewer.username, + currentConnection: 'Username-Password-Authentication', + }); +} + +export async function logoutAsync(): Promise { + currentUser = undefined; + await setSessionAsync(undefined); + Log.log('Logged out'); +} diff --git a/packages/expo/cli/whoami/index.ts b/packages/expo/cli/whoami/index.ts new file mode 100644 index 0000000000000..dad8610bdb63b --- /dev/null +++ b/packages/expo/cli/whoami/index.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import chalk from 'chalk'; + +import { Command } from '../../bin/cli'; +import * as Log from '../log'; +import { assertArgs } from '../utils/args'; +import { logCmdError } from '../utils/errors'; + +export const expoWhoami: Command = async (argv) => { + const args = assertArgs( + { + // Types + '--help': Boolean, + // Aliases + '-h': '--help', + }, + argv + ); + + if (args['--help']) { + Log.exit( + chalk` + {bold Description} + Show the currently authenticated username + + {bold Usage} + $ npx expo whoami + + Options + -h, --help Output usage information + `, + 0 + ); + } + + const { whoamiAsync } = await import('./whoamiAsync'); + return whoamiAsync().catch(logCmdError); +}; diff --git a/packages/expo/cli/whoami/whoamiAsync.ts b/packages/expo/cli/whoami/whoamiAsync.ts new file mode 100644 index 0000000000000..be9fcd4bce17d --- /dev/null +++ b/packages/expo/cli/whoami/whoamiAsync.ts @@ -0,0 +1,13 @@ +import chalk from 'chalk'; + +import * as Log from '../log'; +import { getActorDisplayName, getUserAsync } from '../utils/user/user'; + +export async function whoamiAsync() { + const user = await getUserAsync(); + if (user) { + Log.exit(chalk.green(getActorDisplayName(user)), 0); + } else { + Log.exit('Not logged in'); + } +} diff --git a/packages/expo/e2e/__tests__/index-test.ts b/packages/expo/e2e/__tests__/index-test.ts index 73593f3b4ad75..59c86e4ced0cc 100644 --- a/packages/expo/e2e/__tests__/index-test.ts +++ b/packages/expo/e2e/__tests__/index-test.ts @@ -29,7 +29,7 @@ it('runs `npx expo --help`', async () => { $ npx expo Available commands - config, prebuild + config, login, logout, prebuild, register, whoami Options --version, -v Version number diff --git a/packages/expo/e2e/__tests__/login-test.ts b/packages/expo/e2e/__tests__/login-test.ts new file mode 100644 index 0000000000000..8da68647fc9a6 --- /dev/null +++ b/packages/expo/e2e/__tests__/login-test.ts @@ -0,0 +1,79 @@ +/* eslint-env jest */ +import fs from 'fs/promises'; + +import { execute, getLoadedModulesAsync, projectRoot } from './utils'; + +const originalForceColor = process.env.FORCE_COLOR; +const originalCI = process.env.CI; +beforeAll(async () => { + await fs.mkdir(projectRoot, { recursive: true }); + process.env.FORCE_COLOR = '0'; + process.env.CI = '1'; +}); +afterAll(() => { + process.env.FORCE_COLOR = originalForceColor; + process.env.CI = originalCI; +}); + +it('loads expected modules by default', async () => { + const modules = await getLoadedModulesAsync(`require('../../build-cli/cli/login');`); + expect(modules).toStrictEqual([ + 'node_modules/ansi-styles/index.js', + 'node_modules/arg/index.js', + 'node_modules/chalk/source/index.js', + 'node_modules/chalk/source/util.js', + 'node_modules/has-flag/index.js', + 'node_modules/supports-color/index.js', + 'packages/expo/build-cli/cli/log.js', + 'packages/expo/build-cli/cli/login/index.js', + 'packages/expo/build-cli/cli/utils/args.js', + 'packages/expo/build-cli/cli/utils/errors.js', + ]); +}); + +it('runs `npx expo login --help`', async () => { + const results = await execute('login', '--help'); + expect(results.stdout).toMatchInlineSnapshot(` + " + Description + Log in to an Expo account + + Usage + $ npx expo login + + Options + -u, --username Username + -p, --password Password + --otp One-time password from your 2FA device + -h, --help Output usage information + " + `); +}); + +it('throws on invalid project root', async () => { + expect.assertions(1); + try { + await execute('very---invalid', 'login'); + } catch (e) { + expect(e.stderr).toMatch(/Invalid project root: \//); + } +}); + +it('runs `npx expo login` and throws due to CI', async () => { + expect.assertions(2); + try { + console.log(await execute('login')); + } catch (e) { + expect(e.stderr).toMatch(/Input is required/); + expect(e.stderr).toMatch(/Use the EXPO_TOKEN environment variable to authenticate in CI/); + } +}); + +it('runs `npx expo login` and throws due to invalid credentials', async () => { + expect.assertions(1); + try { + console.log(await execute('login', '--username', 'bacon', '--password', 'invalid')); + } catch (e) { + expect(e.stderr).toMatch(/Invalid username\/password. Please try again/); + } +}); diff --git a/packages/expo/e2e/__tests__/logout-test.ts b/packages/expo/e2e/__tests__/logout-test.ts new file mode 100644 index 0000000000000..f1061eb2e676e --- /dev/null +++ b/packages/expo/e2e/__tests__/logout-test.ts @@ -0,0 +1,55 @@ +/* eslint-env jest */ +import fs from 'fs/promises'; + +import { execute, getLoadedModulesAsync, projectRoot } from './utils'; + +const originalForceColor = process.env.FORCE_COLOR; + +beforeAll(async () => { + await fs.mkdir(projectRoot, { recursive: true }); + process.env.FORCE_COLOR = '0'; +}); +afterAll(() => { + process.env.FORCE_COLOR = originalForceColor; +}); + +it('loads expected modules by default', async () => { + const modules = await getLoadedModulesAsync(`require('../../build-cli/cli/logout');`); + expect(modules).toStrictEqual([ + 'node_modules/ansi-styles/index.js', + 'node_modules/arg/index.js', + 'node_modules/chalk/source/index.js', + 'node_modules/chalk/source/util.js', + 'node_modules/has-flag/index.js', + 'node_modules/supports-color/index.js', + 'packages/expo/build-cli/cli/log.js', + 'packages/expo/build-cli/cli/logout/index.js', + 'packages/expo/build-cli/cli/utils/args.js', + 'packages/expo/build-cli/cli/utils/errors.js', + ]); +}); + +it('runs `npx expo logout --help`', async () => { + const results = await execute('logout', '--help'); + expect(results.stdout).toMatchInlineSnapshot(` + " + Description + Log out of an Expo account + + Usage + $ npx expo logout + + Options + -h, --help Output usage information + " + `); +}); + +it('throws on invalid project root', async () => { + expect.assertions(1); + try { + await execute('very---invalid', 'logout'); + } catch (e) { + expect(e.stderr).toMatch(/Invalid project root: \//); + } +}); diff --git a/packages/expo/e2e/__tests__/register-test.ts b/packages/expo/e2e/__tests__/register-test.ts new file mode 100644 index 0000000000000..a551081b98467 --- /dev/null +++ b/packages/expo/e2e/__tests__/register-test.ts @@ -0,0 +1,66 @@ +/* eslint-env jest */ +import fs from 'fs/promises'; + +import { execute, getLoadedModulesAsync, projectRoot } from './utils'; + +const originalForceColor = process.env.FORCE_COLOR; +const originalCI = process.env.CI; +beforeAll(async () => { + await fs.mkdir(projectRoot, { recursive: true }); + process.env.FORCE_COLOR = '0'; + process.env.CI = '1'; +}); +afterAll(() => { + process.env.FORCE_COLOR = originalForceColor; + process.env.CI = originalCI; +}); + +it('loads expected modules by default', async () => { + const modules = await getLoadedModulesAsync(`require('../../build-cli/cli/register');`); + expect(modules).toStrictEqual([ + 'node_modules/ansi-styles/index.js', + 'node_modules/arg/index.js', + 'node_modules/chalk/source/index.js', + 'node_modules/chalk/source/util.js', + 'node_modules/has-flag/index.js', + 'node_modules/supports-color/index.js', + 'packages/expo/build-cli/cli/log.js', + 'packages/expo/build-cli/cli/register/index.js', + 'packages/expo/build-cli/cli/utils/args.js', + 'packages/expo/build-cli/cli/utils/errors.js', + ]); +}); + +it('runs `npx expo register --help`', async () => { + const results = await execute('register', '--help'); + expect(results.stdout).toMatchInlineSnapshot(` + " + Description + Sign up for a new Expo account + + Usage + $ npx expo register + + Options + -h, --help Output usage information + " + `); +}); + +it('throws on invalid project root', async () => { + expect.assertions(1); + try { + await execute('very---invalid', 'register'); + } catch (e) { + expect(e.stderr).toMatch(/Invalid project root: \//); + } +}); + +it('runs `npx expo register` and throws due to CI', async () => { + expect.assertions(1); + try { + console.log(await execute('register')); + } catch (e) { + expect(e.stderr).toMatch(/Cannot register an account in CI/); + } +}); diff --git a/packages/expo/e2e/__tests__/whoami-test.ts b/packages/expo/e2e/__tests__/whoami-test.ts new file mode 100644 index 0000000000000..78bdfeb76fe36 --- /dev/null +++ b/packages/expo/e2e/__tests__/whoami-test.ts @@ -0,0 +1,79 @@ +/* eslint-env jest */ +import fs from 'fs/promises'; +import os from 'os'; + +import { execute, getLoadedModulesAsync, projectRoot } from './utils'; + +const originalForceColor = process.env.FORCE_COLOR; +beforeAll(async () => { + await fs.mkdir(projectRoot, { recursive: true }); + process.env.FORCE_COLOR = '0'; +}); +afterAll(() => { + process.env.FORCE_COLOR = originalForceColor; +}); + +it('loads expected modules by default', async () => { + const modules = await getLoadedModulesAsync(`require('../../build-cli/cli/whoami');`); + expect(modules).toStrictEqual([ + 'node_modules/ansi-styles/index.js', + 'node_modules/arg/index.js', + 'node_modules/chalk/source/index.js', + 'node_modules/chalk/source/util.js', + 'node_modules/has-flag/index.js', + 'node_modules/supports-color/index.js', + 'packages/expo/build-cli/cli/log.js', + 'packages/expo/build-cli/cli/utils/args.js', + 'packages/expo/build-cli/cli/utils/errors.js', + 'packages/expo/build-cli/cli/whoami/index.js', + ]); +}); + +it('runs `npx expo whoami --help`', async () => { + const results = await execute('whoami', '--help'); + expect(results.stdout).toMatchInlineSnapshot(` + " + Description + Show the currently authenticated username + + Usage + $ npx expo whoami + + Options + -h, --help Output usage information + " + `); +}); + +it('throws on invalid project root', async () => { + expect.assertions(1); + try { + await execute('very---invalid', 'whoami'); + } catch (e) { + expect(e.stderr).toMatch(/Invalid project root: \//); + } +}); + +it('runs `npx expo whoami`', async () => { + const results = await execute('whoami').catch((e) => e); + + // Test logged in or logged out. + if (results.stderr) { + expect(results.stderr.trim()).toBe('Not logged in'); + } else { + expect(results.stdout.trim()).toBe(expect.any(String)); + // Ensure this can always be used as a means of automation. + expect(results.stdout.trim().split(os.EOL)).toBe(1); + } +}); + +if (process.env.CI) { + it('runs `npx expo whoami` and throws logged out error', async () => { + expect.assertions(1); + try { + console.log(await execute('whoami')); + } catch (e) { + expect(e.stderr).toMatch(/Not logged in/); + } + }); +} diff --git a/packages/expo/package.json b/packages/expo/package.json index 677ca9251834a..500a296b44f68 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -63,12 +63,16 @@ "@expo/config-plugins": "^4.0.15", "@expo/json-file": "8.2.34", "@expo/metro-config": "~0.3.7", + "@expo/rudder-sdk-node": "1.1.1", "@expo/package-manager": "0.0.49", "@expo/prebuild-config": "^3.0.16", "@expo/spawn-async": "1.5.0", "@expo/vector-icons": "^12.0.4", + "@urql/core": "2.3.1", + "@urql/exchange-retry": "0.3.0", "arg": "4.1.0", "babel-preset-expo": "~9.0.1", + "better-opn": "~3.0.2", "cacache": "^15.3.0", "chalk": "^4.0.0", "cross-spawn": "^6.0.5", @@ -84,6 +88,9 @@ "form-data": "^2.3.2", "fs-extra": "9.0.0", "getenv": "^1.0.0", + "got": "11.8.2", + "graphql": "15.5.1", + "graphql-tag": "2.12.5", "invariant": "^2.2.4", "js-yaml": "^3.13.1", "md5-file": "^3.2.3", @@ -125,6 +132,7 @@ "@types/uuid": "^3.4.7", "expo-module-scripts": "^2.0.0", "klaw-sync": "^6.0.0", + "nock": "~13.2.2", "react": "17.0.2", "react-dom": "17.0.2", "react-native": "0.66.4", diff --git a/yarn.lock b/yarn.lock index 6d61029faefff..eb1ad3abeb22a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2340,7 +2340,7 @@ tslib "~2.3.0" value-or-promise "1.0.11" -"@graphql-typed-document-node/core@^3.0.0": +"@graphql-typed-document-node/core@^3.0.0", "@graphql-typed-document-node/core@^3.1.0": version "3.1.1" resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== @@ -3395,6 +3395,11 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== +"@sindresorhus/is@^4.0.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.4.0.tgz#e277e5bdbdf7cb1e20d320f02f5e2ed113cd3185" + integrity sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ== + "@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -3531,6 +3536,13 @@ dependencies: defer-to-connect "^1.0.1" +"@szmarczak/http-timer@^4.0.5": + version "4.0.6" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" + integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== + dependencies: + defer-to-connect "^2.0.0" + "@taskr/clear@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@taskr/clear/-/clear-1.1.0.tgz#0a88d180bed2f91f310136375a72c00b50834fd1" @@ -3666,6 +3678,16 @@ dependencies: "@types/node" "*" +"@types/cacheable-request@^6.0.1": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9" + integrity sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "*" + "@types/node" "*" + "@types/responselike" "*" + "@types/cheerio@*", "@types/cheerio@^0.22.22": version "0.22.30" resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.30.tgz#6c1ded70d20d890337f0f5144be2c5e9ce0936e6" @@ -3769,6 +3791,11 @@ resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz#693b316ad323ea97eed6b38ed1a3cc02b1672b57" integrity sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w== +"@types/http-cache-semantics@*": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" + integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ== + "@types/i18n-js@^3.0.1": version "3.8.2" resolved "https://registry.yarnpkg.com/@types/i18n-js/-/i18n-js-3.8.2.tgz#957a3fa268124d09e3b3b34695f0184118f4bc4f" @@ -3846,6 +3873,13 @@ dependencies: "@types/node" "*" +"@types/keyv@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.3.tgz#1c9aae32872ec1f20dcdaee89a9f3ba88f465e41" + integrity sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg== + dependencies: + "@types/node" "*" + "@types/lodash@^4.14.161": version "4.14.178" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" @@ -3971,6 +4005,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/responselike@*", "@types/responselike@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" + integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== + dependencies: + "@types/node" "*" + "@types/retry@^0.12.0": version "0.12.1" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.1.tgz#d8f1c0d0dc23afad6dc16a9e993a0865774b4065" @@ -4188,6 +4229,30 @@ "@typescript-eslint/types" "5.9.0" eslint-visitor-keys "^3.0.0" +"@urql/core@2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@urql/core/-/core-2.3.1.tgz#1aa558b5aa6eca785062a3a768e6fec708aab6d2" + integrity sha512-M9gGz02+TH7buqkelVg+m0eTkLCKly9ZjuTp6NHMP7hXD/zpImPu/m3YluA+D4HbFqw3kofBtLCuVk67X9dxRw== + dependencies: + "@graphql-typed-document-node/core" "^3.1.0" + wonka "^4.0.14" + +"@urql/core@>=2.3.1": + version "2.3.6" + resolved "https://registry.yarnpkg.com/@urql/core/-/core-2.3.6.tgz#ee0a6f8fde02251e9560c5f17dce5cd90f948552" + integrity sha512-PUxhtBh7/8167HJK6WqBv6Z0piuiaZHQGYbhwpNL9aIQmLROPEdaUYkY4wh45wPQXcTpnd11l0q3Pw+TI11pdw== + dependencies: + "@graphql-typed-document-node/core" "^3.1.0" + wonka "^4.0.14" + +"@urql/exchange-retry@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@urql/exchange-retry/-/exchange-retry-0.3.0.tgz#13252108b5a111aab45f9982f4db18d1a286e423" + integrity sha512-hHqer2mcdVC0eYnVNbWyi28AlGOPb2vjH3lP3/Bc8Lc8BjhMsDwFMm7WhoP5C1+cfbr/QJ6Er3H/L08wznXxfg== + dependencies: + "@urql/core" ">=2.3.1" + wonka "^4.0.14" + "@use-expo/permissions@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@use-expo/permissions/-/permissions-2.0.0.tgz#1befc01397eed277f24beda137b110721fbe4868" @@ -5319,10 +5384,10 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" -better-opn@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.1.tgz#01d4dd3bde34d959a0645281fb6b2e35ef91b6f7" - integrity sha512-u7pU4QnwLQ+wCDLHdvtWbI/41pSRayJ+UHyAqpb5sr42FGnqzBlEyWdCklfaSzXqbmnXDBzCvWcaZmL3qp0xGA== +better-opn@^3.0.1, better-opn@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" + integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ== dependencies: open "^8.0.4" @@ -5829,6 +5894,11 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" +cacheable-lookup@^5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== + cacheable-request@^2.1.1: version "2.1.4" resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-2.1.4.tgz#0d808801b6342ad33c91df9d0b44dc09b91e5c3d" @@ -5855,6 +5925,19 @@ cacheable-request@^6.0.0: normalize-url "^4.1.0" responselike "^1.0.2" +cacheable-request@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" + integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -7298,6 +7381,13 @@ decompress-response@^3.3.0: dependencies: mimic-response "^1.0.0" +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.6.0.tgz#0e6da8f0ce52838ef5cec5c8f9396b0c1b64a3cb" @@ -7372,6 +7462,11 @@ defer-to-connect@^1.0.1: resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== +defer-to-connect@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -8548,6 +8643,17 @@ expo-asset-utils@~3.0.0: resolved "https://registry.yarnpkg.com/expo-asset-utils/-/expo-asset-utils-3.0.0.tgz#2c7ddf71ba9efacf7b46c159c1c650114a2f14dc" integrity sha512-CgIbNvTqKqQi1lrlptmwoaCMu4ZVOZf8tghmytlor23CjIOuorw6cfuOqiqWkJLz23arTt91maYEU9XLMPB23A== +expo-modules-autolinking@~0.5.1: + version "0.5.5" + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-0.5.5.tgz#6bcc42072dcbdfca79d207b7f549f1fdb54a2b74" + integrity sha512-bILEG0Fg+ZhIhdEaShHzsEN1WC0hUmXJ5Kcd4cd+8rVk1Ead9vRZxA/yLx1cNBDCOwMe0GAMrhF7TKT+A1P+YA== + dependencies: + chalk "^4.1.0" + commander "^7.2.0" + fast-glob "^3.2.5" + find-up "^5.0.0" + fs-extra "^9.1.0" + expo-progress@^0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/expo-progress/-/expo-progress-0.0.2.tgz#0d42470f8f1f019d3ae0f1da377c9bca891f3dd8" @@ -9705,6 +9811,23 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" +got@11.8.2: + version "11.8.2" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599" + integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.1" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + got@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/got/-/got-8.3.2.tgz#1d23f64390e97f776cac52e5b936e5f514d2e937" @@ -9781,6 +9904,13 @@ graphql-sse@^1.0.1: resolved "https://registry.yarnpkg.com/graphql-sse/-/graphql-sse-1.0.6.tgz#4f98e0a06f2020542ed054399116108491263224" integrity sha512-y2mVBN2KwNrzxX2KBncQ6kzc6JWvecxuBernrl0j65hsr6MAS3+Yn8PTFSOgRmtolxugepxveyZVQEuaNEbw3w== +graphql-tag@2.12.5: + version "2.12.5" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.5.tgz#5cff974a67b417747d05c8d9f5f3cb4495d0db8f" + integrity sha512-5xNhP4063d16Pz3HBtKprutsPrmHZi5IdUGOWRxA2B6VF7BIRGOHZ5WQvDmJXZuPcBg7rYwaFxvQYjqkSdR3TQ== + dependencies: + tslib "^2.1.0" + graphql-tag@^2.10.1, graphql-tag@^2.11.0, graphql-tag@^2.12.3: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" @@ -9793,6 +9923,11 @@ graphql-ws@^5.4.1: resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-5.5.5.tgz#f375486d3f196e2a2527b503644693ae3a8670a9" integrity sha512-hvyIS71vs4Tu/yUYHPvGXsTgo0t3arU820+lT5VjZS2go0ewp2LqyCgxEN56CzOG7Iys52eRhHBiD1gGRdiQtw== +graphql@15.5.1: + version "15.5.1" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.1.tgz#f2f84415d8985e7b84731e7f3536f8bb9d383aad" + integrity sha512-FeTRX67T3LoE3LWAxxOlW2K3Bz+rMYAC18rRguK4wgXaTZMiJwSUwDmPFo3UadAKbzirKIg5Qy+sNJXbpPRnQw== + graphql@^15.3.0: version "15.8.0" resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38" @@ -10258,6 +10393,14 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" + integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" @@ -12386,6 +12529,11 @@ json-buffer@3.0.0: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -12442,7 +12590,7 @@ json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= @@ -12587,6 +12735,13 @@ keyv@^3.0.0: dependencies: json-buffer "3.0.0" +keyv@^4.0.0: + version "4.0.5" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.5.tgz#bb12b467aba372fab2a44d4420c00d3c4ebd484c" + integrity sha512-531pkGLqV3BMg0eDqqJFI0R1mkK1Nm5xIP2mM6keP5P8WfFtCkg2IOwplTUmlGoTgIg9yQYZ/kdihhz89XH3vA== + dependencies: + json-buffer "3.0.1" + killable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -12967,6 +13122,11 @@ lodash.pick@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM= +lodash.set@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -13687,6 +13847,11 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + mini-css-extract-plugin@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.5.0.tgz#ac0059b02b9692515a637115b0cc9fed3a35c7b0" @@ -14032,6 +14197,16 @@ nocache@^2.1.0: resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f" integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q== +nock@~13.2.2: + version "13.2.2" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.2.2.tgz#29a6942250278209c2b3e7a38310f703581b21fa" + integrity sha512-PcBHuvl9i6zfaJ50A7LS55oU+nFLv8htXIhffJO+FxyfibdZ4jEvd9kTuvkrJireBFIGMZ+oUIRpMK5gU9h//g== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-dir@^0.1.17: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -14214,6 +14389,11 @@ normalize-url@^4.1.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + npm-package-arg@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-7.0.0.tgz#52cdf08b491c0c59df687c4c925a89102ef794a5" @@ -15664,6 +15844,11 @@ prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, object-assign "^4.1.1" react-is "^16.13.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== + proper-lockfile@^3.0.2: version "3.2.0" resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-3.2.0.tgz#89ca420eea1d55d38ca552578851460067bcda66" @@ -15889,6 +16074,11 @@ queue@6.0.2: dependencies: inherits "~2.0.3" +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -16817,6 +17007,11 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.5.tgz#852c361247198da6756d07d9296c2b51eddb79f6" integrity sha512-uVdlz8J7OO+ASpBYoz1Zypgx0KasCY20H+N8JD13oUMtPvSHQuscrHop4KbXrbsBcdB9Ds7lVK7eRkBIfO43vQ== +resolve-alpn@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -16903,6 +17098,13 @@ responselike@1.0.2, responselike@^1.0.2: dependencies: lowercase-keys "^1.0.0" +responselike@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723" + integrity sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw== + dependencies: + lowercase-keys "^2.0.0" + restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -19968,6 +20170,11 @@ with-open-file@^0.1.6: p-try "^2.1.0" pify "^4.0.1" +wonka@^4.0.14: + version "4.0.15" + resolved "https://registry.yarnpkg.com/wonka/-/wonka-4.0.15.tgz#9aa42046efa424565ab8f8f451fcca955bf80b89" + integrity sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg== + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"