Skip to content

Commit

Permalink
feat(toolkit): show when new version is available (#2484)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
nija-at authored and rix0rrr committed May 10, 2019
1 parent 0acfa8b commit 6cf4bd3
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 24 deletions.
5 changes: 3 additions & 2 deletions packages/aws-cdk/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
*.snk
29 changes: 16 additions & 13 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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')
Expand All @@ -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({
Expand Down Expand Up @@ -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<number | string | {} | void> {
Expand Down
13 changes: 6 additions & 7 deletions packages/aws-cdk/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ if [ -z "${commit}" ]; then
commit="$(git rev-parse --verify HEAD)"
fi

cat > lib/version.ts <<HERE
// Generated at $(date -u +"%Y-%m-%dT%H:%M:%SZ") by generate.sh
/** The qualified version number for this CDK toolkit. */
// tslint:disable-next-line:no-var-requires
export const VERSION = \`\${require('../package.json').version.replace(/\\+[0-9a-f]+\$/, '')} (build ${commit:0:7})\`;
HERE
cat > build-info.json <<HERE
{
"comment": "Generated at $(date -u +"%Y-%m-%dT%H:%M:%SZ") by generate.sh",
"commit": "${commit:0:7}"
}
HERE
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/commands/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import colors = require('colors/safe');
import yargs = require('yargs');
import version = require('../../lib/version');
import { CommandOptions } from '../command-api';
import { print } from '../logging';
import { Context, PROJECT_CONFIG } from '../settings';
Expand Down Expand Up @@ -44,6 +45,7 @@ export async function realHandler(options: CommandOptions): Promise<number> {
listContext(contextValues);
}
}
await version.displayVersionMessage();

return 0;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/aws-cdk/lib/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +21,7 @@ export async function realHandler(_options: CommandOptions): Promise<number> {
exitStatus = -1;
}
}
await version.displayVersionMessage();
return exitStatus;
}

Expand All @@ -33,7 +34,7 @@ const verifications: Array<() => boolean | Promise<boolean>> = [
// ### Verifications ###

function displayVersionInformation() {
print(`ℹ️ CDK Version: ${colors.green(VERSION)}`);
print(`ℹ️ CDK Version: ${colors.green(version.DISPLAY_VERSION)}`);
return true;
}

Expand Down
43 changes: 43 additions & 0 deletions packages/aws-cdk/lib/util/console-formatters.ts
Original file line number Diff line number Diff line change
@@ -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;
}
105 changes: 105 additions & 0 deletions packages/aws-cdk/lib/version.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<void> {
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<string | null> {
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<void> {
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}`);
}
}
52 changes: 52 additions & 0 deletions packages/aws-cdk/test/test.version.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
58 changes: 58 additions & 0 deletions packages/aws-cdk/test/util/test.console-formatters.ts
Original file line number Diff line number Diff line change
@@ -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();
}
};

0 comments on commit 6cf4bd3

Please sign in to comment.