Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(toolkit): show when new version is available #2484

Merged
merged 4 commits into from
May 10, 2019
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
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');
nija-at marked this conversation as resolved.
Show resolved Hide resolved
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()}]`);
nija-at marked this conversation as resolved.
Show resolved Hide resolved
}
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();
}
};