Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions packages/cli/src/cliEntry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> = 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(
Expand Down Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions packages/cli/src/tools/releaseChecker/getLatestRelease.js
Original file line number Diff line number Diff line change
@@ -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));
});
}
26 changes: 26 additions & 0 deletions packages/cli/src/tools/releaseChecker/printNewRelease.js
Original file line number Diff line number Diff line change
@@ -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}).`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adjusted the message a bit, looks like this now:

image

Hope you don't mind :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @cpojer is this ok, or do you have better ideas?

);
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());
}
69 changes: 69 additions & 0 deletions packages/cli/src/tools/releaseChecker/releaseCacheManager.js
Original file line number Diff line number Diff line change
@@ -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,
};
4 changes: 2 additions & 2 deletions packages/cli/src/tools/walk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down