xref: /expo/tools/bin/expotools.js (revision 7d5f3528)
1'use strict';
2/* eslint-env node */
3
4// This script is just a wrapper around expotools that ensures node modules are installed
5// and TypeScript files are compiled. To make it work even when node_modules are empty,
6// we shouldn't eagerly require any dependency - we have to run yarn first.
7
8const child_process = require('child_process');
9const fs = require('fs');
10const path = require('path');
11
12const ROOT_PATH = path.dirname(__dirname);
13const BUILD_PATH = path.join(ROOT_PATH, 'build');
14const STATE_PATH = path.join(ROOT_PATH, 'cache', '.state.json');
15
16function createLogModifier(modifier) {
17  return (text) => {
18    try {
19      return modifier(require('chalk'))(text);
20    } catch (e) {
21      return text;
22    }
23  };
24}
25/**
26 * Importing chalk directly may lead to errors
27 * if it's not yet available on the machine.
28 *
29 * Intermediary log modifiers catch the error
30 * and return unmodified string passed into the logger.
31 *
32 * See https://github.com/expo/expo/issues/9547
33 */
34const LogModifiers = {
35  error: createLogModifier((chalk) => chalk.red),
36  name: createLogModifier((chalk) => chalk.cyan),
37  command: createLogModifier((chalk) => chalk.cyan.italic),
38};
39
40maybeRebuildAndRun().catch((error) => {
41  console.error(LogModifiers.error(error.stack));
42});
43
44async function maybeRebuildAndRun() {
45  const state = readState();
46  const dependenciesChecksum = await calculateDependenciesChecksumAsync();
47  const sourceChecksum = await calculateSourceChecksumAsync();
48
49  // If `yarn.lock` checksum changed, reinstall expotools dependencies.
50  if (!state.dependenciesChecksum || state.dependenciesChecksum !== dependenciesChecksum) {
51    console.log(' �� Yarning...');
52    await spawnAsync('yarn', ['install']);
53  }
54
55  // If checksum of source files changed, rebuild TypeScript files.
56  if (!state.sourceChecksum || state.sourceChecksum !== sourceChecksum || !buildFolderExists()) {
57    console.log(` ��  Rebuilding ${LogModifiers.name('expotools')}`);
58
59    try {
60      // Compile TypeScript files into build folder.
61      await spawnAsync('yarn', ['run', 'tsc']);
62      state.schema = await getCommandsSchemaAsync();
63    } catch (error) {
64      console.error(LogModifiers.error(` �� Rebuilding failed: ${error.stack}`));
65      process.exit(1);
66      return;
67    }
68    console.log(` ✨ Successfully built ${LogModifiers.name('expotools')}\n`);
69  }
70
71  state.sourceChecksum = sourceChecksum || (await calculateSourceChecksumAsync());
72  state.dependenciesChecksum = dependenciesChecksum || (await calculateDependenciesChecksumAsync());
73
74  saveState(state);
75  run(state.schema);
76}
77
78function buildFolderExists() {
79  try {
80    fs.accessSync(BUILD_PATH, fs.constants.R_OK);
81    return true;
82  } catch (e) {
83    return false;
84  }
85}
86
87async function calculateChecksumAsync(options) {
88  if (canRequire('folder-hash')) {
89    const { hashElement } = require('folder-hash');
90    const { hash } = await hashElement(ROOT_PATH, options);
91    return hash;
92  }
93  return null;
94}
95
96async function calculateDependenciesChecksumAsync() {
97  return calculateChecksumAsync({
98    folders: {
99      exclude: ['*'],
100    },
101    files: {
102      include: ['yarn.lock', 'package.json'],
103    },
104  });
105}
106
107async function calculateSourceChecksumAsync() {
108  return calculateChecksumAsync({
109    folders: {
110      exclude: ['build', 'cache', 'node_modules'],
111    },
112    files: {
113      include: ['*.ts', 'expotools.js', 'tsconfig.json'],
114    },
115  });
116}
117
118function loadCommand(program, commandFile) {
119  const commandModule = require(commandFile);
120
121  if (typeof commandModule.default !== 'function') {
122    console.error(
123      `Command file "${commandFile}" is not valid. Make sure to export command function as a default.`
124    );
125    return;
126  }
127  commandModule.default(program);
128}
129
130async function loadAllCommandsAsync(callback) {
131  const program = require('@expo/commander');
132  const glob = require('glob-promise');
133
134  const commandFiles = await glob('build/commands/*.js', {
135    cwd: ROOT_PATH,
136    absolute: true,
137  });
138
139  for (const commandFile of commandFiles) {
140    loadCommand(program, commandFile);
141
142    if (callback) {
143      callback(commandFile, program);
144    }
145  }
146}
147
148async function getCommandsSchemaAsync() {
149  const schema = {};
150
151  await loadAllCommandsAsync((commandFile, program) => {
152    for (const command of program.commands) {
153      const names = [command._name];
154
155      if (command._aliases) {
156        names.push(...command._aliases);
157      }
158      for (const name of names) {
159        if (!schema[name]) {
160          schema[name] = path.relative(BUILD_PATH, commandFile);
161        }
162      }
163    }
164  });
165  return schema;
166}
167
168function readState() {
169  if (canRequire(STATE_PATH)) {
170    return require(STATE_PATH);
171  }
172  return {
173    sourceChecksum: null,
174    dependenciesChecksum: null,
175    schema: null,
176  };
177}
178
179function saveState(state) {
180  fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
181}
182
183function spawnAsync(command, args, options) {
184  return new Promise((resolve, reject) => {
185    const child = child_process.spawn(
186      command,
187      args,
188      options || {
189        stdio: ['pipe', 'ignore', 'pipe'],
190        ignoreStdio: true,
191        cwd: ROOT_PATH,
192      }
193    );
194
195    child.on('exit', (code) => {
196      child.removeAllListeners();
197      resolve({ code });
198    });
199    child.on('error', (error) => {
200      child.removeAllListeners();
201      reject(error);
202    });
203  });
204}
205
206function canRequire(packageName) {
207  try {
208    require.resolve(packageName);
209    return true;
210  } catch (error) {
211    return false;
212  }
213}
214
215async function run(schema) {
216  const semver = require('semver');
217  const program = require('@expo/commander');
218  const nodeVersion = process.versions.node.split('-')[0]; // explode and truncate tag from version
219
220  // Validate that used Node version is supported
221  if (semver.satisfies(nodeVersion, '<8.9.0')) {
222    console.log(
223      LogModifiers.error(
224        `Node version ${LogModifiers.name(
225          nodeVersion
226        )} is not supported. Please use Node.js ${LogModifiers.name('8.9.0')} or higher.`
227      )
228    );
229    process.exit(1);
230  }
231
232  try {
233    const subCommandName = process.argv[2];
234
235    if (subCommandName && !subCommandName.startsWith('-')) {
236      if (!schema[subCommandName]) {
237        console.log(
238          LogModifiers.error(
239            `${LogModifiers.command(subCommandName)} is not an expotools command.`
240          ),
241          LogModifiers.error(
242            `Run ${LogModifiers.command('et --help')} to see a list of available commands.\n`
243          )
244        );
245        process.exit(1);
246        return;
247      }
248
249      const subCommand =
250        subCommandName &&
251        program.commands.find(({ _name, _aliases }) => {
252          return _name === subCommandName || (_aliases && _aliases.includes(subCommandName));
253        });
254
255      if (!subCommand) {
256        // If the command is known and defined in schema, load just this one command and run it.
257        const commandFilePath = path.join(BUILD_PATH, schema[subCommandName]);
258        loadCommand(program, commandFilePath);
259      }
260
261      // Pass args to commander and run the command.
262      program.parse(process.argv);
263    } else {
264      await loadAllCommandsAsync();
265      program.help();
266    }
267  } catch (e) {
268    console.error(LogModifiers.error(e));
269    throw e;
270  }
271}
272