From 6cf4bd362353737ca27fef6ea8745a272e48c311 Mon Sep 17 00:00:00 2001 From: Niranjan Jayakar <16217941+nija-at@users.noreply.github.com> Date: Fri, 10 May 2019 09:32:19 +0100 Subject: [PATCH] feat(toolkit): show when new version is available (#2484) Check, once a day, if a newer CDK version available in npm and announce it's availability at the end of a significant command. TESTING: * New unit tests for version.ts * Downgraded version number in package.json and verified that the expected message is printed. * Verified that the file cache throttles the check to run only once per day. Closes #297 --- packages/aws-cdk/.gitignore | 5 +- packages/aws-cdk/bin/cdk.ts | 29 ++--- packages/aws-cdk/generate.sh | 13 +-- packages/aws-cdk/lib/commands/context.ts | 2 + packages/aws-cdk/lib/commands/doctor.ts | 5 +- .../aws-cdk/lib/util/console-formatters.ts | 43 +++++++ packages/aws-cdk/lib/version.ts | 105 ++++++++++++++++++ packages/aws-cdk/test/test.version.ts | 52 +++++++++ .../test/util/test.console-formatters.ts | 58 ++++++++++ 9 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 packages/aws-cdk/lib/util/console-formatters.ts create mode 100644 packages/aws-cdk/lib/version.ts create mode 100644 packages/aws-cdk/test/test.version.ts create mode 100644 packages/aws-cdk/test/util/test.console-formatters.ts diff --git a/packages/aws-cdk/.gitignore b/packages/aws-cdk/.gitignore index cd4b6d8758860..f15ab74e43d6f 100644 --- a/packages/aws-cdk/.gitignore +++ b/packages/aws-cdk/.gitignore @@ -5,11 +5,12 @@ node_modules dist # Generated by generate.sh -lib/version.ts +build-info.json +.LAST_VERSION_CHECK .LAST_BUILD .nyc_output coverage .nycrc .LAST_PACKAGE -*.snk \ No newline at end of file +*.snk diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 421ff6dc2831b..6d2b623e9bbf6 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -20,7 +20,7 @@ import { PluginHost } from '../lib/plugin'; import { parseRenames } from '../lib/renames'; import { serializeStructure } from '../lib/serialize'; import { Configuration, Settings } from '../lib/settings'; -import { VERSION } from '../lib/version'; +import version = require('../lib/version'); // tslint:disable-next-line:no-var-requires const promptly = require('promptly'); @@ -76,7 +76,7 @@ async function parseCommandLineArguments() { .option('language', { type: 'string', alias: 'l', desc: 'the language to be used for the new project (default can be configured in ~/.cdk.json)', choices: initTemplateLanuages }) .option('list', { type: 'boolean', desc: 'list the available templates' })) .commandDir('../lib/commands', { exclude: /^_.*/ }) - .version(VERSION) + .version(version.DISPLAY_VERSION) .demandCommand(1, '') // just print help .help() .alias('h', 'help') @@ -96,8 +96,7 @@ async function initCommandLine() { if (argv.verbose) { setVerbose(); } - - debug('CDK toolkit version:', VERSION); + debug('CDK toolkit version:', version.DISPLAY_VERSION); debug('Command line arguments:', argv); const aws = new SDK({ @@ -152,15 +151,19 @@ async function initCommandLine() { // Bundle up global objects so the commands have access to them const commandOptions = { args: argv, appStacks, configuration, aws }; - const returnValue = argv.commandHandler - ? await (argv.commandHandler as (opts: typeof commandOptions) => any)(commandOptions) - : await main(cmd, argv); - if (typeof returnValue === 'object') { - return toJsonOrYaml(returnValue); - } else if (typeof returnValue === 'string') { - return returnValue; - } else { - return returnValue; + try { + const returnValue = argv.commandHandler + ? await (argv.commandHandler as (opts: typeof commandOptions) => any)(commandOptions) + : await main(cmd, argv); + if (typeof returnValue === 'object') { + return toJsonOrYaml(returnValue); + } else if (typeof returnValue === 'string') { + return returnValue; + } else { + return returnValue; + } + } finally { + await version.displayVersionMessage(); } async function main(command: string, args: any): Promise { diff --git a/packages/aws-cdk/generate.sh b/packages/aws-cdk/generate.sh index 476fe8618945d..e5ea04d12acd8 100755 --- a/packages/aws-cdk/generate.sh +++ b/packages/aws-cdk/generate.sh @@ -8,10 +8,9 @@ if [ -z "${commit}" ]; then commit="$(git rev-parse --verify HEAD)" fi -cat > lib/version.ts < build-info.json < { listContext(contextValues); } } + await version.displayVersionMessage(); return 0; } diff --git a/packages/aws-cdk/lib/commands/doctor.ts b/packages/aws-cdk/lib/commands/doctor.ts index 94fa2d638ebef..5aba8e58e506a 100644 --- a/packages/aws-cdk/lib/commands/doctor.ts +++ b/packages/aws-cdk/lib/commands/doctor.ts @@ -3,7 +3,7 @@ import colors = require('colors/safe'); import process = require('process'); import yargs = require('yargs'); import { print } from '../../lib/logging'; -import { VERSION } from '../../lib/version'; +import version = require('../../lib/version'); import { CommandOptions } from '../command-api'; export const command = 'doctor'; @@ -21,6 +21,7 @@ export async function realHandler(_options: CommandOptions): Promise { exitStatus = -1; } } + await version.displayVersionMessage(); return exitStatus; } @@ -33,7 +34,7 @@ const verifications: Array<() => boolean | Promise> = [ // ### Verifications ### function displayVersionInformation() { - print(`ℹ️ CDK Version: ${colors.green(VERSION)}`); + print(`ℹ️ CDK Version: ${colors.green(version.DISPLAY_VERSION)}`); return true; } diff --git a/packages/aws-cdk/lib/util/console-formatters.ts b/packages/aws-cdk/lib/util/console-formatters.ts new file mode 100644 index 0000000000000..79c55eba0bc3f --- /dev/null +++ b/packages/aws-cdk/lib/util/console-formatters.ts @@ -0,0 +1,43 @@ +import colors = require('colors/safe'); + +/** + * Returns a set of strings when printed on the console produces a banner msg. The message is in the following format - + * ******************** + * *** msg line x *** + * *** msg line xyz *** + * ******************** + * + * Spec: + * - The width of every line is equal, dictated by the longest message string + * - The first and last lines are '*'s for the full length of the line + * - Each line in between is prepended with '*** ' and appended with ' ***' + * - The text is indented left, i.e. whitespace is right-padded when the length is shorter than the longest. + * + * @param msgs array of strings containing the message lines to be printed in the banner. Returns empty string if array + * is empty. + * @returns array of strings containing the message formatted as a banner + */ +export function formatAsBanner(msgs: string[]): string[] { + const printLen = (str: string) => colors.strip(str).length; + + if (msgs.length === 0) { + return []; + } + + const leftPad = '*** '; + const rightPad = ' ***'; + const bannerWidth = printLen(leftPad) + printLen(rightPad) + + msgs.reduce((acc, msg) => Math.max(acc, printLen(msg)), 0); + + const bannerLines: string[] = []; + bannerLines.push('*'.repeat(bannerWidth)); + + // Improvement: If any 'msg' is wider than the terminal width, wrap message across lines. + msgs.forEach((msg) => { + const padding = ' '.repeat(bannerWidth - (printLen(msg) + printLen(leftPad) + printLen(rightPad))); + bannerLines.push(''.concat(leftPad, msg, padding, rightPad)); + }); + + bannerLines.push('*'.repeat(bannerWidth)); + return bannerLines; +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/version.ts b/packages/aws-cdk/lib/version.ts new file mode 100644 index 0000000000000..7d9933b6b8f03 --- /dev/null +++ b/packages/aws-cdk/lib/version.ts @@ -0,0 +1,105 @@ +import { exec as _exec } from 'child_process'; +import colors = require('colors/safe'); +import { close as _close, open as _open, stat as _stat } from 'fs'; +import semver = require('semver'); +import { promisify } from 'util'; +import { debug, print, warning } from '../lib/logging'; +import { formatAsBanner } from '../lib/util/console-formatters'; + +const ONE_DAY_IN_SECONDS = 1 * 24 * 60 * 60; + +const close = promisify(_close); +const exec = promisify(_exec); +const open = promisify(_open); +const stat = promisify(_stat); + +export const DISPLAY_VERSION = `${versionNumber()} (build ${commit()})`; + +function versionNumber(): string { + return require('../package.json').version.replace(/\+[0-9a-f]+$/, ''); +} + +function commit(): string { + return require('../build-info.json').commit; +} + +export class TimestampFile { + private readonly file: string; + + // File modify times are accurate only till the second, hence using seconds as precision + private readonly ttlSecs: number; + + constructor(file: string, ttlSecs: number) { + this.file = file; + this.ttlSecs = ttlSecs; + } + + public async hasExpired(): Promise { + try { + const lastCheckTime = (await stat(this.file)).mtimeMs; + const today = new Date().getTime(); + + if ((today - lastCheckTime) / 1000 > this.ttlSecs) { // convert ms to secs + return true; + } + return false; + } catch (err) { + if (err.code === 'ENOENT') { + return true; + } else { + throw err; + } + } + } + + public async update(): Promise { + const fd = await open(this.file, 'w'); + await close(fd); + } +} + +// Export for unit testing only. +// Don't use directly, use displayVersionMessage() instead. +export async function latestVersionIfHigher(currentVersion: string, cacheFile: TimestampFile): Promise { + if (!(await cacheFile.hasExpired())) { + return null; + } + + const { stdout, stderr } = await exec(`npm view aws-cdk version`); + if (stderr && stderr.trim().length > 0) { + debug(`The 'npm view' command generated an error stream with content [${stderr.trim()}]`); + } + const latestVersion = stdout.trim(); + if (!semver.valid(latestVersion)) { + throw new Error(`npm returned an invalid semver ${latestVersion}`); + } + const isNewer = semver.gt(latestVersion, currentVersion); + await cacheFile.update(); + + if (isNewer) { + return latestVersion; + } else { + return null; + } +} + +const versionCheckCache = new TimestampFile(`${__dirname}/../.LAST_VERSION_CHECK`, ONE_DAY_IN_SECONDS); + +export async function displayVersionMessage(): Promise { + if (!process.stdout.isTTY) { + return; + } + + try { + const laterVersion = await latestVersionIfHigher(versionNumber(), versionCheckCache); + if (laterVersion) { + const bannerMsg = formatAsBanner([ + `Newer version of CDK is available [${colors.green(laterVersion as string)}]`, + `Upgrade recommended`, + ]); + bannerMsg.forEach((e) => print(e)); + } + } catch (err) { + warning(`Could not run version check due to error ${err.message}`); + } +} \ No newline at end of file diff --git a/packages/aws-cdk/test/test.version.ts b/packages/aws-cdk/test/test.version.ts new file mode 100644 index 0000000000000..d7b53f7eb0a51 --- /dev/null +++ b/packages/aws-cdk/test/test.version.ts @@ -0,0 +1,52 @@ +import { Test } from 'nodeunit'; +import { setTimeout as _setTimeout } from 'timers'; +import { promisify } from 'util'; +import { latestVersionIfHigher, TimestampFile } from '../lib/version'; + +const setTimeout = promisify(_setTimeout); + +function tmpfile(): string { + return `/tmp/version-${Math.floor(Math.random() * 10000)}`; +} + +export = { + async 'cache file responds correctly when file is not present'(test: Test) { + const cache = new TimestampFile(tmpfile(), 1); + test.strictEqual(await cache.hasExpired(), true); + test.done(); + }, + + async 'cache file honours the specified TTL'(test: Test) { + const cache = new TimestampFile(tmpfile(), 1); + await cache.update(); + test.strictEqual(await cache.hasExpired(), false); + await setTimeout(1000); // 1 sec in ms + test.strictEqual(await cache.hasExpired(), true); + test.done(); + }, + + async 'Skip version check if cache has not expired'(test: Test) { + const cache = new TimestampFile(tmpfile(), 100); + await cache.update(); + test.equal(await latestVersionIfHigher('0.0.0', cache), null); + test.done(); + }, + + async 'Return later version when exists & skip recent re-check'(test: Test) { + const cache = new TimestampFile(tmpfile(), 100); + const result = await latestVersionIfHigher('0.0.0', cache); + test.notEqual(result, null); + test.ok((result as string).length > 0); + + const result2 = await latestVersionIfHigher('0.0.0', cache); + test.equal(result2, null); + test.done(); + }, + + async 'Return null if version is higher than npm'(test: Test) { + const cache = new TimestampFile(tmpfile(), 100); + const result = await latestVersionIfHigher('100.100.100', cache); + test.equal(result, null); + test.done(); + }, +}; \ No newline at end of file diff --git a/packages/aws-cdk/test/util/test.console-formatters.ts b/packages/aws-cdk/test/util/test.console-formatters.ts new file mode 100644 index 0000000000000..6ed0a05fe3a64 --- /dev/null +++ b/packages/aws-cdk/test/util/test.console-formatters.ts @@ -0,0 +1,58 @@ +import colors = require('colors/safe'); +import { Test } from 'nodeunit'; +import { formatAsBanner } from '../../lib/util/console-formatters'; + +function reportBanners(actual: string[], expected: string[]): string { + return 'Assertion failed.\n' + + 'Expected banner: \n' + expected.join('\n') + '\n' + + 'Actual banner: \n' + actual.join('\n'); +} + +export = { + 'no banner on empty msg list'(test: Test) { + test.strictEqual(formatAsBanner([]).length, 0); + test.done(); + }, + + 'banner works as expected'(test: Test) { + const msgs = [ 'msg1', 'msg2' ]; + const expected = [ + '************', + '*** msg1 ***', + '*** msg2 ***', + '************' + ]; + + const actual = formatAsBanner(msgs); + + test.strictEqual(formatAsBanner(msgs).length, expected.length, reportBanners(actual, expected)); + for (let i = 0; i < expected.length; i++) { + test.strictEqual(actual[i], expected[i], reportBanners(actual, expected)); + } + test.done(); + }, + + 'banner works for formatted msgs'(test: Test) { + const msgs = [ + 'hello msg1', + colors.yellow('hello msg2'), + colors.bold('hello msg3'), + ]; + const expected = [ + '******************', + '*** hello msg1 ***', + `*** ${colors.yellow('hello msg2')} ***`, + `*** ${colors.bold('hello msg3')} ***`, + '******************', + ]; + + const actual = formatAsBanner(msgs); + + test.strictEqual(formatAsBanner(msgs).length, expected.length, reportBanners(actual, expected)); + for (let i = 0; i < expected.length; i++) { + test.strictEqual(actual[i], expected[i], reportBanners(actual, expected)); + } + + test.done(); + } +}; \ No newline at end of file