Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[cli] Added auth commands to expo #16087

Merged
merged 16 commits into from Feb 1, 2022
5 changes: 5 additions & 0 deletions packages/expo/bin/cli.ts
Expand Up @@ -11,6 +11,11 @@ const commands: { [command: string]: () => Promise<Command> } = {
// 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(
Expand Down
51 changes: 51 additions & 0 deletions 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}
Login to an Expo account
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved

{bold Usage}
$ npx expo login

Options
-u, --username <string> Username
-p, --password <string> Password
--otp <string> 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);
};
38 changes: 38 additions & 0 deletions 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}
Logout of an Expo account
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved

{bold Usage}
$ npx expo logout

Options
-h, --help Output usage information
`,
0
);
}

const { logoutAsync } = await import('../utils/user/user');
return logoutAsync().catch(logCmdError);
};
38 changes: 38 additions & 0 deletions 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);
};
28 changes: 28 additions & 0 deletions packages/expo/cli/register/registerAsync.ts
@@ -0,0 +1,28 @@
import openBrowserAsync from 'better-opn';

import { CI } from '../utils/env';
import { CommandError } from '../utils/errors';
import { ora } from '../utils/ora';

export async function registerAsync() {
const REGISTRATION_URL = `https://expo.dev/signup`;
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
if (CI) {
throw new CommandError(
'NON_INTERACTIVE',
`Cannot register an account in CI. Register an account at: ${REGISTRATION_URL}`
);
}
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved

const spinner = ora(`Opening ${REGISTRATION_URL}`).start();
try {
const opened = openBrowserAsync(REGISTRATION_URL);

if (opened) {
spinner.succeed(`Opened ${REGISTRATION_URL}`);
}
return;
} catch (error) {
spinner.fail(`Unable to open a web browser. Register an account at: ${REGISTRATION_URL}`);
throw error;
}
}
50 changes: 50 additions & 0 deletions packages/expo/cli/utils/__tests__/api-test.ts
@@ -0,0 +1,50 @@
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' },
},
],
});

let error: Error | null = null;
try {
await apiClient.post('test');
} catch (e: any) {
error = e;
}

expect(error).toBeInstanceOf(ApiV2Error);
assert(error instanceof ApiV2Error);

expect(error.message).toEqual('hellomessage');
expect(error.expoApiV2ErrorCode).toEqual('TEST_CODE');
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
});

it('does not convert non-APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl()).post('/v2/test').reply(500, 'Something went wrong');

let error: Error | null = null;
try {
await apiClient.post('test');
} catch (e: any) {
error = e;
}
expect(error).toBeInstanceOf(RequestError);
expect(error).not.toBeInstanceOf(ApiV2Error);
});
112 changes: 112 additions & 0 deletions packages/expo/cli/utils/analytics/rudderstackClient.ts
@@ -0,0 +1,112 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
import os from 'os';
import { URL } from 'url';
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 rudderstackClient: RudderAnalytics | null = null;
let identifier = false;
let identifyData: {
userId: string;
deviceId: string;
traits: Record<string, any>;
} | null = null;

export async function initAsync(): Promise<void> {
if (EXPO_NO_TELEMETRY) {
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
const config =
EXPO_STAGING || EXPO_LOCAL
? {
// staging environment
rudderstackWriteKey: '1wpX20Da4ltFGSXbPFYUL00Chb7',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
}
: {
// prod environment
rudderstackWriteKey: '1wpXLFxmujq86etH6G6cc90hPcC',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
};

rudderstackClient = new RudderAnalytics(
config.rudderstackWriteKey,
new URL('/v1/batch', config.rudderstackDataPlaneURL).toString(),
{
flushInterval: 300,
}
);
}
}

export async function setUserDataAsync(userId: string, traits: Record<string, any>): Promise<void> {
const savedDeviceId = await UserSettings.getAsync('analyticsDeviceId', null);
const deviceId = savedDeviceId ?? uuidv4();
if (!savedDeviceId) {
await UserSettings.setAsync('analyticsDeviceId', deviceId);
}

identifyData = {
userId,
deviceId,
traits,
};

ensureIdentified();
}

export async function flushAsync(): Promise<void> {
if (rudderstackClient) {
await rudderstackClient.flush();
}
}

export function logEvent(name: string, properties: Record<string, any> = {}): void {
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
if (!rudderstackClient) {
return;
}
ensureIdentified();

const { userId, deviceId } = identifyData ?? {};
const commonEventProperties = { source_version: process.env.__EXPO_VERSION, source: 'expo' };

const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() };
rudderstackClient.track({
event: name,
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
properties: { ...properties, ...commonEventProperties },
...identity,
context: getRudderStackContext(),
});
}

function ensureIdentified(): void {
if (!rudderstackClient || identifier || !identifyData) {
return;
}

rudderstackClient.identify({
userId: identifyData.userId,
anonymousId: identifyData.deviceId,
traits: identifyData.traits,
});
identifier = true;
}

function getRudderStackContext(): Record<string, any> {
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 },
};
}

export enum AnalyticsEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
}