xref: /expo/tools/bin/expotools.js (revision d30d453e)
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', 'build']);
62      state.schema = await getCommandsSchemaAsync();
63    } catch (error) {
64      console.error(LogModifiers.error(` �� Rebuilding failed: ${error.stack}`));
65      process.exit(1);
66    }
67    console.log(` ✨ Successfully built ${LogModifiers.name('expotools')}\n`);
68  }
69
70  state.sourceChecksum = sourceChecksum || (await calculateSourceChecksumAsync());
71  state.dependenciesChecksum = dependenciesChecksum || (await calculateDependenciesChecksumAsync());
72
73  saveState(state);
74  run(state.schema);
75}
76
77function buildFolderExists() {
78  try {
79    fs.accessSync(BUILD_PATH, fs.constants.R_OK);
80    return true;
81  } catch (e) {
82    return false;
83  }
84}
85
86async function calculateChecksumAsync(options) {
87  if (canRequire('folder-hash')) {
88    const { hashElement } = require('folder-hash');
89    const { hash } = await hashElement(ROOT_PATH, options);
90    return hash;
91  }
92  return null;
93}
94
95async function calculateDependenciesChecksumAsync() {
96  return calculateChecksumAsync({
97    folders: {
98      exclude: ['*'],
99    },
100    files: {
101      include: ['yarn.lock', 'package.json'],
102    },
103  });
104}
105
106async function calculateSourceChecksumAsync() {
107  return calculateChecksumAsync({
108    folders: {
109      exclude: ['build', 'cache', 'node_modules'],
110    },
111    files: {
112      include: [
113        // source files
114        '**.ts',
115        // src/versioning files
116        '**.json',
117        'expotools.js',
118        // swc build files
119        'taskfile.js',
120        'taskfile-swc.js',
121        // type checking
122        'tsconfig.json',
123      ],
124    },
125  });
126}
127
128function loadCommand(program, commandFile) {
129  const commandModule = require(commandFile);
130
131  if (typeof commandModule.default !== 'function') {
132    console.error(
133      `Command file "${commandFile}" is not valid. Make sure to export command function as a default.`
134    );
135    return;
136  }
137  commandModule.default(program);
138}
139
140async function loadAllCommandsAsync(callback) {
141  const program = require('@expo/commander');
142  const glob = require('glob-promise');
143
144  const commandFiles = await glob('build/commands/*.js', {
145    cwd: ROOT_PATH,
146    absolute: true,
147  });
148
149  for (const commandFile of commandFiles) {
150    loadCommand(program, commandFile);
151
152    if (callback) {
153      callback(commandFile, program);
154    }
155  }
156}
157
158async function getCommandsSchemaAsync() {
159  const schema = {};
160
161  await loadAllCommandsAsync((commandFile, program) => {
162    for (const command of program.commands) {
163      const names = [command._name];
164
165      if (command._aliases) {
166        names.push(...command._aliases);
167      }
168      for (const name of names) {
169        if (!schema[name]) {
170          schema[name] = path.relative(BUILD_PATH, commandFile);
171        }
172      }
173    }
174  });
175  return schema;
176}
177
178function readState() {
179  if (canRequire(STATE_PATH)) {
180    return require(STATE_PATH);
181  }
182  return {
183    sourceChecksum: null,
184    dependenciesChecksum: null,
185    schema: null,
186  };
187}
188
189function saveState(state) {
190  fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
191  fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
192}
193
194function spawnAsync(command, args, options) {
195  return new Promise((resolve, reject) => {
196    const child = child_process.spawn(
197      command,
198      args,
199      options || {
200        stdio: ['pipe', 'ignore', 'pipe'],
201        ignoreStdio: true,
202        cwd: ROOT_PATH,
203      }
204    );
205
206    child.on('exit', (code) => {
207      child.removeAllListeners();
208      resolve({ code });
209    });
210    child.on('error', (error) => {
211      child.removeAllListeners();
212      reject(error);
213    });
214  });
215}
216
217function canRequire(packageName) {
218  try {
219    require.resolve(packageName);
220    return true;
221  } catch {
222    return false;
223  }
224}
225
226async function run(schema) {
227  const semver = require('semver');
228  const program = require('@expo/commander');
229  const nodeVersion = process.versions.node.split('-')[0]; // explode and truncate tag from version
230
231  // Validate that used Node version is supported
232  if (semver.satisfies(nodeVersion, '<8.9.0')) {
233    console.log(
234      LogModifiers.error(
235        `Node version ${LogModifiers.name(
236          nodeVersion
237        )} is not supported. Please use Node.js ${LogModifiers.name('8.9.0')} or higher.`
238      )
239    );
240    process.exit(1);
241  }
242
243  try {
244    const subCommandName = process.argv[2];
245
246    if (subCommandName && !subCommandName.startsWith('-')) {
247      if (!schema[subCommandName]) {
248        console.log(
249          LogModifiers.error(
250            `${LogModifiers.command(subCommandName)} is not an expotools command.`
251          ),
252          LogModifiers.error(
253            `Run ${LogModifiers.command('et --help')} to see a list of available commands.\n`
254          )
255        );
256        process.exit(1);
257        return;
258      }
259
260      const subCommand =
261        subCommandName &&
262        program.commands.find(({ _name, _aliases }) => {
263          return _name === subCommandName || (_aliases && _aliases.includes(subCommandName));
264        });
265
266      if (!subCommand) {
267        // If the command is known and defined in schema, load just this one command and run it.
268        const commandFilePath = path.join(BUILD_PATH, schema[subCommandName]);
269        loadCommand(program, commandFilePath);
270      }
271
272      // Pass args to commander and run the command.
273      program.parse(process.argv);
274    } else {
275      await loadAllCommandsAsync();
276      program.help();
277    }
278  } catch (e) {
279    console.error(LogModifiers.error(e));
280    throw e;
281  }
282}
283