react-native/react-native-git-upgrade/cliEntry.js

459 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
'use strict';
const fs = require('fs');
const os = require('os');
const assert = require('assert');
const path = require('path');
const shell = require('shelljs');
const Promise = require('promise');
const yeoman = require('yeoman-environment');
const TerminalAdapter = require('yeoman-environment/lib/adapter');
const log = require('npmlog');
const rimraf = require('rimraf');
const semver = require('semver');
const yarn = require('./yarn');
const {
checkDeclaredVersion,
checkMatchingVersions,
checkReactPeerDependency,
checkGitAvailable,
} = require('./checks');
log.heading = 'git-upgrade';
/**
* Promisify the callback-based shelljs function exec
* @param logOutput If true, log the stdout of the command.
* @param logger Custom logger to modify the output, invoked with the data and the stream.
* @returns {Promise}
*/
function exec(command, logOutput, logger = null) {
return new Promise((resolve, reject) => {
let stderr,
stdout = '';
const child = shell.exec(command, {async: true, silent: true});
child.stdout.on('data', data => {
stdout += data;
if (logOutput) {
if (logger) {
logger(data, process.stdout);
} else {
process.stdout.write(data);
}
}
});
child.stderr.on('data', data => {
stderr += data;
if (logger) {
logger(data, process.stderr);
} else {
process.stderr.write(data);
}
});
child.on('exit', (code, signal) => {
if (code === 0) {
resolve(stdout);
} else if (code) {
reject(
new Error(`Command '${command}' exited with code ${code}:
stderr: ${stderr}
stdout: ${stdout}`),
);
} else {
reject(
new Error(`Command '${command}' terminated with signal '${signal}':
stderr: ${stderr}
stdout: ${stdout}`),
);
}
});
});
}
function parseJsonFile(path, useYarn) {
const installHint = useYarn
? 'Make sure you ran "yarn" and that you are inside a React Native project.'
: 'Make sure you ran "npm install" and that you are inside a React Native project.';
let fileContents;
try {
fileContents = fs.readFileSync(path, 'utf8');
} catch (err) {
throw new Error('Cannot find "' + path + '". ' + installHint);
}
try {
return JSON.parse(fileContents);
} catch (err) {
throw new Error('Cannot parse "' + path + '": ' + err.message);
}
}
function readPackageFiles(useYarn) {
const reactNativeNodeModulesPakPath = path.resolve(
process.cwd(),
'node_modules',
'react-native',
'package.json',
);
const reactNodeModulesPakPath = path.resolve(
process.cwd(),
'node_modules',
'react',
'package.json',
);
const pakPath = path.resolve(process.cwd(), 'package.json');
return {
reactNativeNodeModulesPak: parseJsonFile(reactNativeNodeModulesPakPath),
reactNodeModulesPak: parseJsonFile(reactNodeModulesPakPath),
pak: parseJsonFile(pakPath),
};
}
function parseInformationJsonOutput(jsonOutput, requestedVersion) {
try {
const output = JSON.parse(jsonOutput);
const newVersion = output.version;
const peerDependencies = output.peerDependencies;
const newReactVersionRange = peerDependencies.react;
assert(semver.valid(newVersion));
return {newVersion, newReactVersionRange};
} catch (err) {
throw new Error(
'The specified version of React Native ' +
requestedVersion +
" doesn't exist.\n" +
'Re-run the react-native-git-upgrade command with an existing version,\n' +
'for example: "react-native-git-upgrade 0.38.0",\n' +
'or without arguments to upgrade to the latest: "react-native-git-upgrade".',
);
}
}
function setupWorkingDir(tmpDir) {
return new Promise((resolve, reject) => {
rimraf(tmpDir, err => {
if (err) {
reject(err);
} else {
fs.mkdirSync(tmpDir);
resolve();
}
});
});
}
function configureGitEnv(tmpDir) {
/*
* The workflow inits a temporary Git repository. We don't want to interfere
* with an existing user's Git repository.
* Thanks to Git env vars, we can make Git use a different directory for its ".git" folder.
* See https://git-scm.com/book/tr/v2/Git-Internals-Environment-Variables
*/
process.env.GIT_DIR = path.resolve(tmpDir, '.gitrn');
process.env.GIT_WORK_TREE = '.';
}
function copyCurrentGitIgnoreFile(tmpDir) {
/*
* The user may have added new files or directories in the .gitignore file.
* We need to keep those files ignored during the process, otherwise they
* will be deleted.
* See https://github.com/facebook/react-native/issues/12237
*/
try {
const gitignorePath = path.resolve(process.cwd(), '.gitignore');
const repoExcludePath = path.resolve(
tmpDir,
process.env.GIT_DIR,
'info/exclude',
);
const content = fs.readFileSync(gitignorePath, 'utf8');
fs.appendFileSync(repoExcludePath, content);
} catch (err) {
if (err.code === 'ENOENT') {
log.info('No .gitignore file found, this step is a no-op');
return;
}
throw err;
}
}
function generateTemplates(generatorDir, appName, verbose) {
try {
const yeomanGeneratorEntryPoint = path.resolve(generatorDir, 'index.js');
// Try requiring the index.js (entry-point of Yeoman generators)
fs.accessSync(yeomanGeneratorEntryPoint);
return runYeomanGenerators(generatorDir, appName, verbose);
} catch (err) {
return runCopyAndReplace(generatorDir, appName);
}
}
function runCopyAndReplace(generatorDir, appName) {
const copyProjectTemplateAndReplacePath = path.resolve(
generatorDir,
'copyProjectTemplateAndReplace',
);
/*
* This module is required twice during the process: for both old and new version
* of React Native.
* This file could have changed between these 2 versions. When generating the new template,
* we don't want to load the old version of the generator from the cache
*/
delete require.cache[require.resolve(copyProjectTemplateAndReplacePath)];
const copyProjectTemplateAndReplace = require(copyProjectTemplateAndReplacePath);
copyProjectTemplateAndReplace(
path.resolve(generatorDir, '..', 'templates', 'HelloWorld'),
process.cwd(),
appName,
{upgrade: true, force: true},
);
}
function runYeomanGenerators(generatorDir, appName, verbose) {
if (!verbose) {
// Yeoman output needs monkey-patching (no silent option)
TerminalAdapter.prototype.log = () => {};
TerminalAdapter.prototype.log.force = () => {};
TerminalAdapter.prototype.log.create = () => {};
}
const env = yeoman.createEnv();
env.register(generatorDir, 'react:app');
const generatorArgs = ['react:app', appName];
return new Promise(resolve =>
env.run(generatorArgs, {upgrade: true, force: true}, resolve),
);
}
/**
* If there's a newer version of react-native-git-upgrade in npm, suggest to the user to upgrade.
*/
async function checkForUpdates() {
try {
log.info('Check for updates');
const lastGitUpgradeVersion = await exec(
'npm view react-native-git-upgrade@latest version',
);
const current = require('./package').version;
const latest = semver.clean(lastGitUpgradeVersion);
if (semver.gt(latest, current)) {
log.warn(
'A more recent version of "react-native-git-upgrade" has been found.\n' +
`Current: ${current}\n` +
`Latest: ${latest}\n` +
'Please run "npm install -g react-native-git-upgrade"',
);
}
} catch (err) {
log.warn('Check for latest version failed', err.message);
}
}
/**
* If true, use yarn instead of the npm client to upgrade the project.
*/
function shouldUseYarn(cliArgs, projectDir) {
if (cliArgs && cliArgs.npm) {
return false;
}
const yarnVersion = yarn.getYarnVersionIfAvailable();
if (yarnVersion && yarn.isProjectUsingYarn(projectDir)) {
log.info('Using yarn ' + yarnVersion);
return true;
}
return false;
}
/**
* @param requestedVersion The version argument, e.g. 'react-native-git-upgrade 0.38'.
* `undefined` if no argument passed.
* @param cliArgs Additional arguments parsed using minimist.
*/
async function run(requestedVersion, cliArgs) {
const tmpDir = path.resolve(os.tmpdir(), 'react-native-git-upgrade');
const generatorDir = path.resolve(
process.cwd(),
'node_modules',
'react-native',
'local-cli',
'generator',
);
let projectBackupCreated = false;
try {
await checkForUpdates();
const useYarn = shouldUseYarn(cliArgs, path.resolve(process.cwd()));
log.info('Read package.json files');
const {
reactNativeNodeModulesPak,
reactNodeModulesPak,
pak,
} = readPackageFiles(useYarn);
const appName = pak.name;
const currentVersion = reactNativeNodeModulesPak.version;
const currentReactVersion = reactNodeModulesPak.version;
const declaredVersion = pak.dependencies['react-native'];
const declaredReactVersion = pak.dependencies.react;
const verbose = cliArgs.verbose;
log.info('Check declared version');
checkDeclaredVersion(declaredVersion);
log.info('Check matching versions');
checkMatchingVersions(currentVersion, declaredVersion, useYarn);
log.info('Check React peer dependency');
checkReactPeerDependency(currentVersion, declaredReactVersion);
log.info('Check that Git is installed');
checkGitAvailable();
log.info('Get information from NPM registry');
const viewCommand =
'npm view react-native@' + (requestedVersion || 'latest') + ' --json';
const jsonOutput = await exec(viewCommand, verbose);
const {newVersion, newReactVersionRange} = parseInformationJsonOutput(
jsonOutput,
requestedVersion,
);
// Print which versions we're upgrading to
log.info(
'Upgrading to React Native ' +
newVersion +
(newReactVersionRange ? ', React ' + newReactVersionRange : ''),
);
log.info('Setup temporary working directory');
await setupWorkingDir(tmpDir);
log.info('Configure Git environment');
configureGitEnv(tmpDir);
log.info('Init temporary Git repository');
await exec('git init', verbose);
log.info('Save current .gitignore file');
copyCurrentGitIgnoreFile(tmpDir);
log.info('Add all files to commit');
await exec('git add .', verbose);
log.info('Commit current project sources');
await exec('git commit -m "Project snapshot" --no-verify', verbose);
log.info('Create a tag before updating sources');
await exec('git tag project-snapshot', verbose);
projectBackupCreated = true;
log.info('Generate old version template');
await generateTemplates(generatorDir, appName, verbose);
log.info('Add updated files to commit');
await exec('git add .', verbose);
log.info('Commit old version template');
await exec(
'git commit -m "Old version" --allow-empty --no-verify',
verbose,
);
log.info('Install the new version');
let installCommand;
if (useYarn) {
installCommand = 'yarn add';
} else {
installCommand = 'npm install --save --color=always';
}
installCommand += ' react-native@' + newVersion;
if (
newReactVersionRange &&
!semver.satisfies(currentReactVersion, newReactVersionRange)
) {
// Install React as well to avoid unmet peer dependency
installCommand += ' react@' + newReactVersionRange;
}
await exec(installCommand, verbose);
log.info('Generate new version template');
await generateTemplates(generatorDir, appName, verbose);
log.info('Add updated files to commit');
await exec('git add .', verbose);
log.info('Commit new version template');
await exec(
'git commit -m "New version" --allow-empty --no-verify',
verbose,
);
log.info('Generate the patch between the 2 versions');
const diffOutput = await exec(
'git diff --binary --no-color HEAD~1 HEAD',
verbose,
);
log.info('Save the patch in temp directory');
const patchPath = path.resolve(
tmpDir,
`upgrade_${currentVersion}_${newVersion}.patch`,
);
fs.writeFileSync(patchPath, diffOutput);
log.info('Reset the 2 temporary commits');
await exec('git reset HEAD~2 --hard', verbose);
try {
log.info('Apply the patch');
await exec(`git apply --3way ${patchPath}`, true, (data, stream) => {
if (data.indexOf('conflicts') >= 0 || data.startsWith('U ')) {
stream.write(`\x1b[31m${data}\x1b[0m`);
} else {
stream.write(data);
}
});
} catch (err) {
log.warn(
'The upgrade process succeeded but there might be conflicts to be resolved. ' +
'See above for the list of files that have merge conflicts. ' +
'If you dont see the expected changes, try running:\n' +
`git apply --reject ${patchPath}`,
);
} finally {
log.info('Upgrade done');
if (cliArgs.verbose) {
log.info(`Temporary working directory: ${tmpDir}`);
}
}
} catch (err) {
log.error('An error occurred during upgrade:');
log.error(err.stack);
if (projectBackupCreated) {
log.error('Restore initial sources');
await exec('git checkout project-snapshot --no-verify', true);
}
}
}
module.exports = {
run: run,
};