xref: /expo/packages/@expo/cli/src/utils/prompts.ts (revision e5f02478)
1import assert from 'assert';
2import prompts, { Choice, Options, PromptObject, PromptType } from 'prompts';
3
4import * as Log from '../log';
5import { CI } from './env';
6import { AbortCommandError, CommandError } from './errors';
7
8export type Question<V extends string = string> = PromptObject<V> & {
9  optionsPerPage?: number;
10};
11
12export interface ExpoChoice<T> extends Choice {
13  value: T;
14}
15
16export { PromptType };
17
18type PromptOptions = { nonInteractiveHelp?: string } & Options;
19
20export type NamelessQuestion = Omit<Question<'value'>, 'name' | 'type'>;
21
22type InteractionOptions = { pause: boolean; canEscape?: boolean };
23
24type InteractionCallback = (options: InteractionOptions) => void;
25
26/** Interaction observers for detecting when keystroke tracking should pause/resume. */
27const listeners: InteractionCallback[] = [];
28
29export default async function prompt(
30  questions: Question | Question[],
31  { nonInteractiveHelp, ...options }: PromptOptions = {}
32) {
33  questions = Array.isArray(questions) ? questions : [questions];
34  if (CI && questions.length !== 0) {
35    let message = `Input is required, but 'npx expo' is in non-interactive mode.\n`;
36    if (nonInteractiveHelp) {
37      message += nonInteractiveHelp;
38    } else {
39      const question = questions[0];
40      const questionMessage =
41        typeof question.message === 'function'
42          ? question.message(undefined, {}, question)
43          : question.message;
44
45      message += `Required input:\n${(questionMessage || '').trim().replace(/^/gm, '> ')}`;
46    }
47    throw new CommandError('NON_INTERACTIVE', message);
48  }
49
50  pauseInteractions();
51  try {
52    const results = await prompts(questions, {
53      onCancel() {
54        throw new AbortCommandError();
55      },
56      ...options,
57    });
58
59    return results;
60  } finally {
61    resumeInteractions();
62  }
63}
64
65/**
66 * Create a standard yes/no confirmation that can be cancelled.
67 *
68 * @param questions
69 * @param options
70 */
71export async function confirmAsync(
72  questions: NamelessQuestion,
73  options?: PromptOptions
74): Promise<boolean> {
75  const { value } = await prompt(
76    {
77      initial: true,
78      ...questions,
79      name: 'value',
80      type: 'confirm',
81    },
82    options
83  );
84  return value ?? null;
85}
86
87/** Select an option from a list of options. */
88export async function selectAsync<T>(
89  message: string,
90  choices: ExpoChoice<T>[],
91  options?: PromptOptions
92): Promise<T> {
93  const { value } = await prompt(
94    {
95      message,
96      choices,
97      name: 'value',
98      type: 'select',
99    },
100    options
101  );
102  return value ?? null;
103}
104
105export const promptAsync = prompt;
106
107/** Used to pause/resume interaction observers while prompting (made for TerminalUI). */
108export function addInteractionListener(callback: InteractionCallback) {
109  listeners.push(callback);
110}
111
112export function removeInteractionListener(callback: InteractionCallback) {
113  const listenerIndex = listeners.findIndex((_callback) => _callback === callback);
114  assert(
115    listenerIndex >= 0,
116    'removeInteractionListener(): cannot remove an unregistered event listener.'
117  );
118  listeners.splice(listenerIndex, 1);
119}
120
121/** Notify all listeners that keypress observations must pause. */
122export function pauseInteractions(options: Omit<InteractionOptions, 'pause'> = {}) {
123  Log.debug('Interaction observers paused');
124  for (const listener of listeners) {
125    listener({ pause: true, ...options });
126  }
127}
128
129/** Notify all listeners that keypress observations can start.. */
130export function resumeInteractions(options: Omit<InteractionOptions, 'pause'> = {}) {
131  Log.debug('Interaction observers resumed');
132  for (const listener of listeners) {
133    listener({ pause: false, ...options });
134  }
135}
136