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