xref: /expo/tools/src/Utils.ts (revision 5dab530b)
1import basicSpawnAsync, { SpawnResult, SpawnOptions, SpawnPromise } from '@expo/spawn-async';
2import chalk from 'chalk';
3import { IOptions as GlobOptions } from 'glob';
4import glob from 'glob-promise';
5import ora from 'ora';
6
7import { EXPO_DIR } from './Constants';
8
9export { SpawnResult, SpawnOptions };
10
11/**
12 * Asynchronously spawns a process with given command, args and options. Working directory is set to repo's root by default.
13 */
14export function spawnAsync(
15  command: string,
16  args: Readonly<string[]> = [],
17  options: SpawnOptions = {}
18): SpawnPromise<SpawnResult> {
19  return basicSpawnAsync(command, args, {
20    env: { ...process.env },
21    cwd: EXPO_DIR,
22    ...options,
23  });
24}
25
26/**
27 * Does the same as `spawnAsync` but parses the output to JSON object.
28 */
29export async function spawnJSONCommandAsync<T = object>(
30  command: string,
31  args: Readonly<string[]> = [],
32  options: SpawnOptions = {}
33): Promise<T> {
34  const child = await spawnAsync(command, args, options);
35  try {
36    return JSON.parse(child.stdout);
37  } catch (e) {
38    e.message +=
39      '\n' + chalk.red('Cannot parse this output as JSON: ') + chalk.yellow(child.stdout.trim());
40    throw e;
41  }
42}
43
44/**
45 * Deeply clones an object. It's used to make a backup of home's `app.json` file.
46 */
47export function deepCloneObject<ObjectType extends object = object>(
48  object: ObjectType
49): ObjectType {
50  return JSON.parse(JSON.stringify(object));
51}
52
53/**
54 * Waits given amount of time (in milliseconds).
55 */
56export function sleepAsync(duration: number): Promise<void> {
57  return new Promise((resolve) => {
58    setTimeout(resolve, duration);
59  });
60}
61
62/**
63 * Filters an array asynchronously.
64 */
65export async function filterAsync<T = any>(
66  arr: T[],
67  filter: (item: T, index: number) => boolean | Promise<boolean>
68): Promise<T[]> {
69  const results = await Promise.all(arr.map(filter));
70  return arr.filter((item, index) => results[index]);
71}
72
73/**
74 * Retries executing the function with given interval and with given retry limit.
75 * It resolves immediately once the callback returns anything else than `undefined`.
76 */
77export async function retryAsync<T = any>(
78  interval: number,
79  limit: number,
80  callback: () => T | Promise<T>
81): Promise<T | undefined> {
82  return new Promise((resolve) => {
83    let count = 0;
84
85    const timeoutCallback = async () => {
86      const result = await callback();
87
88      if (result !== undefined) {
89        resolve(result);
90        return;
91      }
92      if (++count < limit) {
93        setTimeout(timeoutCallback, interval);
94      } else {
95        resolve(undefined);
96      }
97    };
98    timeoutCallback();
99  });
100}
101
102/**
103 * Executes regular expression against a string until the last match is found.
104 */
105export function execAll(rgx: RegExp, str: string, index: number = 0): string[] {
106  const globalRgx = new RegExp(rgx.source, 'g' + rgx.flags.replace('g', ''));
107  const matches: string[] = [];
108  let match;
109  while ((match = globalRgx.exec(str))) {
110    matches.push(match[index]);
111  }
112  return matches;
113}
114
115/**
116 * Searches for files matching given glob patterns.
117 */
118export async function searchFilesAsync(
119  rootPath: string,
120  patterns: string | string[],
121  options?: GlobOptions
122): Promise<Set<string>> {
123  const files = await Promise.all(
124    arrayize(patterns).map((pattern) =>
125      glob(pattern, {
126        cwd: rootPath,
127        nodir: true,
128        ...options,
129      })
130    )
131  );
132  return new Set(([] as string[]).concat(...files));
133}
134
135/**
136 * Ensures the value is an array.
137 */
138export function arrayize<T>(value: T | T[]): T[] {
139  if (Array.isArray(value)) {
140    return value;
141  }
142  return value != null ? [value] : [];
143}
144
145/**
146 * Execute `patch` command for given patch content
147 */
148export async function applyPatchAsync(options: {
149  patchContent: string;
150  cwd: string;
151  reverse?: boolean;
152  stripPrefixNum?: number;
153}) {
154  const args: string[] = [];
155  if (options.stripPrefixNum != null) {
156    // -pN passing to the `patch` command for striping slashed prefixes
157    args.push(`-p${options.stripPrefixNum}`);
158  }
159  if (options.reverse) {
160    args.push('-R');
161  }
162
163  const procPromise = spawnAsync('patch', args, {
164    cwd: options.cwd,
165  });
166  procPromise.child.stdin?.write(options.patchContent);
167  procPromise.child.stdin?.end();
168  await procPromise;
169}
170
171export async function runWithSpinner<Result>(
172  title: string,
173  action: (step: ora.Ora) => Promise<Result> | Result,
174  succeedText: string | null = null,
175  options: ora.Options = {}
176): Promise<Result> {
177  const disabled = process.env.CI || process.env.EXPO_DEBUG;
178  const step = ora({
179    text: chalk.bold(title),
180    isEnabled: !disabled,
181    stream: disabled ? process.stdout : process.stderr,
182    ...options,
183  });
184
185  step.start();
186
187  try {
188    const result = await action(step);
189
190    if (succeedText) {
191      step.succeed(succeedText);
192    }
193    return result;
194  } catch (error) {
195    step.fail();
196    console.error(error);
197    process.exit(1);
198  }
199}
200