diff --git a/packages/cli/src/cliEntry.js b/packages/cli/src/cliEntry.js index c22bc0573..1798f391e 100644 --- a/packages/cli/src/cliEntry.js +++ b/packages/cli/src/cliEntry.js @@ -19,6 +19,9 @@ import init from './commands/init/initCompat'; import assertRequiredOptions from './tools/assertRequiredOptions'; import {logger} from '@react-native-community/cli-tools'; import {setProjectDir} from './tools/packageManager'; +import resolveNodeModuleDir from './tools/config/resolveNodeModuleDir'; +import getLatestRelease from './tools/releaseChecker/getLatestRelease'; +import printNewRelease from './tools/releaseChecker/printNewRelease'; import pkgJson from '../package.json'; import loadConfig from './tools/config'; @@ -107,16 +110,18 @@ const addCommand = (command: CommandT, ctx: ConfigT) => { const cmd = commander .command(command.name) .description(command.description) - .action(function handleAction(...args) { + .action(async function handleAction(...args) { const passedOptions = this.opts(); const argv: Array = Array.from(args).slice(0, -1); - Promise.resolve() - .then(() => { - assertRequiredOptions(options, passedOptions); - return command.func(argv, ctx, passedOptions); - }) - .catch(handleError); + try { + assertRequiredOptions(options, passedOptions); + await command.func(argv, ctx, passedOptions); + } catch (error) { + handleError(error); + } finally { + checkForNewRelease(ctx.root); + } }); cmd.helpInformation = printHelpInformation.bind( @@ -186,6 +191,29 @@ async function setupAndRun() { logger.setVerbose(commander.verbose); } +async function checkForNewRelease(root: string) { + try { + const {version: currentVersion} = require(path.join( + resolveNodeModuleDir(root, 'react-native'), + 'package.json', + )); + const {name} = require(path.join(root, 'package.json')); + const latestRelease = await getLatestRelease(name, currentVersion); + + if (latestRelease) { + printNewRelease(name, latestRelease, currentVersion); + } + } catch (e) { + // We let the flow continue as this component is not vital for the rest of + // the CLI. + logger.debug( + 'Cannot detect current version of React Native, ' + + 'skipping check for a newer release', + ); + logger.debug(e); + } +} + export default { run, init, diff --git a/packages/cli/src/tools/releaseChecker/getLatestRelease.js b/packages/cli/src/tools/releaseChecker/getLatestRelease.js new file mode 100644 index 000000000..debf37f0e --- /dev/null +++ b/packages/cli/src/tools/releaseChecker/getLatestRelease.js @@ -0,0 +1,140 @@ +/** + * @flow + */ +import https from 'https'; +import semver from 'semver'; +import logger from '../logger'; +import cacheManager from './releaseCacheManager'; + +export type Release = { + version: string, + changelogUrl: string, +}; + +/** + * Checks via GitHub API if there is a newer stable React Native release and, + * if it exists, returns the release data. + * + * If the latest release is not newer or if it's a prerelease, the function + * will return undefined. + */ +export default async function getLatestRelease( + name: string, + currentVersion: string, +) { + logger.debug('Checking for a newer version of React Native'); + try { + logger.debug(`Current version: ${currentVersion}`); + + const cachedLatest = cacheManager.get(name, 'latestVersion'); + + if (cachedLatest) { + logger.debug(`Cached release version: ${cachedLatest}`); + } + + const aWeek = 7 * 24 * 60 * 60 * 1000; + const lastChecked = cacheManager.get(name, 'lastChecked'); + const now = new Date(); + if (lastChecked && now - new Date(lastChecked) < aWeek) { + logger.debug('Cached release is still recent, skipping remote check'); + return; + } + + logger.debug('Checking for newer releases on GitHub'); + const eTag = cacheManager.get(name, 'eTag'); + const latestVersion = await getLatestRnDiffPurgeVersion(name, eTag); + logger.debug(`Latest release: ${latestVersion}`); + + if ( + semver.compare(latestVersion, currentVersion) === 1 && + !semver.prerelease(latestVersion) + ) { + return { + version: latestVersion, + changelogUrl: buildChangelogUrl(latestVersion), + }; + } + } catch (e) { + logger.debug( + 'Something went wrong with remote version checking, moving on', + ); + logger.debug(e); + } +} + +function buildChangelogUrl(version: string) { + return `https://github.com/facebook/react-native/releases/tag/v${version}`; +} + +/** + * Returns the most recent React Native version available to upgrade to. + */ +async function getLatestRnDiffPurgeVersion(name: string, eTag: ?string) { + const options = { + hostname: 'api.github.com', + path: '/repos/react-native-community/rn-diff-purge/tags', + // https://developer.github.com/v3/#user-agent-required + headers: ({'User-Agent': 'React-Native-CLI'}: Headers), + }; + + if (eTag) { + options.headers['If-None-Match'] = eTag; + } + + const response = await httpsGet(options); + + // Remote is newer. + if (response.statusCode === 200) { + const latestVersion = JSON.parse(response.body)[0].name.substring(8); + + // Update cache only if newer release is stable. + if (!semver.prerelease(latestVersion)) { + logger.debug(`Saving ${response.eTag} to cache`); + cacheManager.set(name, 'eTag', response.eTag); + cacheManager.set(name, 'latestVersion', latestVersion); + } + + return latestVersion; + } + + // Cache is still valid. + if (response.statusCode === 304) { + const latestVersion = cacheManager.get(name, 'latestVersion'); + if (latestVersion) { + return latestVersion; + } + } + + // Should be returned only if something went wrong. + return '0.0.0'; +} + +type Headers = { + 'User-Agent': string, + [header: string]: string, +}; + +function httpsGet(options: any) { + return new Promise((resolve, reject) => { + https + .get(options, result => { + let body = ''; + + result.setEncoding('utf8'); + result.on('data', data => { + body += data; + }); + + result.on('end', () => { + resolve({ + body, + eTag: result.headers.etag, + statusCode: result.statusCode, + }); + }); + + result.on('error', error => reject(error)); + }) + .on('error', error => reject(error)); + }); +} diff --git a/packages/cli/src/tools/releaseChecker/printNewRelease.js b/packages/cli/src/tools/releaseChecker/printNewRelease.js new file mode 100644 index 000000000..cfababab7 --- /dev/null +++ b/packages/cli/src/tools/releaseChecker/printNewRelease.js @@ -0,0 +1,26 @@ +/** + * @flow + */ +import chalk from 'chalk'; +import logger from '../logger'; +import type {Release} from './getLatestRelease'; +import cacheManager from './releaseCacheManager'; + +/** + * Notifies the user that a newer version of React Native is available. + */ +export default function printNewRelease( + name: string, + latestRelease: Release, + currentVersion: string, +) { + logger.info( + `React Native v${ + latestRelease.version + } is now available (your project is running on v${currentVersion}).`, + ); + logger.info(`Changelog: ${chalk.dim.underline(latestRelease.changelogUrl)}.`); + logger.info(`To upgrade, run "${chalk.bold('react-native upgrade')}".`); + + cacheManager.set(name, 'lastChecked', new Date().toISOString()); +} diff --git a/packages/cli/src/tools/releaseChecker/releaseCacheManager.js b/packages/cli/src/tools/releaseChecker/releaseCacheManager.js new file mode 100644 index 000000000..193af6c33 --- /dev/null +++ b/packages/cli/src/tools/releaseChecker/releaseCacheManager.js @@ -0,0 +1,69 @@ +/** + * @flow + */ +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import mkdirp from 'mkdirp'; +import logger from '../logger'; + +type ReleaseCacheKey = 'eTag' | 'lastChecked' | 'latestVersion'; +type Cache = {[key: ReleaseCacheKey]: string}; + +function loadCache(name: string): ?Cache { + try { + const cacheRaw = fs.readFileSync( + path.resolve(getCacheRootPath(), name), + 'utf8', + ); + const cache = JSON.parse(cacheRaw); + return cache; + } catch (e) { + if (e.code === 'ENOENT') { + // Create cache file since it doesn't exist. + saveCache(name, {}); + } + logger.debug('No release cache found'); + } +} + +function saveCache(name: string, cache: Cache) { + fs.writeFileSync( + path.resolve(getCacheRootPath(), name), + JSON.stringify(cache, null, 2), + ); +} + +/** + * Returns the path string of `$HOME/.react-native-cli`. + * + * In case it doesn't exist, it will be created. + */ +function getCacheRootPath() { + const cachePath = path.resolve(os.homedir(), '.react-native-cli', 'cache'); + if (!fs.existsSync(cachePath)) { + mkdirp(cachePath); + } + + return cachePath; +} + +function get(name: string, key: ReleaseCacheKey): ?string { + const cache = loadCache(name); + if (cache) { + return cache[key]; + } +} + +function set(name: string, key: ReleaseCacheKey, value: string) { + const cache = loadCache(name); + if (cache) { + cache[key] = value; + saveCache(name, cache); + } +} + +export default { + get, + set, +}; diff --git a/packages/cli/src/tools/walk.js b/packages/cli/src/tools/walk.js index 5fdc88848..fb0a81eba 100644 --- a/packages/cli/src/tools/walk.js +++ b/packages/cli/src/tools/walk.js @@ -4,13 +4,13 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @format + * @flow */ import fs from 'fs'; import path from 'path'; -function walk(current) { +function walk(current: string) { if (!fs.lstatSync(current).isDirectory()) { return [current]; }