Compare commits

...
8 changed files with 353 additions and 231 deletions

No files matched your search

@@ -1,8 +1,10 @@
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json'; import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager'; import * as PackageManagerUtils from '@expo/package-manager';
import { Command } from '@oclif/core'; import { Command, Flags } from '@oclif/core';
import chalk from 'chalk'; import chalk from 'chalk';
import fs from 'fs-extra'; import fs from 'fs-extra';
// eslint-disable-next-line no-restricted-imports
import { Options, Ora } from 'ora';
import path from 'path'; import path from 'path';
import semver from 'semver'; import semver from 'semver';
@@ -12,15 +14,48 @@ import {
initAsync as initAnalyticsAsync, initAsync as initAnalyticsAsync,
logEvent, logEvent,
} from '../analytics/rudderstackClient'; } from '../analytics/rudderstackClient';
import { learnMore } from '../log'; import Log, { learnMore } from '../log';
import { interactiveOra, nonInteractiveOra } from '../ora';
import { findProjectRootAsync } from '../project/projectUtils'; import { findProjectRootAsync } from '../project/projectUtils';
import { InteractivePrompts, NonInteractivePrompts, Prompts } from '../prompts';
import { getUserAsync } from '../user/User'; import { getUserAsync } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions'; import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli'; import { easCliVersion } from '../utils/easCli';
import { setVcsClient } from '../vcs'; import { setVcsClient } from '../vcs';
import GitClient from '../vcs/clients/git'; import GitClient from '../vcs/clients/git';
type CommandOptions =
| {
nonInteractive: true;
json: true;
}
| {
nonInteractive: boolean;
json: false;
};
export type CommandContext = Readonly<
CommandOptions & {
logger: Log;
prompts: Prompts;
ora: (options?: Options | string) => Ora;
}
>;
export default abstract class EasCommand extends Command { export default abstract class EasCommand extends Command {
static override globalFlags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
dependsOn: ['non-interactive'],
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
// disable built in oclif json stuff
enableJsonFlag = false;
/** /**
* When user data is unavailable locally, determines if the command will * When user data is unavailable locally, determines if the command will
* force the user to log in * force the user to log in
@@ -28,10 +63,26 @@ export default abstract class EasCommand extends Command {
protected requiresAuthentication = true; protected requiresAuthentication = true;
protected mustBeRunInsideProject = true; protected mustBeRunInsideProject = true;
protected abstract runAsync(): Promise<any>; protected abstract runAsync({
nonInteractive,
json,
logger,
prompts: {
promptAsync,
confirmAsync,
selectAsync,
toggleConfirmAsync,
pressAnyKeyToContinueAsync,
},
ora,
}: CommandContext): Promise<{ jsonOutput: object }>;
// eslint-disable-next-line async-protect/async-suffix // eslint-disable-next-line async-protect/async-suffix
async run(): Promise<any> { async run(): Promise<any> {
const { flags } = await this.parse();
const json = flags.json ?? false;
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await initAnalyticsAsync(); await initAnalyticsAsync();
if (this.mustBeRunInsideProject) { if (this.mustBeRunInsideProject) {
@@ -41,8 +92,6 @@ export default abstract class EasCommand extends Command {
} }
if (this.requiresAuthentication) { if (this.requiresAuthentication) {
const { flags } = await this.parse();
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await ensureLoggedInAsync({ nonInteractive }); await ensureLoggedInAsync({ nonInteractive });
} else { } else {
await getUserAsync(); await getUserAsync();
@@ -52,7 +101,18 @@ export default abstract class EasCommand extends Command {
// commands/submit === submit, commands/build/list === build:list // commands/submit === submit, commands/build/list === build:list
action: `eas ${this.id}`, action: `eas ${this.id}`,
}); });
return this.runAsync();
const jsonOutput = await this.runAsync({
nonInteractive,
json: json ?? false,
logger: new Log(json),
prompts: nonInteractive ? new InteractivePrompts() : new NonInteractivePrompts(),
ora: nonInteractive ? nonInteractiveOra : interactiveOra,
});
if (json) {
this.printJsonOnlyOutput(jsonOutput);
}
} }
// eslint-disable-next-line async-protect/async-suffix // eslint-disable-next-line async-protect/async-suffix
@@ -119,4 +179,26 @@ export default abstract class EasCommand extends Command {
packageJson?.devDependencies?.['eas-cli'] !== undefined packageJson?.devDependencies?.['eas-cli'] !== undefined
); );
} }
private printJsonOnlyOutput(value: object): void {
try {
new Log(false).log(JSON.stringify(this.sanitizeValue(value), null, 2));
} catch {}
}
private sanitizeValue(value: any): unknown {
if (Array.isArray(value)) {
return value.map(val => this.sanitizeValue(val));
} else if (value && typeof value === 'object') {
const result: Record<string, any> = {};
Object.keys(value).forEach(key => {
if (key !== '__typename' && value[key] !== null) {
result[key] = this.sanitizeValue(value[key]);
}
});
return result;
} else {
return value;
}
}
} }
@@ -1,12 +1,15 @@
import { Flags } from '@oclif/core'; import { Flags } from '@oclif/core';
export const getPaginatedQueryOptions = ( export const getPaginatedQueryOptions = (flags: {
flags: Record<keyof typeof EasPaginatedQueryFlags, any> json: boolean;
): PaginatedQueryOptions => { 'non-interactive': boolean;
offset: number | undefined;
limit: number | undefined;
}): PaginatedQueryOptions => {
return { return {
json: flags.json ?? false, json: flags.json,
offset: flags.offset ?? 0, offset: flags.offset ?? 0,
nonInteractive: flags['non-interactive'] ?? false, nonInteractive: flags['non-interactive'],
...('limit' in flags && { limit: flags.limit }), ...('limit' in flags && { limit: flags.limit }),
}; };
}; };
@@ -40,12 +43,6 @@ export const EasPaginatedQueryFlags = {
// eslint-disable-next-line async-protect/async-suffix // eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, 100), parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, 100),
}), }),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
}; };
// options required to control a paginated query // options required to control a paginated query
+17 -29
View File
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk'; import chalk from 'chalk';
import gql from 'graphql-tag'; import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand'; import EasCommand, { CommandContext } from '../../commandUtils/EasCommand';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client'; import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { import {
DeleteUpdateBranchMutation, DeleteUpdateBranchMutation,
@@ -11,15 +10,12 @@ import {
GetBranchInfoQuery, GetBranchInfoQuery,
GetBranchInfoQueryVariables, GetBranchInfoQueryVariables,
} from '../../graphql/generated'; } from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig'; import { getExpoConfig } from '../../project/expoConfig';
import { import {
findProjectRootAsync, findProjectRootAsync,
getProjectFullNameAsync, getProjectFullNameAsync,
getProjectIdAsync, getProjectIdAsync,
} from '../../project/projectUtils'; } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function getBranchInfoAsync({ async function getBranchInfoAsync({
appId, appId,
@@ -86,21 +82,15 @@ export default class BranchDelete extends EasCommand {
description: 'Name of the branch to delete', description: 'Name of the branch to delete',
}, },
]; ];
static override flags = {
json: Flags.boolean({
description: `return JSON with the edited branch's ID and name.`,
default: false,
}),
};
async runAsync(): Promise<void> { protected async runAsync({
nonInteractive,
logger,
prompts: { promptAsync, toggleConfirmAsync },
}: CommandContext): Promise<{ jsonOutput: object }> {
let { let {
args: { name }, args: { name },
flags: { json: jsonFlag },
} = await this.parse(BranchDelete); } = await this.parse(BranchDelete);
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync(); const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir); const exp = getExpoConfig(projectDir);
@@ -109,7 +99,7 @@ export default class BranchDelete extends EasCommand {
if (!name) { if (!name) {
const validationMessage = 'branch name may not be empty.'; const validationMessage = 'branch name may not be empty.';
if (jsonFlag) { if (nonInteractive) {
throw new Error(validationMessage); throw new Error(validationMessage);
} }
({ name } = await promptAsync({ ({ name } = await promptAsync({
@@ -126,16 +116,16 @@ export default class BranchDelete extends EasCommand {
throw new Error(`Could not find branch ${name} on ${fullName}`); throw new Error(`Could not find branch ${name} on ${fullName}`);
} }
if (!jsonFlag) { if (!nonInteractive) {
Log.addNewLineIfNone(); logger.addNewLineIfNone();
Log.warn( logger.warn(
`You are about to permanently delete branch: "${name}" and all of the updates published on it.` + `You are about to permanently delete branch: "${name}" and all of the updates published on it.` +
`\nThis action is irreversible.` `\nThis action is irreversible.`
); );
Log.newLine(); logger.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' }); const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) { if (!confirmed) {
Log.error(`Cancelled deletion of branch: "${name}".`); logger.error(`Cancelled deletion of branch: "${name}".`);
process.exit(1); process.exit(1);
} }
} }
@@ -144,12 +134,10 @@ export default class BranchDelete extends EasCommand {
branchId, branchId,
}); });
if (jsonFlag) { logger.withTick(
printJsonOnlyOutput(deletionResult); `Deleted branch "${name}" and all of its updates on project ${chalk.bold(fullName)}.`
} else { );
Log.withTick(
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(fullName)}.` return { jsonOutput: deletionResult };
);
}
} }
} }
+59 -35
View File
@@ -2,7 +2,7 @@ import { Device, DeviceStatus } from '@expo/apple-utils';
import { Flags } from '@oclif/core'; import { Flags } from '@oclif/core';
import assert from 'assert'; import assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand'; import EasCommand, { CommandContext } from '../../commandUtils/EasCommand';
import { chooseDevicesToDeleteAsync } from '../../credentials/ios/actions/DeviceUtils'; import { chooseDevicesToDeleteAsync } from '../../credentials/ios/actions/DeviceUtils';
import { AppleDeviceMutation } from '../../credentials/ios/api/graphql/mutations/AppleDeviceMutation'; import { AppleDeviceMutation } from '../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { import {
@@ -14,11 +14,8 @@ import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleT
import { authenticateAsync, getRequestContext } from '../../credentials/ios/appstore/authenticate'; import { authenticateAsync, getRequestContext } from '../../credentials/ios/appstore/authenticate';
import formatDevice from '../../devices/utils/formatDevice'; import formatDevice from '../../devices/utils/formatDevice';
import { AppleDevice, Maybe } from '../../graphql/generated'; import { AppleDevice, Maybe } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig'; import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils'; import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
export default class DeviceDelete extends EasCommand { export default class DeviceDelete extends EasCommand {
static override description = 'remove a registered device from your account'; static override description = 'remove a registered device from your account';
@@ -28,7 +25,7 @@ export default class DeviceDelete extends EasCommand {
udid: Flags.string({ multiple: true }), udid: Flags.string({ multiple: true }),
}; };
async runAsync(): Promise<void> { protected async runAsync(commandContext: CommandContext): Promise<{ jsonOutput: object }> {
let { let {
flags: { 'apple-team-id': appleTeamIdentifier, udid: udids }, flags: { 'apple-team-id': appleTeamIdentifier, udid: udids },
} = await this.parse(DeviceDelete); } = await this.parse(DeviceDelete);
@@ -38,46 +35,61 @@ export default class DeviceDelete extends EasCommand {
const accountName = await getProjectAccountNameAsync(exp); const accountName = await getProjectAccountNameAsync(exp);
if (!appleTeamIdentifier) { if (!appleTeamIdentifier) {
appleTeamIdentifier = await this.askForAppleTeamAsync(accountName); if (commandContext.nonInteractive) {
throw new Error('Must supply `apple-team-id` in non-interactive mode');
}
appleTeamIdentifier = await this.askForAppleTeamAsync(commandContext, accountName);
} }
assert(appleTeamIdentifier, 'No team identifier is specified'); assert(appleTeamIdentifier, 'No team identifier is specified');
const appleDevicesResult = await this.getDevicesForTeamAsync(accountName, appleTeamIdentifier); const appleDevicesResult = await this.getDevicesForTeamAsync(
commandContext,
accountName,
appleTeamIdentifier
);
if (!appleDevicesResult) { if (!appleDevicesResult) {
return; return { jsonOutput: [] };
} }
const { appleTeamName, appleDevices } = appleDevicesResult; const { appleTeamName, appleDevices } = appleDevicesResult;
const chosenDevices = await this.chooseDevicesToDeleteAsync(appleDevices, udids); const chosenDevices = await this.chooseDevicesToDeleteAsync(
commandContext,
appleDevices,
udids
);
if (chosenDevices.length === 0) { if (chosenDevices.length === 0) {
Log.newLine(); commandContext.logger.newLine();
Log.warn('No devices were chosen to be removed.'); commandContext.logger.warn('No devices were chosen to be removed.');
return; return { jsonOutput: [] };
} }
this.logChosenDevices(chosenDevices, appleTeamName, appleTeamIdentifier); this.logChosenDevices(commandContext, chosenDevices, appleTeamName, appleTeamIdentifier);
const hasRemoved = await this.askAndRemoveFromExpoAsync(chosenDevices);
const hasRemoved = await this.askAndRemoveFromExpoAsync(commandContext, chosenDevices);
if (!hasRemoved) { if (!hasRemoved) {
return; return { jsonOutput: [] };
} }
await this.askAndDisableOnAppleAsync(chosenDevices, appleTeamIdentifier); await this.askAndDisableOnAppleAsync(commandContext, chosenDevices, appleTeamIdentifier);
return { jsonOutput: chosenDevices.map(it => it.id) };
} }
async askAndDisableOnAppleAsync( async askAndDisableOnAppleAsync(
{ nonInteractive, logger, prompts: { toggleConfirmAsync }, ora }: CommandContext,
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[], chosenDevices: (AppleDevice | AppleDeviceQueryResult)[],
appleTeamIdentifier: string appleTeamIdentifier: string
): Promise<void> { ): Promise<void> {
Log.newLine(); logger.newLine();
const deleteOnApple = await toggleConfirmAsync({ const deleteOnApple = nonInteractive
message: 'Do you want to disable these devices on your Apple account as well?', ? true
}); : await toggleConfirmAsync({
message: 'Do you want to disable these devices on your Apple account as well?',
});
if (!deleteOnApple) { if (!deleteOnApple) {
return; return;
@@ -86,7 +98,7 @@ export default class DeviceDelete extends EasCommand {
const ctx = await authenticateAsync({ teamId: appleTeamIdentifier }); const ctx = await authenticateAsync({ teamId: appleTeamIdentifier });
const context = getRequestContext(ctx); const context = getRequestContext(ctx);
Log.addNewLineIfNone(); logger.addNewLineIfNone();
const removeAppleSpinner = ora('Disabling devices on Apple').start(); const removeAppleSpinner = ora('Disabling devices on Apple').start();
try { try {
const chosenDeviceIdentifiers = chosenDevices.map(cd => cd.identifier); const chosenDeviceIdentifiers = chosenDevices.map(cd => cd.identifier);
@@ -107,18 +119,21 @@ export default class DeviceDelete extends EasCommand {
} }
async askAndRemoveFromExpoAsync( async askAndRemoveFromExpoAsync(
{ nonInteractive, logger, prompts: { toggleConfirmAsync }, ora }: CommandContext,
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[] chosenDevices: (AppleDevice | AppleDeviceQueryResult)[]
): Promise<boolean> { ): Promise<boolean> {
Log.warn( logger.warn(
`You are about to remove the Apple device${ `You are about to remove the Apple device${
chosenDevices.length > 1 ? 's' : '' chosenDevices.length > 1 ? 's' : ''
} listed above from your Expo account.` } listed above from your Expo account.`
); );
Log.newLine(); logger.newLine();
const confirmed = await toggleConfirmAsync({ const confirmed = nonInteractive
message: 'Are you sure you wish to proceed?', ? true
}); : await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (confirmed) { if (confirmed) {
const removalSpinner = ora(`Removing Apple devices on Expo`).start(); const removalSpinner = ora(`Removing Apple devices on Expo`).start();
@@ -137,49 +152,55 @@ export default class DeviceDelete extends EasCommand {
} }
logChosenDevices( logChosenDevices(
{ logger }: CommandContext,
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[], chosenDevices: (AppleDevice | AppleDeviceQueryResult)[],
appleTeamName: Maybe<string> | undefined, appleTeamName: Maybe<string> | undefined,
appleTeamIdentifier: string appleTeamIdentifier: string
): void { ): void {
Log.addNewLineIfNone(); logger.addNewLineIfNone();
chosenDevices.forEach(device => { chosenDevices.forEach(device => {
Log.log( logger.log(
formatDevice(device, { formatDevice(device, {
appleTeamName, appleTeamName,
appleTeamIdentifier: appleTeamIdentifier!, appleTeamIdentifier: appleTeamIdentifier!,
}) })
); );
Log.newLine(); logger.newLine();
}); });
} }
async chooseDevicesToDeleteAsync( async chooseDevicesToDeleteAsync(
{ nonInteractive, logger }: CommandContext,
appleDevices: AppleDeviceQueryResult[], appleDevices: AppleDeviceQueryResult[],
udids: string[] udids: string[]
): Promise<(AppleDevice | AppleDeviceQueryResult)[]> { ): Promise<(AppleDevice | AppleDeviceQueryResult)[]> {
let chosenDevices: (AppleDeviceQueryResult | AppleDevice)[] = []; let chosenDevices: (AppleDeviceQueryResult | AppleDevice)[] = [];
Log.newLine(); logger.newLine();
if (udids) { if (udids) {
udids.forEach(udid => { udids.forEach(udid => {
const foundDevice = appleDevices.find(device => device.identifier === udid); const foundDevice = appleDevices.find(device => device.identifier === udid);
if (foundDevice) { if (foundDevice) {
chosenDevices.push(foundDevice); chosenDevices.push(foundDevice);
} else { } else {
Log.warn(`No device found with UDID ${udid}.`); logger.warn(`No device found with UDID ${udid}.`);
} }
}); });
} }
if (chosenDevices.length === 0) { if (chosenDevices.length === 0) {
Log.addNewLineIfNone(); if (nonInteractive) {
throw new Error('Must supply `udid` paramter in non-interactive mode');
}
logger.addNewLineIfNone();
chosenDevices = await chooseDevicesToDeleteAsync(appleDevices); chosenDevices = await chooseDevicesToDeleteAsync(appleDevices);
Log.newLine(); logger.newLine();
} }
return chosenDevices; return chosenDevices;
} }
async getDevicesForTeamAsync( async getDevicesForTeamAsync(
{ ora }: CommandContext,
accountName: string, accountName: string,
appleTeamIdentifier: string appleTeamIdentifier: string
): Promise<AppleDevicesByTeamIdentifierQueryResult | undefined> { ): Promise<AppleDevicesByTeamIdentifierQueryResult | undefined> {
@@ -209,7 +230,10 @@ export default class DeviceDelete extends EasCommand {
} }
} }
async askForAppleTeamAsync(accountName: string): Promise<string | undefined> { async askForAppleTeamAsync(
{ prompts: { promptAsync }, ora }: CommandContext,
accountName: string
): Promise<string | undefined> {
const teamSpinner = ora().start('Fetching the list of teams for the project…'); const teamSpinner = ora().start('Fetching the list of teams for the project…');
try { try {
+38 -31
View File
@@ -9,70 +9,77 @@ type Color = (...text: string[]) => string;
export default class Log { export default class Log {
public static readonly isDebug = boolish('EXPO_DEBUG', false); public static readonly isDebug = boolish('EXPO_DEBUG', false);
public static log(...args: any[]): void { constructor(private readonly jsonOutputMode: boolean) {}
Log.consoleLog(...args);
public log(...args: any[]): void {
this.consoleLog(...args);
} }
public static newLine(): void { public newLine(): void {
Log.consoleLog(); this.consoleLog();
} }
public static addNewLineIfNone(): void { public addNewLineIfNone(): void {
if (!Log.isLastLineNewLine) { if (!this.isLastLineNewLine) {
Log.newLine(); this.newLine();
} }
} }
public static error(...args: any[]): void { public error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red)); this.consoleLog(...this.withTextColor(args, chalk.red));
} }
public static warn(...args: any[]): void { public warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow)); this.consoleLog(...this.withTextColor(args, chalk.yellow));
} }
public static debug(...args: any[]): void { public debug(...args: any[]): void {
if (Log.isDebug) { if (Log.isDebug) {
Log.consoleLog(...args); this.consoleLog(...args);
} }
} }
public static gray(...args: any[]): void { public gray(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.gray)); this.consoleLog(...this.withTextColor(args, chalk.gray));
} }
public static warnDeprecatedFlag(flag: string, message: string): void { public warnDeprecatedFlag(flag: string, message: string): void {
Log.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`); this.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
} }
public static succeed(message: string): void { public succeed(message: string): void {
Log.log(`${chalk.green(logSymbols.success)} ${message}`); this.log(`${chalk.green(logSymbols.success)} ${message}`);
} }
public static withTick(...args: any[]): void { public withTick(...args: any[]): void {
Log.consoleLog(chalk.green(figures.tick), ...args); this.consoleLog(chalk.green(figures.tick), ...args);
} }
private static consoleLog(...args: any[]): void { private consoleLog(...args: any[]): void {
Log.updateIsLastLineNewLine(args); this.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console if (this.jsonOutputMode) {
console.log(...args); // eslint-disable-next-line no-console
console.error(...args);
} else {
// eslint-disable-next-line no-console
console.log(...args);
}
} }
private static withTextColor(args: any[], chalkColor: Color): string[] { private withTextColor(args: any[], chalkColor: Color): string[] {
return args.map(arg => chalkColor(arg)); return args.map(arg => chalkColor(arg));
} }
private static isLastLineNewLine = false; private isLastLineNewLine = false;
private static updateIsLastLineNewLine(args: any[]): void { private updateIsLastLineNewLine(args: any[]): void {
if (args.length === 0) { if (args.length === 0) {
Log.isLastLineNewLine = true; this.isLastLineNewLine = true;
} else { } else {
const lastArg = args[args.length - 1]; const lastArg = args[args.length - 1];
if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) { if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) {
Log.isLastLineNewLine = true; this.isLastLineNewLine = true;
} else { } else {
Log.isLastLineNewLine = false; this.isLastLineNewLine = false;
} }
} }
} }
+23 -1
View File
@@ -3,6 +3,28 @@ import oraReal, { Options, Ora } from 'ora';
import Log from './log'; import Log from './log';
export function nonInteractiveOra(_options?: Options | string): Ora {
const stubOra: Ora = {
text: '',
prefixText: '',
color: 'black',
spinner: 'arc',
indent: 0,
isSpinning: false,
start: () => stubOra,
stop: () => stubOra,
succeed: () => stubOra,
fail: () => stubOra,
warn: () => stubOra,
info: () => stubOra,
stopAndPersist: () => stubOra,
clear: () => stubOra,
render: () => stubOra,
frame: () => '',
};
return stubOra;
}
export { Ora, Options }; export { Ora, Options };
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -20,7 +42,7 @@ const errorReal = console.error;
* @param options * @param options
* @returns * @returns
*/ */
export function ora(options?: Options | string): Ora { export function interactiveOra(options?: Options | string): Ora {
const inputOptions = typeof options === 'string' ? { text: options } : options ?? {}; const inputOptions = typeof options === 'string' ? { text: options } : options ?? {};
const disabled = Log.isDebug || !process.stdin.isTTY; const disabled = Log.isDebug || !process.stdin.isTTY;
const spinner = oraReal({ const spinner = oraReal({
+120 -79
View File
@@ -9,87 +9,128 @@ export interface ExpoChoice<T> extends Choice {
type NamelessQuestion = Omit<Question<'value'>, 'name' | 'type'>; type NamelessQuestion = Omit<Question<'value'>, 'name' | 'type'>;
export async function promptAsync<T extends string = string>( export interface Prompts {
questions: Question<T> | Question<T>[], promptAsync<T extends string = string>(
options: Options = {} questions: Question<T> | Question<T>[],
): Promise<Answers<T>> { options?: Options
if (!process.stdin.isTTY && !global.test) { ): Promise<Answers<T>>;
throw new Error('Input is required, but stdin is not readable.'); confirmAsync(question: NamelessQuestion, options?: Options): Promise<boolean>;
selectAsync<T>(message: string, choices: ExpoChoice<T>[], options?: Options): Promise<T>;
toggleConfirmAsync(questions: NamelessQuestion, options?: Options): Promise<boolean>;
pressAnyKeyToContinueAsync(): Promise<void>;
}
export class NonInteractivePrompts implements Prompts {
private static errorMessage =
'Command can not be run as specified in non-interactive mode. Run the command with --help to see arguments and flags required to run in non-interactive mode.';
async promptAsync<T extends string = string>(
_questions: prompts.PromptObject<T> | prompts.PromptObject<T>[],
_options: prompts.Options
): Promise<prompts.Answers<T>> {
throw new Error(NonInteractivePrompts.errorMessage);
}
async confirmAsync(
_question: NamelessQuestion,
_options?: prompts.Options | undefined
): Promise<boolean> {
throw new Error(NonInteractivePrompts.errorMessage);
}
async selectAsync<T>(
_message: string,
_choices: ExpoChoice<T>[],
_options?: prompts.Options | undefined
): Promise<T> {
throw new Error(NonInteractivePrompts.errorMessage);
}
async toggleConfirmAsync(
_questions: NamelessQuestion,
_options?: prompts.Options | undefined
): Promise<boolean> {
throw new Error(NonInteractivePrompts.errorMessage);
}
async pressAnyKeyToContinueAsync(): Promise<void> {
throw new Error(NonInteractivePrompts.errorMessage);
} }
return await prompts<T>(questions, {
onCancel() {
process.exit(constants.signals.SIGINT + 128); // Exit code 130 used when process is interrupted with ctrl+c.
},
...options,
});
} }
export async function confirmAsync( export class InteractivePrompts implements Prompts {
question: NamelessQuestion, async promptAsync<T extends string = string>(
options?: Options questions: Question<T> | Question<T>[],
): Promise<boolean> { options: Options = {}
const { value } = await promptAsync( ): Promise<Answers<T>> {
{ if (!process.stdin.isTTY && !global.test) {
initial: true, throw new Error('Input is required, but stdin is not readable.');
...question, }
name: 'value', return await prompts<T>(questions, {
type: 'confirm', onCancel() {
}, process.exit(constants.signals.SIGINT + 128); // Exit code 130 used when process is interrupted with ctrl+c.
options },
); ...options,
return value;
}
export async function selectAsync<T>(
message: string,
choices: ExpoChoice<T>[],
options?: Options
): Promise<T> {
const { value } = await promptAsync(
{
message,
choices,
name: 'value',
type: 'select',
},
options
);
return value ?? null;
}
/**
* Create a more dynamic yes/no confirmation that can be cancelled.
*
* @param questions
* @param options
*/
export async function toggleConfirmAsync(
questions: NamelessQuestion,
options?: Options
): Promise<boolean> {
const { value } = await promptAsync(
{
active: 'yes',
inactive: 'no',
...questions,
name: 'value',
type: 'toggle',
},
options
);
return value ?? null;
}
export async function pressAnyKeyToContinueAsync(): Promise<void> {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
await new Promise<void>(res => {
process.stdin.on('data', key => {
if (String(key) === '\u0003') {
process.exit(constants.signals.SIGINT + 128); // ctrl-c
}
res();
}); });
}); }
async confirmAsync(question: NamelessQuestion, options?: Options): Promise<boolean> {
const { value } = await this.promptAsync(
{
initial: true,
...question,
name: 'value',
type: 'confirm',
},
options
);
return value;
}
async selectAsync<T>(message: string, choices: ExpoChoice<T>[], options?: Options): Promise<T> {
const { value } = await this.promptAsync(
{
message,
choices,
name: 'value',
type: 'select',
},
options
);
return value ?? null;
}
/**
* Create a more dynamic yes/no confirmation that can be cancelled.
*
* @param questions
* @param options
*/
async toggleConfirmAsync(questions: NamelessQuestion, options?: Options): Promise<boolean> {
const { value } = await this.promptAsync(
{
active: 'yes',
inactive: 'no',
...questions,
name: 'value',
type: 'toggle',
},
options
);
return value ?? null;
}
async pressAnyKeyToContinueAsync(): Promise<void> {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
await new Promise<void>(res => {
process.stdin.on('data', key => {
if (String(key) === '\u0003') {
process.exit(constants.signals.SIGINT + 128); // ctrl-c
}
res();
});
});
}
} }
-39
View File
@@ -1,39 +0,0 @@
import assert from 'assert';
import Log from '../log';
let stdoutWrite: NodeJS.WriteStream['write'] | undefined;
export function enableJsonOutput(): void {
if (stdoutWrite) {
return;
}
stdoutWrite = process.stdout.write;
process.stdout.write = process.stderr.write.bind(process.stderr);
}
export function printJsonOnlyOutput(value: object): void {
assert(stdoutWrite, 'this should only be called with --json flag');
try {
process.stdout.write = stdoutWrite;
Log.log(JSON.stringify(sanitizeValue(value), null, 2));
} finally {
process.stdout.write = process.stderr.write.bind(process.stderr);
}
}
function sanitizeValue(value: any): unknown {
if (Array.isArray(value)) {
return value.map(val => sanitizeValue(val));
} else if (value && typeof value === 'object') {
const result: Record<string, any> = {};
Object.keys(value).forEach(key => {
if (key !== '__typename' && value[key] !== null) {
result[key] = sanitizeValue(value[key]);
}
});
return result;
} else {
return value;
}
}