/** * 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. * * @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 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, };