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 * as PackageManagerUtils from '@expo/package-manager';
import { Command } from '@oclif/core';
import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
// eslint-disable-next-line no-restricted-imports
import { Options, Ora } from 'ora';
import path from 'path';
import semver from 'semver';
@@ -12,15 +14,48 @@ import {
initAsync as initAnalyticsAsync,
logEvent,
} from '../analytics/rudderstackClient';
import { learnMore } from '../log';
import Log, { learnMore } from '../log';
import { interactiveOra, nonInteractiveOra } from '../ora';
import { findProjectRootAsync } from '../project/projectUtils';
import { InteractivePrompts, NonInteractivePrompts, Prompts } from '../prompts';
import { getUserAsync } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { setVcsClient } from '../vcs';
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 {
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
* force the user to log in
@@ -28,10 +63,26 @@ export default abstract class EasCommand extends Command {
protected requiresAuthentication = 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
async run(): Promise<any> {
const { flags } = await this.parse();
const json = flags.json ?? false;
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await initAnalyticsAsync();
if (this.mustBeRunInsideProject) {
@@ -41,8 +92,6 @@ export default abstract class EasCommand extends Command {
}
if (this.requiresAuthentication) {
const { flags } = await this.parse();
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await ensureLoggedInAsync({ nonInteractive });
} else {
await getUserAsync();
@@ -52,7 +101,18 @@ export default abstract class EasCommand extends Command {
// commands/submit === submit, commands/build/list === build:list
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
@@ -119,4 +179,26 @@ export default abstract class EasCommand extends Command {
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';
export const getPaginatedQueryOptions = (
flags: Record<keyof typeof EasPaginatedQueryFlags, any>
): PaginatedQueryOptions => {
export const getPaginatedQueryOptions = (flags: {
json: boolean;
'non-interactive': boolean;
offset: number | undefined;
limit: number | undefined;
}): PaginatedQueryOptions => {
return {
json: flags.json ?? false,
json: flags.json,
offset: flags.offset ?? 0,
nonInteractive: flags['non-interactive'] ?? false,
nonInteractive: flags['non-interactive'],
...('limit' in flags && { limit: flags.limit }),
};
};
@@ -40,12 +43,6 @@ export const EasPaginatedQueryFlags = {
// eslint-disable-next-line async-protect/async-suffix
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
+17 -29
View File
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { CommandContext } from '../../commandUtils/EasCommand';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
@@ -11,15 +10,12 @@ import {
GetBranchInfoQuery,
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function getBranchInfoAsync({
appId,
@@ -86,21 +82,15 @@ export default class BranchDelete extends EasCommand {
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 {
args: { name },
flags: { json: jsonFlag },
} = await this.parse(BranchDelete);
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
@@ -109,7 +99,7 @@ export default class BranchDelete extends EasCommand {
if (!name) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
@@ -126,16 +116,16 @@ export default class BranchDelete extends EasCommand {
throw new Error(`Could not find branch ${name} on ${fullName}`);
}
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.warn(
if (!nonInteractive) {
logger.addNewLineIfNone();
logger.warn(
`You are about to permanently delete branch: "${name}" and all of the updates published on it.` +
`\nThis action is irreversible.`
);
Log.newLine();
logger.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Cancelled deletion of branch: "${name}".`);
logger.error(`Cancelled deletion of branch: "${name}".`);
process.exit(1);
}
}
@@ -144,12 +134,10 @@ export default class BranchDelete extends EasCommand {
branchId,
});
if (jsonFlag) {
printJsonOnlyOutput(deletionResult);
} else {
Log.withTick(
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(fullName)}.`
);
}
logger.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 assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { CommandContext } from '../../commandUtils/EasCommand';
import { chooseDevicesToDeleteAsync } from '../../credentials/ios/actions/DeviceUtils';
import { AppleDeviceMutation } from '../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import {
@@ -14,11 +14,8 @@ import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleT
import { authenticateAsync, getRequestContext } from '../../credentials/ios/appstore/authenticate';
import formatDevice from '../../devices/utils/formatDevice';
import { AppleDevice, Maybe } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
export default class DeviceDelete extends EasCommand {
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 }),
};
async runAsync(): Promise<void> {
protected async runAsync(commandContext: CommandContext): Promise<{ jsonOutput: object }> {
let {
flags: { 'apple-team-id': appleTeamIdentifier, udid: udids },
} = await this.parse(DeviceDelete);
@@ -38,46 +35,61 @@ export default class DeviceDelete extends EasCommand {
const accountName = await getProjectAccountNameAsync(exp);
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');
const appleDevicesResult = await this.getDevicesForTeamAsync(accountName, appleTeamIdentifier);
const appleDevicesResult = await this.getDevicesForTeamAsync(
commandContext,
accountName,
appleTeamIdentifier
);
if (!appleDevicesResult) {
return;
return { jsonOutput: [] };
}
const { appleTeamName, appleDevices } = appleDevicesResult;
const chosenDevices = await this.chooseDevicesToDeleteAsync(appleDevices, udids);
const chosenDevices = await this.chooseDevicesToDeleteAsync(
commandContext,
appleDevices,
udids
);
if (chosenDevices.length === 0) {
Log.newLine();
Log.warn('No devices were chosen to be removed.');
return;
commandContext.logger.newLine();
commandContext.logger.warn('No devices were chosen to be removed.');
return { jsonOutput: [] };
}
this.logChosenDevices(chosenDevices, appleTeamName, appleTeamIdentifier);
const hasRemoved = await this.askAndRemoveFromExpoAsync(chosenDevices);
this.logChosenDevices(commandContext, chosenDevices, appleTeamName, appleTeamIdentifier);
const hasRemoved = await this.askAndRemoveFromExpoAsync(commandContext, chosenDevices);
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(
{ nonInteractive, logger, prompts: { toggleConfirmAsync }, ora }: CommandContext,
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[],
appleTeamIdentifier: string
): Promise<void> {
Log.newLine();
const deleteOnApple = await toggleConfirmAsync({
message: 'Do you want to disable these devices on your Apple account as well?',
});
logger.newLine();
const deleteOnApple = nonInteractive
? true
: await toggleConfirmAsync({
message: 'Do you want to disable these devices on your Apple account as well?',
});
if (!deleteOnApple) {
return;
@@ -86,7 +98,7 @@ export default class DeviceDelete extends EasCommand {
const ctx = await authenticateAsync({ teamId: appleTeamIdentifier });
const context = getRequestContext(ctx);
Log.addNewLineIfNone();
logger.addNewLineIfNone();
const removeAppleSpinner = ora('Disabling devices on Apple').start();
try {
const chosenDeviceIdentifiers = chosenDevices.map(cd => cd.identifier);
@@ -107,18 +119,21 @@ export default class DeviceDelete extends EasCommand {
}
async askAndRemoveFromExpoAsync(
{ nonInteractive, logger, prompts: { toggleConfirmAsync }, ora }: CommandContext,
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[]
): Promise<boolean> {
Log.warn(
logger.warn(
`You are about to remove the Apple device${
chosenDevices.length > 1 ? 's' : ''
} listed above from your Expo account.`
);
Log.newLine();
logger.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
const confirmed = nonInteractive
? true
: await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (confirmed) {
const removalSpinner = ora(`Removing Apple devices on Expo`).start();
@@ -137,49 +152,55 @@ export default class DeviceDelete extends EasCommand {
}
logChosenDevices(
{ logger }: CommandContext,
chosenDevices: (AppleDevice | AppleDeviceQueryResult)[],
appleTeamName: Maybe<string> | undefined,
appleTeamIdentifier: string
): void {
Log.addNewLineIfNone();
logger.addNewLineIfNone();
chosenDevices.forEach(device => {
Log.log(
logger.log(
formatDevice(device, {
appleTeamName,
appleTeamIdentifier: appleTeamIdentifier!,
})
);
Log.newLine();
logger.newLine();
});
}
async chooseDevicesToDeleteAsync(
{ nonInteractive, logger }: CommandContext,
appleDevices: AppleDeviceQueryResult[],
udids: string[]
): Promise<(AppleDevice | AppleDeviceQueryResult)[]> {
let chosenDevices: (AppleDeviceQueryResult | AppleDevice)[] = [];
Log.newLine();
logger.newLine();
if (udids) {
udids.forEach(udid => {
const foundDevice = appleDevices.find(device => device.identifier === udid);
if (foundDevice) {
chosenDevices.push(foundDevice);
} else {
Log.warn(`No device found with UDID ${udid}.`);
logger.warn(`No device found with UDID ${udid}.`);
}
});
}
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);
Log.newLine();
logger.newLine();
}
return chosenDevices;
}
async getDevicesForTeamAsync(
{ ora }: CommandContext,
accountName: string,
appleTeamIdentifier: string
): 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…');
try {
+38 -31
View File
@@ -9,70 +9,77 @@ type Color = (...text: string[]) => string;
export default class Log {
public static readonly isDebug = boolish('EXPO_DEBUG', false);
public static log(...args: any[]): void {
Log.consoleLog(...args);
constructor(private readonly jsonOutputMode: boolean) {}
public log(...args: any[]): void {
this.consoleLog(...args);
}
public static newLine(): void {
Log.consoleLog();
public newLine(): void {
this.consoleLog();
}
public static addNewLineIfNone(): void {
if (!Log.isLastLineNewLine) {
Log.newLine();
public addNewLineIfNone(): void {
if (!this.isLastLineNewLine) {
this.newLine();
}
}
public static error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
public error(...args: any[]): void {
this.consoleLog(...this.withTextColor(args, chalk.red));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
public warn(...args: any[]): void {
this.consoleLog(...this.withTextColor(args, chalk.yellow));
}
public static debug(...args: any[]): void {
public debug(...args: any[]): void {
if (Log.isDebug) {
Log.consoleLog(...args);
this.consoleLog(...args);
}
}
public static gray(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.gray));
public gray(...args: any[]): void {
this.consoleLog(...this.withTextColor(args, chalk.gray));
}
public static warnDeprecatedFlag(flag: string, message: string): void {
Log.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
public warnDeprecatedFlag(flag: string, message: string): void {
this.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
}
public static succeed(message: string): void {
Log.log(`${chalk.green(logSymbols.success)} ${message}`);
public succeed(message: string): void {
this.log(`${chalk.green(logSymbols.success)} ${message}`);
}
public static withTick(...args: any[]): void {
Log.consoleLog(chalk.green(figures.tick), ...args);
public withTick(...args: any[]): void {
this.consoleLog(chalk.green(figures.tick), ...args);
}
private static consoleLog(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.log(...args);
private consoleLog(...args: any[]): void {
this.updateIsLastLineNewLine(args);
if (this.jsonOutputMode) {
// 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));
}
private static isLastLineNewLine = false;
private static updateIsLastLineNewLine(args: any[]): void {
private isLastLineNewLine = false;
private updateIsLastLineNewLine(args: any[]): void {
if (args.length === 0) {
Log.isLastLineNewLine = true;
this.isLastLineNewLine = true;
} else {
const lastArg = args[args.length - 1];
if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) {
Log.isLastLineNewLine = true;
this.isLastLineNewLine = true;
} 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';
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 };
// eslint-disable-next-line no-console
@@ -20,7 +42,7 @@ const errorReal = console.error;
* @param options
* @returns
*/
export function ora(options?: Options | string): Ora {
export function interactiveOra(options?: Options | string): Ora {
const inputOptions = typeof options === 'string' ? { text: options } : options ?? {};
const disabled = Log.isDebug || !process.stdin.isTTY;
const spinner = oraReal({
+120 -79
View File
@@ -9,87 +9,128 @@ export interface ExpoChoice<T> extends Choice {
type NamelessQuestion = Omit<Question<'value'>, 'name' | 'type'>;
export async function promptAsync<T extends string = string>(
questions: Question<T> | Question<T>[],
options: Options = {}
): Promise<Answers<T>> {
if (!process.stdin.isTTY && !global.test) {
throw new Error('Input is required, but stdin is not readable.');
export interface Prompts {
promptAsync<T extends string = string>(
questions: Question<T> | Question<T>[],
options?: Options
): Promise<Answers<T>>;
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(
question: NamelessQuestion,
options?: Options
): Promise<boolean> {
const { value } = await promptAsync(
{
initial: true,
...question,
name: 'value',
type: 'confirm',
},
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();
export class InteractivePrompts implements Prompts {
async promptAsync<T extends string = string>(
questions: Question<T> | Question<T>[],
options: Options = {}
): Promise<Answers<T>> {
if (!process.stdin.isTTY && !global.test) {
throw new Error('Input is required, but stdin is not readable.');
}
return await prompts<T>(questions, {
onCancel() {
process.exit(constants.signals.SIGINT + 128); // Exit code 130 used when process is interrupted with ctrl+c.
},
...options,
});
});
}
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;
}
}