Compare commits

...
32 changed files with 637 additions and 664 deletions

No files matched your search

+10 -140
View File
@@ -1,10 +1,4 @@
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager';
import { Command } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import {
AnalyticsEvent,
@@ -12,71 +6,20 @@ import {
initAsync as initAnalyticsAsync,
logEvent,
} from '../analytics/rudderstackClient';
import { learnMore } from '../log';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
import { Actor, getUserAsync } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { setVcsClient } from '../vcs';
import GitClient from '../vcs/clients/git';
import { getUserAsync } from '../user/User';
import ActorContextField from './context/ActorContextField';
import ContextField from './context/ContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import ProjectIdContextField from './context/ProjectIdContextField';
export interface CommandConfiguration {
/**
* When user data is unavailable locally, determines if the command will
* force the user to log in. By default, all commands require authentication.
*/
allowUnauthenticated?: boolean;
/**
* Whether a command can be run outside of an Expo project directory.
* By default, all commands must be run inside an Expo project directory.
*/
canRunOutsideProject?: boolean;
}
export interface ContextOptions {
nonInteractive: boolean;
}
export abstract class ContextField<T> {
abstract getValueAsync(options: ContextOptions): Promise<T>;
}
export class ProjectIdContextField extends ContextField<string> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<string> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
return await getProjectIdAsync(exp, { nonInteractive });
}
}
export class OptionalProjectIdContextField extends ContextField<string | undefined> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<string | undefined> {
const projectDir = await findProjectRootAsync();
if (!projectDir) {
return undefined;
}
const exp = getExpoConfig(projectDir);
return await getProjectIdAsync(exp, { nonInteractive });
}
}
export class ActorContextField extends ContextField<Actor> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<Actor> {
return await ensureLoggedInAsync({ nonInteractive });
}
}
export const EASCommandProjectDirContext = {
projectDir: new ProjectDirContextField(),
};
export const EASCommandProjectIdContext = {
projectId: new ProjectIdContextField(),
};
export const EASCommandProjectIdIfProjectDirContext = {
projectId: new OptionalProjectIdContextField(),
};
export const EASCommandLoggedInContext = {
actor: new ActorContextField(),
};
@@ -100,8 +43,6 @@ type ContextOutput<
export default abstract class EasCommand extends Command {
static contextDefinition: ContextInput = {};
protected commandConfiguration: CommandConfiguration = {};
protected async getContextAsync<
C extends {
[name: string]: any;
@@ -127,20 +68,8 @@ export default abstract class EasCommand extends Command {
async run(): Promise<any> {
await initAnalyticsAsync();
if (!this.commandConfiguration.canRunOutsideProject) {
const projectDir = await findProjectRootAsync();
await this.applyCliConfigAsync(projectDir);
await this.ensureEasCliIsNotInDependenciesAsync(projectDir);
}
if (!this.commandConfiguration.allowUnauthenticated) {
const { flags } = await this.parse();
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await ensureLoggedInAsync({ nonInteractive });
} else {
await getUserAsync();
}
// this is needed for logEvent call below as it identifies the user in the analytics system
await getUserAsync();
logEvent(AnalyticsEvent.ACTION, {
// id is assigned by oclif in constructor based on the filepath:
// commands/submit === submit, commands/build/list === build:list
@@ -155,63 +84,4 @@ export default abstract class EasCommand extends Command {
await flushAnalyticsAsync();
return super.finally(err);
}
private async applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonAccessor = new EasJsonAccessor(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
throw new Error(
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint in eas.json (${config.version})`
);
}
if (config?.requireCommit) {
setVcsClient(new GitClient());
}
}
private async ensureEasCliIsNotInDependenciesAsync(projectDir: string): Promise<void> {
let printCliVersionWarning = false;
const consoleWarn = (msg?: string): void => {
if (msg) {
// eslint-disable-next-line no-console
console.warn(chalk.yellow(msg));
} else {
// eslint-disable-next-line no-console
console.warn();
}
};
if (await this.isEasCliInDependenciesAsync(projectDir)) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your project dependencies.`);
}
const maybeRepoRoot = PackageManagerUtils.findWorkspaceRoot(projectDir) ?? projectDir;
if (maybeRepoRoot !== projectDir && (await this.isEasCliInDependenciesAsync(maybeRepoRoot))) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your monorepo dependencies.`);
}
if (printCliVersionWarning) {
consoleWarn(
`It's recommended to use the ${chalk.bold(
'"cli.version"'
)} field in eas.json to enforce the ${chalk.bold('eas-cli')} version for your project.`
);
consoleWarn(
learnMore('https://github.com/expo/eas-cli#enforcing-eas-cli-version-for-your-project')
);
consoleWarn();
}
}
private async isEasCliInDependenciesAsync(dir: string): Promise<boolean> {
const packageJsonPath = path.join(dir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
return (
packageJson?.dependencies?.['eas-cli'] !== undefined ||
packageJson?.devDependencies?.['eas-cli'] !== undefined
);
}
}
@@ -1,11 +1,9 @@
import { flushAsync, initAsync, logEvent } from '../../analytics/rudderstackClient';
import { jester as mockJester } from '../../credentials/__tests__/fixtures-constants';
import { getUserAsync } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
import EasCommand, { CommandConfiguration } from '../EasCommand';
import EasCommand from '../EasCommand';
jest.mock('../../user/User');
jest.mock('../../user/actions', () => ({ ensureLoggedInAsync: jest.fn() }));
jest.mock('../../analytics/rudderstackClient', () => {
const { AnalyticsEvent } = jest.requireActual('../../analytics/rudderstackClient');
return {
@@ -32,21 +30,13 @@ beforeEach(() => {
.mocked(getUserAsync)
.mockReset()
.mockImplementation(async () => mockJester);
jest
.mocked(ensureLoggedInAsync)
.mockReset()
.mockImplementation(async () => mockJester);
jest.mocked(initAsync).mockReset();
jest.mocked(flushAsync).mockReset();
jest.mocked(logEvent).mockReset();
});
const createTestEasCommand = (authValue: boolean): typeof EasCommand => {
const createTestEasCommand = (): typeof EasCommand => {
class TestEasCommand extends EasCommand {
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: !authValue,
};
async runAsync(): Promise<void> {}
}
@@ -63,37 +53,29 @@ describe(EasCommand.name, () => {
//
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
// and look for "Config.load"
it('ensures the user is logged in', async () => {
const TestEasCommand = createTestEasCommand(true);
await TestEasCommand.run();
expect(ensureLoggedInAsync).toHaveBeenCalled();
}, 15_000);
it('ensures the user data is read from cache', async () => {
const TestEasCommand = createTestEasCommand(false);
it('ensures the user data is read', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
expect(jest.mocked(getUserAsync)).toBeCalledTimes(1);
expect(jest.mocked(ensureLoggedInAsync)).not.toBeCalled();
});
}, 15_000);
it('initializes analytics', async () => {
const TestEasCommand = createTestEasCommand(true);
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
expect(initAsync).toHaveBeenCalled();
});
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand(true);
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
expect(flushAsync).toHaveBeenCalled();
});
it('logs events', async () => {
const TestEasCommand = createTestEasCommand(true);
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
expect(logEvent).toHaveBeenCalledWith('action', {
@@ -104,7 +86,7 @@ describe(EasCommand.name, () => {
describe('after exceptions', () => {
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand(true);
const TestEasCommand = createTestEasCommand();
try {
await TestEasCommand.run().then(() => {
throw new Error('foo');
@@ -0,0 +1,85 @@
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { ApiV2Error } from '../../api';
import Log, { learnMore } from '../../log';
import { promptAsync } from '../../prompts';
import { Actor, getUserAsync, loginAsync } from '../../user/User';
import { retryUsernamePasswordAuthWithOTPAsync } from '../../user/otp';
import ContextField, { ContextOptions } from './ContextField';
export default class ActorContextField extends ContextField<Actor> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<Actor> {
return await ActorContextField.ensureLoggedInAsync({ nonInteractive });
}
private static async ensureLoggedInAsync({
nonInteractive,
}: {
nonInteractive: boolean;
}): Promise<Actor> {
let user: Actor | undefined;
try {
user = await getUserAsync();
} catch {}
if (!user) {
Log.warn('An Expo user account is required to proceed.');
await this.showLoginPromptAsync({ nonInteractive, printNewLine: true });
user = await getUserAsync();
}
return nullthrows(user);
}
private static async showLoginPromptAsync({
nonInteractive = false,
printNewLine = false,
} = {}): Promise<void> {
if (nonInteractive) {
Errors.error(
`Either log in with ${chalk.bold('eas login')} or set the ${chalk.bold(
'EXPO_TOKEN'
)} environment variable if you're using EAS CLI on CI (${learnMore(
'https://docs.expo.dev/accounts/programmatic-access/',
{ dim: false }
)})`
);
}
if (printNewLine) {
Log.newLine();
}
Log.log('Log in to EAS');
const { username, password } = await promptAsync([
{
type: 'text',
name: 'username',
message: 'Email or username',
},
{
type: 'password',
name: 'password',
message: 'Password',
},
]);
try {
await loginAsync({
username,
password,
});
} catch (e) {
if (e instanceof ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
await retryUsernamePasswordAuthWithOTPAsync(
username,
password,
e.expoApiV2ErrorMetadata as any
);
} else {
throw e;
}
}
}
}
@@ -0,0 +1,7 @@
export interface ContextOptions {
nonInteractive: boolean;
}
export default abstract class ContextField<T> {
abstract getValueAsync(options: ContextOptions): Promise<T>;
}
@@ -0,0 +1,106 @@
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import pkgDir from 'pkg-dir';
import semver from 'semver';
import { learnMore } from '../../log';
import { easCliVersion } from '../../utils/easCli';
import { getVcsClient, setVcsClient } from '../../vcs';
import GitClient from '../../vcs/clients/git';
import ContextField from './ContextField';
export default class ProjectDirContextField extends ContextField<string> {
async getValueAsync(): Promise<string> {
return await ProjectDirContextField.findProjectDirAndVerifyProjectSetupAsync();
}
private static async applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonAccessor = new EasJsonAccessor(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
throw new Error(
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint in eas.json (${config.version})`
);
}
if (config?.requireCommit) {
setVcsClient(new GitClient());
}
}
private static async ensureEasCliIsNotInDependenciesAsync(projectDir: string): Promise<void> {
let printCliVersionWarning = false;
const consoleWarn = (msg?: string): void => {
if (msg) {
// eslint-disable-next-line no-console
console.warn(chalk.yellow(msg));
} else {
// eslint-disable-next-line no-console
console.warn();
}
};
if (await this.isEasCliInDependenciesAsync(projectDir)) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your project dependencies.`);
}
const maybeRepoRoot = PackageManagerUtils.findWorkspaceRoot(projectDir) ?? projectDir;
if (maybeRepoRoot !== projectDir && (await this.isEasCliInDependenciesAsync(maybeRepoRoot))) {
printCliVersionWarning = true;
consoleWarn(`Found ${chalk.bold('eas-cli')} in your monorepo dependencies.`);
}
if (printCliVersionWarning) {
consoleWarn(
`It's recommended to use the ${chalk.bold(
'"cli.version"'
)} field in eas.json to enforce the ${chalk.bold('eas-cli')} version for your project.`
);
consoleWarn(
learnMore('https://github.com/expo/eas-cli#enforcing-eas-cli-version-for-your-project')
);
consoleWarn();
}
}
private static async isEasCliInDependenciesAsync(dir: string): Promise<boolean> {
const packageJsonPath = path.join(dir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
return (
packageJson?.dependencies?.['eas-cli'] !== undefined ||
packageJson?.devDependencies?.['eas-cli'] !== undefined
);
}
/**
* Get the project root directory.
*/
private static async findProjectRootAsync(): Promise<string> {
const projectRootDir = await pkgDir();
if (!projectRootDir) {
throw new Error('Run this command inside a project directory.');
} else {
let vcsRoot;
try {
vcsRoot = path.normalize(await getVcsClient().getRootPathAsync());
} catch {}
if (vcsRoot && vcsRoot.startsWith(projectRootDir) && vcsRoot !== projectRootDir) {
throw new Error(
`package.json is outside of the current git repository (project root: ${projectRootDir}, git root: ${vcsRoot}).`
);
}
return projectRootDir;
}
}
private static async findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
const projectDir = await this.findProjectRootAsync();
await this.applyCliConfigAsync(projectDir);
await this.ensureEasCliIsNotInDependenciesAsync(projectDir);
return projectDir;
}
}
@@ -0,0 +1,127 @@
import { getProjectConfigDescription, modifyConfigAsync } from '@expo/config';
import { ExpoConfig } from '@expo/config-types';
import chalk from 'chalk';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { toAppPrivacy } from '../../project/projectUtils';
import { Actor } from '../../user/User';
import ActorContextField from './ActorContextField';
import ContextField, { ContextOptions } from './ContextField';
import ProjectDirContextField from './ProjectDirContextField';
export default class ProjectIdContextField extends ContextField<string> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<string> {
const projectDir = await ProjectDirContextField['findProjectDirAndVerifyProjectSetupAsync']();
const exp = getExpoConfig(projectDir);
return await ProjectIdContextField.getProjectIdAsync(exp, { nonInteractive });
}
/**
* Save an EAS project ID to the appropriate field in the app config.
*/
private static async saveProjectIdToAppConfigAsync(
projectDir: string,
projectId: string
): Promise<void> {
const exp = getExpoConfig(projectDir);
const result = await modifyConfigAsync(projectDir, {
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
});
switch (result.type) {
case 'success':
break;
case 'warn': {
Log.warn();
Log.warn(
`Warning: Your project uses dynamic app configuration, and the EAS project ID can't automatically be added to it.`
);
Log.warn(
chalk.dim(
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
)
);
Log.warn();
Log.warn(
`To complete the setup process, set the ${chalk.bold(
'extra.eas.projectId'
)} in your ${chalk.bold(getProjectConfigDescription(projectDir))}:`
);
Log.warn();
Log.warn(chalk.bold(JSON.stringify({ expo: { extra: { eas: { projectId } } } }, null, 2)));
Log.warn();
throw new Error(result.message);
}
case 'fail':
throw new Error(result.message);
default:
throw new Error('Unexpected result type from modifyConfigAsync');
}
}
/**
* Get the EAS project ID from the app config. If the project ID is not set in the config,
* use the owner/slug to identify an EAS project on the server (asking for confirmation first),
* and attempt to save the EAS project ID to the appropriate field in the app config. If unable to
* save to the app config, throw an error.
*/
private static async getProjectIdAsync(
exp: ExpoConfig,
options: { nonInteractive: boolean }
): Promise<string> {
const localProjectId = exp.extra?.eas?.projectId;
if (localProjectId) {
return localProjectId;
}
const projectDir = await ProjectDirContextField['findProjectRootAsync']();
if (!projectDir) {
throw new Error('This command must be run inside a project directory.');
}
Log.warn('EAS project not configured.');
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
if (exp.owner) {
return exp.owner;
}
switch (user.__typename) {
case 'User':
return user.username;
case 'Robot':
throw new Error(
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
);
}
};
const actor = await ActorContextField['ensureLoggedInAsync']({
nonInteractive: options.nonInteractive,
});
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
{
accountName: getAccountNameForEASProjectSync(exp, actor),
projectName: exp.slug,
privacy: toAppPrivacy(exp.privacy),
},
{
nonInteractive: options.nonInteractive,
},
actor
);
const spinner = ora(`Linking local project to EAS project ${projectId}`).start();
try {
await this.saveProjectIdToAppConfigAsync(projectDir, projectId);
spinner.succeed(`Linked local project to EAS project ${projectId}`);
} catch (e: any) {
spinner.fail();
throw e;
}
return projectId;
}
}
@@ -1,16 +1,20 @@
import { ApiV2Error } from '../../api';
import { promptAsync } from '../../prompts';
import { loginAsync } from '../User';
import { showLoginPromptAsync } from '../actions';
import { UserSecondFactorDeviceMethod, retryUsernamePasswordAuthWithOTPAsync } from '../otp';
import { ApiV2Error } from '../../../api';
import { promptAsync } from '../../../prompts';
import { loginAsync } from '../../../user/User';
import {
UserSecondFactorDeviceMethod,
retryUsernamePasswordAuthWithOTPAsync,
} from '../../../user/otp';
import ActorContextField from '../ActorContextField';
jest.mock('../../prompts');
jest.mock('../../api', () => ({
ApiV2Error: jest.requireActual('../../api').ApiV2Error,
jest.mock('../../../prompts');
jest.mock('../../../api', () => ({
ApiV2Error: jest.requireActual('../../../api').ApiV2Error,
getExpoApiBaseUrl: jest.fn(),
}));
jest.mock('../otp');
jest.mock('../User', () => ({
jest.mock('../../../user/otp');
jest.mock('../../../user/User', () => ({
loginAsync: jest.fn(),
}));
@@ -23,7 +27,7 @@ beforeEach(() => {
jest.mocked(loginAsync).mockReset();
});
describe(showLoginPromptAsync, () => {
describe(ActorContextField['showLoginPromptAsync'], () => {
it('prompts for OTP when 2FA is enabled', async () => {
jest
.mocked(promptAsync)
@@ -53,7 +57,7 @@ describe(showLoginPromptAsync, () => {
})
.mockImplementation(async () => {});
await showLoginPromptAsync();
await ActorContextField['showLoginPromptAsync']();
expect(retryUsernamePasswordAuthWithOTPAsync).toHaveBeenCalledWith('hello', 'world', {
secondFactorDevices: [
@@ -0,0 +1,46 @@
import { vol } from 'memfs';
import pkgDir from 'pkg-dir';
import ProjectDirContextField from '../ProjectDirContextField';
jest.mock('@expo/config');
jest.mock('fs');
jest.mock('pkg-dir');
jest.mock('../../../prompts');
beforeEach(() => {
jest.resetAllMocks();
});
describe(ProjectDirContextField['findProjectRootAsync'], () => {
beforeEach(() => {
vol.reset();
});
it('throws if not inside the project directory', async () => {
vol.fromJSON(
{
'./README.md': '1',
},
'/app'
);
await expect(ProjectDirContextField['findProjectRootAsync']()).rejects.toThrow(
'Run this command inside a project directory.'
);
});
it('returns the root directory of the project', async () => {
jest.mocked(pkgDir).mockResolvedValue('/app');
vol.fromJSON(
{
'./README.md': '1',
'./package.json': '2',
'./src/index.ts': '3',
},
'/app'
);
const projectRoot = await ProjectDirContextField['findProjectRootAsync']();
expect(projectRoot).toBe('/app');
});
});
@@ -0,0 +1,104 @@
import { getConfig, modifyConfigAsync } from '@expo/config';
import { vol } from 'memfs';
import { Role } from '../../../graphql/generated';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import ActorContextField from '../ActorContextField';
import ProjectDirContextField from '../ProjectDirContextField';
import ProjectIdContextField from '../ProjectIdContextField';
jest.mock('@expo/config');
jest.mock('fs');
jest.mock('../ActorContextField');
jest.mock('../ProjectDirContextField');
jest.mock('../../../user/User');
jest.mock('../../../ora', () => ({
ora: () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
}),
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
describe(ProjectIdContextField.name, () => {
beforeEach(() => {
jest.mocked(ActorContextField['ensureLoggedInAsync']).mockResolvedValue({
__typename: 'User',
id: 'user_id',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
});
vol.fromJSON(
{
'./README.md': '1',
'./package.json': '2',
'./src/index.ts': '3',
},
'/app'
);
});
it('gets the project ID from app config if exists', async () => {
jest.mocked(getConfig).mockReturnValue({
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
} as any);
jest.mocked(ProjectDirContextField['findProjectRootAsync']).mockResolvedValue('/app');
await expect(
new ProjectIdContextField().getValueAsync({ nonInteractive: false })
).resolves.toEqual('1234');
});
it('fetches the project ID when not in app config, and sets it in the config', async () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(ProjectDirContextField['findProjectRootAsync']).mockResolvedValue('/app');
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'success',
config: { expo: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } } },
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '2345');
const projectId = await new ProjectIdContextField().getValueAsync({ nonInteractive: false });
expect(projectId).toBe('2345');
expect(modifyConfigAsync).toHaveBeenCalledTimes(1);
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', {
extra: { eas: { projectId: '2345' } },
});
});
it('throws if writing the ID back to the config fails', async () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(ProjectDirContextField['findProjectRootAsync']).mockResolvedValue('/app');
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'fail',
config: null,
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '4567');
await expect(
new ProjectIdContextField().getValueAsync({ nonInteractive: false })
).rejects.toThrow();
});
});
@@ -1,18 +1,13 @@
import EasCommand, { CommandConfiguration } from '../../commandUtils/EasCommand';
import EasCommand from '../../commandUtils/EasCommand';
import ActorContextField from '../../commandUtils/context/ActorContextField';
import Log from '../../log';
import { showLoginPromptAsync } from '../../user/actions';
export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
static override aliases = ['login'];
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
canRunOutsideProject: true,
};
async runAsync(): Promise<void> {
await showLoginPromptAsync();
await ActorContextField['showLoginPromptAsync']();
Log.log('Logged in');
}
}
@@ -1,4 +1,4 @@
import EasCommand, { CommandConfiguration } from '../../commandUtils/EasCommand';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { logoutAsync } from '../../user/User';
@@ -6,11 +6,6 @@ export default class AccountLogout extends EasCommand {
static override description = 'log out';
static override aliases = ['logout'];
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
canRunOutsideProject: true,
};
async runAsync(): Promise<void> {
await logoutAsync();
Log.log('Logged out');
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import EasCommand, { CommandConfiguration } from '../../commandUtils/EasCommand';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { getActorDisplayName, getUserAsync } from '../../user/User';
@@ -8,11 +8,6 @@ export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
static override aliases = ['whoami'];
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
canRunOutsideProject: true,
};
async runAsync(): Promise<void> {
const user = await getUserAsync();
if (user) {
+1 -5
View File
@@ -1,4 +1,4 @@
import EasCommand, { CommandConfiguration } from '../commandUtils/EasCommand';
import EasCommand from '../commandUtils/EasCommand';
import Log from '../log';
import UserSettings from '../user/UserSettings';
@@ -7,10 +7,6 @@ export default class AnalyticsView extends EasCommand {
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
};
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
if (status) {
@@ -4,11 +4,14 @@ import chalk from 'chalk';
import { cleanUpOldEasBuildGradleScriptAsync } from '../../build/android/syncProjectConfiguration';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import Log, { learnMore } from '../../log';
import { RequestedPlatform } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, isExpoUpdatesInstalled } from '../../project/projectUtils';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
@@ -28,11 +31,12 @@ export default class BuildConfigure extends EasCommand {
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildConfigure);
const { projectId } = await this.getContextAsync(BuildConfigure, {
const { projectId, projectDir } = await this.getContextAsync(BuildConfigure, {
nonInteractive: false,
});
@@ -42,7 +46,6 @@ export default class BuildConfigure extends EasCommand {
await getVcsClient().ensureRepoExistsAsync();
const projectDir = await findProjectRootAsync();
const expoUpdatesIsInstalled = isExpoUpdatesInstalled(projectDir);
const platform =
+3 -4
View File
@@ -9,13 +9,13 @@ import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmi
import { UserInputResourceClass } from '../../build/types';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
import { findProjectRootAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import uniq from '../../utils/expodash/uniq';
import { enableJsonOutput } from '../../utils/json';
@@ -102,6 +102,7 @@ export default class Build extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
@@ -112,12 +113,10 @@ export default class Build extends EasCommand {
}
const flags = this.sanitizeFlags(rawFlags);
const { actor, projectId } = await this.getContextAsync(Build, {
const { actor, projectId, projectDir } = await this.getContextAsync(Build, {
nonInteractive: flags.nonInteractive,
});
const projectDir = await findProjectRootAsync();
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
if (!flags.localBuildOptions.enable) {
@@ -7,12 +7,12 @@ import { v4 as uuidv4 } from 'uuid';
import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { findProjectRootAsync } from '../../project/projectUtils';
import { getTmpDirectory } from '../../utils/paths';
import { getVcsClient } from '../../vcs';
@@ -68,11 +68,12 @@ export default class BuildInspect extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildInspect);
const { actor, projectId } = await this.getContextAsync(BuildInspect, {
const { actor, projectId, projectDir } = await this.getContextAsync(BuildInspect, {
nonInteractive: false,
});
@@ -93,7 +94,6 @@ export default class BuildInspect extends EasCommand {
await vcs.makeShallowCopyAsync(tmpWorkingdir);
await this.copyToOutputDirAsync(tmpWorkingdir, outputDirectory);
} else {
const projectDir = await findProjectRootAsync();
try {
await runBuildAndSubmitAsync(
projectDir,
@@ -6,6 +6,7 @@ import chalk from 'chalk';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../../commandUtils/EasCommand';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
@@ -17,10 +18,7 @@ import { VERSION_CODE_REQUIREMENTS, isValidVersionCode } from '../../../project/
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
import { getExpoConfig } from '../../../project/expoConfig';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import {
findProjectRootAsync,
getDisplayNameForProjectIdAsync,
} from '../../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../../project/projectUtils';
import {
ensureVersionSourceIsRemoteAsync,
getBuildVersionName,
@@ -47,16 +45,15 @@ export default class BuildVersionSetView extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSetView);
const { actor, projectId } = await this.getContextAsync(BuildVersionSetView, {
const { actor, projectId, projectDir } = await this.getContextAsync(BuildVersionSetView, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const platform = await selectPlatformAsync(flags.platform);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
@@ -8,6 +8,7 @@ import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '.
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../../build/ios/version';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../../commandUtils/EasCommand';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
@@ -25,7 +26,6 @@ import { getExpoConfig } from '../../../project/expoConfig';
import { resolveXcodeBuildContextAsync } from '../../../project/ios/scheme';
import { resolveTargetsAsync } from '../../../project/ios/target';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import { findProjectRootAsync } from '../../../project/projectUtils';
import {
ensureVersionSourceIsRemoteAsync,
getBuildVersionName,
@@ -62,14 +62,14 @@ export default class BuildVersionSyncView extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSyncView);
const { actor, projectId } = await this.getContextAsync(BuildVersionSyncView, {
const { actor, projectId, projectDir } = await this.getContextAsync(BuildVersionSyncView, {
nonInteractive: true,
});
const projectDir = await findProjectRootAsync();
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = new EasJsonAccessor(projectDir);
+6 -6
View File
@@ -4,12 +4,11 @@ import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand, { CommandConfiguration } from '../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectDirContext } from '../commandUtils/EasCommand';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { appPlatformEmojis } from '../platform';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync } from '../project/projectUtils';
import { selectAsync } from '../prompts';
export default class Config extends EasCommand {
@@ -25,19 +24,20 @@ export default class Config extends EasCommand {
}),
};
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
static override contextDefinition = {
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(Config);
const { projectDir } = await this.getContextAsync(Config, {
nonInteractive: false,
});
const { platform: maybePlatform, profile: maybeProfile } = flags as {
platform?: Platform;
profile?: string;
};
const projectDir = await findProjectRootAsync();
const accessor = new EasJsonAccessor(projectDir);
const profileName =
maybeProfile ??
+5 -3
View File
@@ -2,11 +2,11 @@ import { Flags } from '@oclif/core';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../commandUtils/EasCommand';
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync } from '../project/projectUtils';
export default class Credentials extends EasCommand {
static override description = 'manage credentials';
@@ -18,13 +18,15 @@ export default class Credentials extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(Credentials);
const { actor, projectId } = await this.getContextAsync(Credentials, { nonInteractive: false });
const { actor, projectId, projectDir } = await this.getContextAsync(Credentials, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
await new SelectPlatform(actor, { projectId, exp }, flags.platform).runAsync();
+9 -7
View File
@@ -1,20 +1,23 @@
import { Platform } from '@expo/eas-build-job';
import envinfo from 'envinfo';
import EasCommand, { CommandConfiguration } from '../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectDirContext } from '../commandUtils/EasCommand';
import Log from '../log';
import { findProjectRootAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { easCliVersion } from '../utils/easCli';
export default class Diagnostics extends EasCommand {
static override description = 'display environment info';
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
static override contextDefinition = {
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
const { projectDir } = await this.getContextAsync(Diagnostics, {
nonInteractive: true,
});
const info = await envinfo.run(
{
System: ['OS', 'Shell'],
@@ -40,12 +43,11 @@ export default class Diagnostics extends EasCommand {
);
Log.log(info.trimEnd());
await this.printWorkflowAsync();
await this.printWorkflowAsync(projectDir);
Log.newLine();
}
private async printWorkflowAsync(): Promise<void> {
const projectDir = await findProjectRootAsync();
private async printWorkflowAsync(projectDir: string): Promise<void> {
const androidWorkflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const iosWorkflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
@@ -6,6 +6,7 @@ import path from 'path';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { CredentialsContext } from '../../credentials/context';
@@ -13,7 +14,6 @@ import Log, { learnMore } from '../../log';
import { createMetadataContextAsync } from '../../metadata/context';
import { downloadMetadataAsync } from '../../metadata/download';
import { handleMetadataError } from '../../metadata/errors';
import { findProjectRootAsync } from '../../project/projectUtils';
export default class MetadataPull extends EasCommand {
static override description = 'generate the local store configuration from the app stores';
@@ -29,17 +29,17 @@ export default class MetadataPull extends EasCommand {
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandLoggedInContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPull);
const { actor, projectId } = await this.getContextAsync(MetadataPull, {
const { actor, projectId, projectDir } = await this.getContextAsync(MetadataPull, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
// this command is interactive (all nonInteractive flags passed to utility functions are false)
@@ -4,6 +4,7 @@ import { Flags } from '@oclif/core';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { CredentialsContext } from '../../credentials/context';
@@ -11,7 +12,6 @@ import Log, { learnMore } from '../../log';
import { createMetadataContextAsync } from '../../metadata/context';
import { handleMetadataError } from '../../metadata/errors';
import { uploadMetadataAsync } from '../../metadata/upload';
import { findProjectRootAsync } from '../../project/projectUtils';
export default class MetadataPush extends EasCommand {
static override description = 'sync the local store configuration to the app stores';
@@ -27,17 +27,17 @@ export default class MetadataPush extends EasCommand {
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandLoggedInContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPush);
const { actor, projectId } = await this.getContextAsync(MetadataPush, {
const { actor, projectId, projectDir } = await this.getContextAsync(MetadataPush, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
// this command is interactive (all nonInteractive flags passed to utility functions are false)
+7 -4
View File
@@ -1,24 +1,27 @@
import openBrowserAsync from 'better-opn';
import { getProjectDashboardUrl } from '../build/utils/url';
import EasCommand, { EASCommandProjectIdContext } from '../commandUtils/EasCommand';
import EasCommand, {
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../commandUtils/EasCommand';
import { ora } from '../ora';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync, getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
export default class Open extends EasCommand {
static override description = 'open the project page in a web browser';
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
// this command is interactive by nature (only really run by humans in a terminal)
const { projectId } = await this.getContextAsync(Open, {
const { projectId, projectDir } = await this.getContextAsync(Open, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const account = await getOwnerAccountForProjectIdAsync(projectId);
@@ -2,17 +2,17 @@ import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
import chalk from 'chalk';
import { vol } from 'memfs';
import ActorContextField from '../../../commandUtils/context/ActorContextField';
import ProjectDirContextField from '../../../commandUtils/context/ProjectDirContextField';
import ProjectIdContextField from '../../../commandUtils/context/ProjectIdContextField';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { findProjectRootAsync, saveProjectIdToAppConfigAsync } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { ensureLoggedInAsync } from '../../../user/actions';
import ProjectInit from '../init';
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('../../../project/projectUtils');
jest.mock('../../../prompts');
jest.mock('../../../user/actions');
jest.mock('../../../graphql/mutations/AppMutation');
@@ -22,6 +22,7 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../commandUtils/context/ProjectIdContextField');
let originalProcessArgv: string[];
@@ -72,15 +73,14 @@ function mockTestProject(options: {
const mockManifest = { exp: appJSON.expo };
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
jest.mocked(findProjectRootAsync).mockResolvedValue('/test-project');
jest.spyOn(ProjectDirContextField.prototype, 'getValueAsync').mockResolvedValue('/test-project');
jest.spyOn(ActorContextField.prototype, 'getValueAsync').mockResolvedValue(jester);
}
const commandOptions = { root: '/test-project' } as any;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(ensureLoggedInAsync).mockResolvedValue(jester);
});
describe(ProjectInit.name, () => {
@@ -93,20 +93,23 @@ describe(ProjectInit.name, () => {
describe('interactive', () => {
it('is no-op if already configured for id', async () => {
await new ProjectInit(['--id', '1234'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
});
it('prompts to overwrite when different', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
await new ProjectInit(['--id', '12345'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).toHaveBeenCalledWith(
'/test-project',
'12345'
);
expect(confirmAsync).toHaveBeenCalled();
});
it('aborts when prompt to overwrite is declined', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await new ProjectInit(['--id', '12345'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
expect(confirmAsync).toHaveBeenCalled();
});
});
@@ -116,7 +119,10 @@ describe(ProjectInit.name, () => {
await new ProjectInit(['--id', '12345', '--force'], {
root: '/test-project',
} as any).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).toHaveBeenCalledWith(
'/test-project',
'12345'
);
expect(confirmAsync).not.toHaveBeenCalled();
});
});
@@ -132,7 +138,7 @@ describe(ProjectInit.name, () => {
'1234'
)}. Use --force flag to overwrite.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
});
});
});
@@ -144,7 +150,10 @@ describe(ProjectInit.name, () => {
it('configures', async () => {
await new ProjectInit(['--id', '1234'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '1234');
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).toHaveBeenCalledWith(
'/test-project',
'1234'
);
});
});
});
@@ -157,7 +166,7 @@ describe(ProjectInit.name, () => {
it('does not configure', async () => {
await new ProjectInit([], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
});
});
@@ -175,7 +184,10 @@ describe(ProjectInit.name, () => {
it('prompts for confirmation to link', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
await new ProjectInit([], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).toHaveBeenCalledWith(
'/test-project',
'123456'
);
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
@@ -184,7 +196,7 @@ describe(ProjectInit.name, () => {
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrow(
'Project ID configuration canceled. Re-run the command to select a different account/project.'
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
});
@@ -194,7 +206,10 @@ describe(ProjectInit.name, () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
await new ProjectInit([], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).toHaveBeenCalledWith(
'/test-project',
'0129'
);
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
@@ -203,7 +218,7 @@ describe(ProjectInit.name, () => {
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrowError(
'Project ID configuration canceled for @jester/testing-123.'
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
});
@@ -229,7 +244,7 @@ describe(ProjectInit.name, () => {
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrowError(
`You don't have permission to create a new project on the other account and no matching project already exists on the account.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(ProjectIdContextField['saveProjectIdToAppConfigAsync']).not.toHaveBeenCalled();
});
});
});
+16 -13
View File
@@ -4,18 +4,18 @@ import nullthrows from 'nullthrows';
import terminalLink from 'terminal-link';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand, { EASCommandLoggedInContext } from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
} from '../../commandUtils/EasCommand';
import ProjectIdContextField from '../../commandUtils/context/ProjectIdContextField';
import { AppPrivacy, Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import {
findProjectRootAsync,
saveProjectIdToAppConfigAsync,
toAppPrivacy,
} from '../../project/projectUtils';
import { toAppPrivacy } from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
@@ -45,21 +45,22 @@ export default class ProjectInit extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectDirContext,
};
private static async saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
): Promise<void> {
await saveProjectIdToAppConfigAsync(projectDir, projectId);
await ProjectIdContextField['saveProjectIdToAppConfigAsync'](projectDir, projectId);
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
}
private static async initializeWithExplicitIDAsync(
projectId: string,
projectDir: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
@@ -101,8 +102,10 @@ export default class ProjectInit extends EasCommand {
}
}
private static async initializeWithInteractiveSelectionAsync(actor: Actor): Promise<void> {
const projectDir = await findProjectRootAsync();
private static async initializeWithInteractiveSelectionAsync(
actor: Actor,
projectDir: string
): Promise<void> {
const exp = getExpoConfig(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
@@ -218,12 +221,12 @@ export default class ProjectInit extends EasCommand {
const {
flags: { id, force, 'non-interactive': nonInteractive },
} = await this.parse(ProjectInit);
const { actor } = await this.getContextAsync(ProjectInit, { nonInteractive });
const { actor, projectDir } = await this.getContextAsync(ProjectInit, { nonInteractive });
if (id) {
await ProjectInit.initializeWithExplicitIDAsync(id, { force, nonInteractive });
await ProjectInit.initializeWithExplicitIDAsync(id, projectDir, { force, nonInteractive });
} else {
await ProjectInit.initializeWithInteractiveSelectionAsync(actor);
await ProjectInit.initializeWithInteractiveSelectionAsync(actor, projectDir);
}
}
}
+5 -4
View File
@@ -4,6 +4,7 @@ import chalk from 'chalk';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../commandUtils/EasCommand';
import { StatuspageServiceName, SubmissionFragment } from '../graphql/generated';
@@ -16,7 +17,6 @@ import {
selectRequestedPlatformAsync,
toPlatforms,
} from '../platform';
import { findProjectRootAsync } from '../project/projectUtils';
import { SubmitArchiveFlags, createSubmissionContextAsync } from '../submit/context';
import {
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish,
@@ -96,16 +96,17 @@ export default class Submit extends EasCommand {
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(Submit);
const { actor, projectId } = await this.getContextAsync(Submit, { nonInteractive: false });
const { actor, projectId, projectDir } = await this.getContextAsync(Submit, {
nonInteractive: false,
});
const flags = this.sanitizeFlags(rawFlags);
const projectDir = await findProjectRootAsync();
await maybeWarnAboutEasOutagesAsync([StatuspageServiceName.EasSubmit]);
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
@@ -5,13 +5,15 @@ import assert from 'assert';
import chalk from 'chalk';
import { getEASUpdateURL } from '../../api';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { AppPlatform } from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import { RequestedPlatform, appPlatformDisplayNames } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
installExpoUpdatesAsync,
isExpoUpdatesInstalledOrAvailable,
} from '../../project/projectUtils';
@@ -36,6 +38,7 @@ export default class UpdateConfigure extends EasCommand {
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
@@ -43,11 +46,10 @@ export default class UpdateConfigure extends EasCommand {
'💡 The following process will configure your project to run EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
);
const { flags } = await this.parse(UpdateConfigure);
const { projectId } = await this.getContextAsync(UpdateConfigure, {
const { projectId, projectDir } = await this.getContextAsync(UpdateConfigure, {
nonInteractive: true,
});
const platform = flags.platform as RequestedPlatform;
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
if (!isExpoUpdatesInstalledOrAvailable(projectDir, exp.sdkVersion)) {
@@ -10,7 +10,10 @@ import { getEASUpdateURL } from '../../api';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { BranchNotFoundError, getDefaultBranchNameAsync } from '../../branch/utils';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandProjectDirContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
@@ -29,7 +32,6 @@ import Log, { learnMore, link } from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getOwnerAccountForProjectIdAsync,
installExpoUpdatesAsync,
isExpoUpdatesInstalledOrAvailable,
@@ -170,6 +172,7 @@ export default class UpdatePublish extends EasCommand {
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandProjectDirContext,
};
async runAsync(): Promise<void> {
@@ -188,7 +191,7 @@ export default class UpdatePublish extends EasCommand {
'non-interactive': nonInteractive,
json: jsonFlag,
} = flags;
const { projectId } = await this.getContextAsync(UpdatePublish, {
const { projectId, projectDir } = await this.getContextAsync(UpdatePublish, {
nonInteractive,
});
@@ -200,7 +203,6 @@ export default class UpdatePublish extends EasCommand {
// If a group was specified, that means we are republishing it.
republish = republish || !!group;
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir, {
isPublicConfig: true,
});
@@ -1,147 +0,0 @@
import { getConfig, modifyConfigAsync } from '@expo/config';
import { vol } from 'memfs';
import { Role } from '../../graphql/generated';
import { Actor, getUserAsync } from '../../user/User';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { findProjectRootAsync, getProjectIdAsync } from '../projectUtils';
jest.mock('@expo/config');
jest.mock('fs');
jest.mock('../fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../prompts');
jest.mock('../../user/User');
jest.mock('../../ora', () => ({
ora: () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
}),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe(findProjectRootAsync, () => {
beforeEach(() => {
vol.reset();
});
it('throws if not inside the project directory', async () => {
vol.fromJSON(
{
'./README.md': '1',
},
'/app'
);
await expect(findProjectRootAsync({ cwd: '/app' })).rejects.toThrow(
'Run this command inside a project directory.'
);
});
it('defaults to process.cwd() if defaultToProcessCwd = true', async () => {
const spy = jest.spyOn(process, 'cwd').mockReturnValue('/this/is/fake/process/cwd');
try {
vol.fromJSON(
{
'./README.md': '1',
},
'/app'
);
const projectDir = await findProjectRootAsync({ cwd: '/app', defaultToProcessCwd: true });
expect(projectDir).toBe('/this/is/fake/process/cwd');
} finally {
spy.mockRestore();
}
});
it('returns the root directory of the project', async () => {
vol.fromJSON(
{
'./README.md': '1',
'./package.json': '2',
'./src/index.ts': '3',
},
'/app'
);
const projectRoot = await findProjectRootAsync({ cwd: '/app/src' });
expect(projectRoot).toBe('/app');
});
});
describe(getProjectIdAsync, () => {
beforeEach(() => {
jest.mocked(getUserAsync).mockImplementation(
async (): Promise<Actor> => ({
__typename: 'User',
id: 'user_id',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
})
);
});
it('gets the project ID from app config if exists', async () => {
await expect(
getProjectIdAsync(
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
{ nonInteractive: false }
)
).resolves.toEqual('1234');
});
it('fetches the project ID when not in app config, and sets it in the config', async () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'success',
config: { expo: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } } },
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '2345');
const projectId = await getProjectIdAsync(
{ name: 'test', slug: 'test' },
{ nonInteractive: false },
{ cwd: '/app' }
);
expect(projectId).toBe('2345');
expect(modifyConfigAsync).toHaveBeenCalledTimes(1);
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', {
extra: { eas: { projectId: '2345' } },
});
});
it('throws if writing the ID back to the config fails', async () => {
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
jest.mocked(modifyConfigAsync).mockResolvedValue({
type: 'fail',
config: null,
});
jest
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
.mockImplementation(async () => '4567');
await expect(
getProjectIdAsync({ name: 'test', slug: 'test' }, { nonInteractive: false }, { cwd: '/app' })
).rejects.toThrow();
});
});
+1 -145
View File
@@ -1,22 +1,15 @@
import { ExpoConfig, getConfigFilePaths, getPackageJson, modifyConfigAsync } from '@expo/config';
import { Env } from '@expo/eas-build-job';
import { ExpoConfig, getConfigFilePaths, getPackageJson } from '@expo/config';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import pkgDir from 'pkg-dir';
import resolveFrom from 'resolve-from';
import semver from 'semver';
import { AccountFragment, AppPrivacy } from '../graphql/generated';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log from '../log';
import { ora } from '../ora';
import { Actor } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { expoCommandAsync } from '../utils/expoCli';
import { getVcsClient } from '../vcs';
import { getExpoConfig } from './expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from './fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
export function getUsername(exp: ExpoConfig, user: Actor): string | undefined {
switch (user.__typename) {
@@ -34,143 +27,6 @@ export function getUsername(exp: ExpoConfig, user: Actor): string | undefined {
}
}
export async function findProjectRootAsync({
cwd,
defaultToProcessCwd = false,
}: {
cwd?: string;
defaultToProcessCwd?: boolean;
} = {}): Promise<string> {
const projectRootDir = await pkgDir(cwd);
if (!projectRootDir) {
if (!defaultToProcessCwd) {
throw new Error('Run this command inside a project directory.');
} else {
return process.cwd();
}
} else {
let vcsRoot;
try {
vcsRoot = path.normalize(await getVcsClient().getRootPathAsync());
} catch {}
if (vcsRoot && vcsRoot.startsWith(projectRootDir) && vcsRoot !== projectRootDir) {
throw new Error(
`package.json is outside of the current git repository (project root: ${projectRootDir}, git root: ${vcsRoot}.`
);
}
return projectRootDir;
}
}
/**
* Save an EAS project ID to the appropriate field in the app config.
*/
export async function saveProjectIdToAppConfigAsync(
projectDir: string,
projectId: string,
options: { env?: Env } = {}
): Promise<void> {
const exp = getExpoConfig(projectDir, options);
const result = await modifyConfigAsync(projectDir, {
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
});
switch (result.type) {
case 'success':
break;
case 'warn': {
Log.warn();
Log.warn(
`Warning: Your project uses dynamic app configuration, and the EAS project ID can't automatically be added to it.`
);
Log.warn(
chalk.dim(
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
)
);
Log.warn();
Log.warn(
`To complete the setup process, set the ${chalk.bold(
'extra.eas.projectId'
)} in your ${chalk.bold(getProjectConfigDescription(projectDir))}:`
);
Log.warn();
Log.warn(chalk.bold(JSON.stringify({ expo: { extra: { eas: { projectId } } } }, null, 2)));
Log.warn();
throw new Error(result.message);
}
case 'fail':
throw new Error(result.message);
default:
throw new Error('Unexpected result type from modifyConfigAsync');
}
}
/**
* Get the EAS project ID from the app config. If the project ID is not set in the config,
* use the owner/slug to identify an EAS project on the server (asking for confirmation first),
* and attempt to save the EAS project ID to the appropriate field in the app config. If unable to
* save to the app config, throw an error.
*/
export async function getProjectIdAsync(
exp: ExpoConfig,
options: { env?: Env; nonInteractive: boolean },
findProjectRootOptions: {
cwd?: string;
defaultToProcessCwd?: boolean;
} = {}
): Promise<string> {
const localProjectId = exp.extra?.eas?.projectId;
if (localProjectId) {
return localProjectId;
}
const projectDir = await findProjectRootAsync(findProjectRootOptions);
if (!projectDir) {
throw new Error('This command must be run inside a project directory.');
}
Log.warn('EAS project not configured.');
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
if (exp.owner) {
return exp.owner;
}
switch (user.__typename) {
case 'User':
return user.username;
case 'Robot':
throw new Error(
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
);
}
};
const actor = await ensureLoggedInAsync({ nonInteractive: options.nonInteractive });
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
{
accountName: getAccountNameForEASProjectSync(exp, actor),
projectName: exp.slug,
privacy: toAppPrivacy(exp.privacy),
},
{
nonInteractive: options.nonInteractive,
},
actor
);
const spinner = ora(`Linking local project to EAS project ${projectId}`).start();
try {
await saveProjectIdToAppConfigAsync(projectDir, projectId, options);
spinner.succeed(`Linked local project to EAS project ${projectId}`);
} catch (e: any) {
spinner.fail();
throw e;
}
return projectId;
}
export const toAppPrivacy = (privacy: ExpoConfig['privacy']): AppPrivacy => {
if (privacy === 'public') {
return AppPrivacy.Public;
+1 -78
View File
@@ -1,82 +1,5 @@
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { ApiV2Error } from '../api';
import { AccountFragment } from '../graphql/generated';
import Log, { learnMore } from '../log';
import { promptAsync } from '../prompts';
import { Actor, getUserAsync, loginAsync } from './User';
import { retryUsernamePasswordAuthWithOTPAsync } from './otp';
export async function showLoginPromptAsync({
nonInteractive = false,
printNewLine = false,
} = {}): Promise<void> {
if (nonInteractive) {
Errors.error(
`Either log in with ${chalk.bold('eas login')} or set the ${chalk.bold(
'EXPO_TOKEN'
)} environment variable if you're using EAS CLI on CI (${learnMore(
'https://docs.expo.dev/accounts/programmatic-access/',
{ dim: false }
)})`
);
}
if (printNewLine) {
Log.newLine();
}
Log.log('Log in to EAS');
const { username, password } = await promptAsync([
{
type: 'text',
name: 'username',
message: 'Email or username',
},
{
type: 'password',
name: 'password',
message: 'Password',
},
]);
try {
await loginAsync({
username,
password,
});
} catch (e) {
if (e instanceof ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
await retryUsernamePasswordAuthWithOTPAsync(
username,
password,
e.expoApiV2ErrorMetadata as any
);
} else {
throw e;
}
}
}
export async function ensureLoggedInAsync({
nonInteractive,
}: {
nonInteractive: boolean;
}): Promise<Actor> {
let user: Actor | undefined;
try {
user = await getUserAsync();
} catch {}
if (!user) {
Log.warn('An Expo user account is required to proceed.');
await showLoginPromptAsync({ nonInteractive, printNewLine: true });
user = await getUserAsync();
}
return nullthrows(user);
}
import { Actor } from './User';
export function ensureActorHasPrimaryAccount(user: Actor): AccountFragment {
if (user.__typename === 'User') {