402 lines
13 KiB
JavaScript
402 lines
13 KiB
JavaScript
/**
|
||
* Copyright (c) 2015-present, Facebook, Inc.
|
||
*
|
||
* This source code is licensed under the MIT license found in the
|
||
* LICENSE file in the root directory of this source tree.
|
||
*/
|
||
'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 don’t 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,
|
||
};
|