Skip to content

Commit 6cf4bd3

Browse files
nija-atrix0rrr
authored andcommitted
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
1 parent 0acfa8b commit 6cf4bd3

File tree

9 files changed

+288
-24
lines changed

9 files changed

+288
-24
lines changed

packages/aws-cdk/.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ node_modules
55
dist
66

77
# Generated by generate.sh
8-
lib/version.ts
8+
build-info.json
9+
.LAST_VERSION_CHECK
910

1011
.LAST_BUILD
1112
.nyc_output
1213
coverage
1314
.nycrc
1415
.LAST_PACKAGE
15-
*.snk
16+
*.snk

packages/aws-cdk/bin/cdk.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { PluginHost } from '../lib/plugin';
2020
import { parseRenames } from '../lib/renames';
2121
import { serializeStructure } from '../lib/serialize';
2222
import { Configuration, Settings } from '../lib/settings';
23-
import { VERSION } from '../lib/version';
23+
import version = require('../lib/version');
2424

2525
// tslint:disable-next-line:no-var-requires
2626
const promptly = require('promptly');
@@ -76,7 +76,7 @@ async function parseCommandLineArguments() {
7676
.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 })
7777
.option('list', { type: 'boolean', desc: 'list the available templates' }))
7878
.commandDir('../lib/commands', { exclude: /^_.*/ })
79-
.version(VERSION)
79+
.version(version.DISPLAY_VERSION)
8080
.demandCommand(1, '') // just print help
8181
.help()
8282
.alias('h', 'help')
@@ -96,8 +96,7 @@ async function initCommandLine() {
9696
if (argv.verbose) {
9797
setVerbose();
9898
}
99-
100-
debug('CDK toolkit version:', VERSION);
99+
debug('CDK toolkit version:', version.DISPLAY_VERSION);
101100
debug('Command line arguments:', argv);
102101

103102
const aws = new SDK({
@@ -152,15 +151,19 @@ async function initCommandLine() {
152151
// Bundle up global objects so the commands have access to them
153152
const commandOptions = { args: argv, appStacks, configuration, aws };
154153

155-
const returnValue = argv.commandHandler
156-
? await (argv.commandHandler as (opts: typeof commandOptions) => any)(commandOptions)
157-
: await main(cmd, argv);
158-
if (typeof returnValue === 'object') {
159-
return toJsonOrYaml(returnValue);
160-
} else if (typeof returnValue === 'string') {
161-
return returnValue;
162-
} else {
163-
return returnValue;
154+
try {
155+
const returnValue = argv.commandHandler
156+
? await (argv.commandHandler as (opts: typeof commandOptions) => any)(commandOptions)
157+
: await main(cmd, argv);
158+
if (typeof returnValue === 'object') {
159+
return toJsonOrYaml(returnValue);
160+
} else if (typeof returnValue === 'string') {
161+
return returnValue;
162+
} else {
163+
return returnValue;
164+
}
165+
} finally {
166+
await version.displayVersionMessage();
164167
}
165168

166169
async function main(command: string, args: any): Promise<number | string | {} | void> {

packages/aws-cdk/generate.sh

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ if [ -z "${commit}" ]; then
88
commit="$(git rev-parse --verify HEAD)"
99
fi
1010

11-
cat > lib/version.ts <<HERE
12-
// Generated at $(date -u +"%Y-%m-%dT%H:%M:%SZ") by generate.sh
13-
14-
/** The qualified version number for this CDK toolkit. */
15-
// tslint:disable-next-line:no-var-requires
16-
export const VERSION = \`\${require('../package.json').version.replace(/\\+[0-9a-f]+\$/, '')} (build ${commit:0:7})\`;
17-
HERE
11+
cat > build-info.json <<HERE
12+
{
13+
"comment": "Generated at $(date -u +"%Y-%m-%dT%H:%M:%SZ") by generate.sh",
14+
"commit": "${commit:0:7}"
15+
}
16+
HERE

packages/aws-cdk/lib/commands/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import colors = require('colors/safe');
22
import yargs = require('yargs');
3+
import version = require('../../lib/version');
34
import { CommandOptions } from '../command-api';
45
import { print } from '../logging';
56
import { Context, PROJECT_CONFIG } from '../settings';
@@ -44,6 +45,7 @@ export async function realHandler(options: CommandOptions): Promise<number> {
4445
listContext(contextValues);
4546
}
4647
}
48+
await version.displayVersionMessage();
4749

4850
return 0;
4951
}

packages/aws-cdk/lib/commands/doctor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import colors = require('colors/safe');
33
import process = require('process');
44
import yargs = require('yargs');
55
import { print } from '../../lib/logging';
6-
import { VERSION } from '../../lib/version';
6+
import version = require('../../lib/version');
77
import { CommandOptions } from '../command-api';
88

99
export const command = 'doctor';
@@ -21,6 +21,7 @@ export async function realHandler(_options: CommandOptions): Promise<number> {
2121
exitStatus = -1;
2222
}
2323
}
24+
await version.displayVersionMessage();
2425
return exitStatus;
2526
}
2627

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

3536
function displayVersionInformation() {
36-
print(`ℹ️ CDK Version: ${colors.green(VERSION)}`);
37+
print(`ℹ️ CDK Version: ${colors.green(version.DISPLAY_VERSION)}`);
3738
return true;
3839
}
3940

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import colors = require('colors/safe');
2+
3+
/**
4+
* Returns a set of strings when printed on the console produces a banner msg. The message is in the following format -
5+
* ********************
6+
* *** msg line x ***
7+
* *** msg line xyz ***
8+
* ********************
9+
*
10+
* Spec:
11+
* - The width of every line is equal, dictated by the longest message string
12+
* - The first and last lines are '*'s for the full length of the line
13+
* - Each line in between is prepended with '*** ' and appended with ' ***'
14+
* - The text is indented left, i.e. whitespace is right-padded when the length is shorter than the longest.
15+
*
16+
* @param msgs array of strings containing the message lines to be printed in the banner. Returns empty string if array
17+
* is empty.
18+
* @returns array of strings containing the message formatted as a banner
19+
*/
20+
export function formatAsBanner(msgs: string[]): string[] {
21+
const printLen = (str: string) => colors.strip(str).length;
22+
23+
if (msgs.length === 0) {
24+
return [];
25+
}
26+
27+
const leftPad = '*** ';
28+
const rightPad = ' ***';
29+
const bannerWidth = printLen(leftPad) + printLen(rightPad) +
30+
msgs.reduce((acc, msg) => Math.max(acc, printLen(msg)), 0);
31+
32+
const bannerLines: string[] = [];
33+
bannerLines.push('*'.repeat(bannerWidth));
34+
35+
// Improvement: If any 'msg' is wider than the terminal width, wrap message across lines.
36+
msgs.forEach((msg) => {
37+
const padding = ' '.repeat(bannerWidth - (printLen(msg) + printLen(leftPad) + printLen(rightPad)));
38+
bannerLines.push(''.concat(leftPad, msg, padding, rightPad));
39+
});
40+
41+
bannerLines.push('*'.repeat(bannerWidth));
42+
return bannerLines;
43+
}

packages/aws-cdk/lib/version.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { exec as _exec } from 'child_process';
2+
import colors = require('colors/safe');
3+
import { close as _close, open as _open, stat as _stat } from 'fs';
4+
import semver = require('semver');
5+
import { promisify } from 'util';
6+
import { debug, print, warning } from '../lib/logging';
7+
import { formatAsBanner } from '../lib/util/console-formatters';
8+
9+
const ONE_DAY_IN_SECONDS = 1 * 24 * 60 * 60;
10+
11+
const close = promisify(_close);
12+
const exec = promisify(_exec);
13+
const open = promisify(_open);
14+
const stat = promisify(_stat);
15+
16+
export const DISPLAY_VERSION = `${versionNumber()} (build ${commit()})`;
17+
18+
function versionNumber(): string {
19+
return require('../package.json').version.replace(/\+[0-9a-f]+$/, '');
20+
}
21+
22+
function commit(): string {
23+
return require('../build-info.json').commit;
24+
}
25+
26+
export class TimestampFile {
27+
private readonly file: string;
28+
29+
// File modify times are accurate only till the second, hence using seconds as precision
30+
private readonly ttlSecs: number;
31+
32+
constructor(file: string, ttlSecs: number) {
33+
this.file = file;
34+
this.ttlSecs = ttlSecs;
35+
}
36+
37+
public async hasExpired(): Promise<boolean> {
38+
try {
39+
const lastCheckTime = (await stat(this.file)).mtimeMs;
40+
const today = new Date().getTime();
41+
42+
if ((today - lastCheckTime) / 1000 > this.ttlSecs) { // convert ms to secs
43+
return true;
44+
}
45+
return false;
46+
} catch (err) {
47+
if (err.code === 'ENOENT') {
48+
return true;
49+
} else {
50+
throw err;
51+
}
52+
}
53+
}
54+
55+
public async update(): Promise<void> {
56+
const fd = await open(this.file, 'w');
57+
await close(fd);
58+
}
59+
}
60+
61+
// Export for unit testing only.
62+
// Don't use directly, use displayVersionMessage() instead.
63+
export async function latestVersionIfHigher(currentVersion: string, cacheFile: TimestampFile): Promise<string | null> {
64+
if (!(await cacheFile.hasExpired())) {
65+
return null;
66+
}
67+
68+
const { stdout, stderr } = await exec(`npm view aws-cdk version`);
69+
if (stderr && stderr.trim().length > 0) {
70+
debug(`The 'npm view' command generated an error stream with content [${stderr.trim()}]`);
71+
}
72+
const latestVersion = stdout.trim();
73+
if (!semver.valid(latestVersion)) {
74+
throw new Error(`npm returned an invalid semver ${latestVersion}`);
75+
}
76+
const isNewer = semver.gt(latestVersion, currentVersion);
77+
await cacheFile.update();
78+
79+
if (isNewer) {
80+
return latestVersion;
81+
} else {
82+
return null;
83+
}
84+
}
85+
86+
const versionCheckCache = new TimestampFile(`${__dirname}/../.LAST_VERSION_CHECK`, ONE_DAY_IN_SECONDS);
87+
88+
export async function displayVersionMessage(): Promise<void> {
89+
if (!process.stdout.isTTY) {
90+
return;
91+
}
92+
93+
try {
94+
const laterVersion = await latestVersionIfHigher(versionNumber(), versionCheckCache);
95+
if (laterVersion) {
96+
const bannerMsg = formatAsBanner([
97+
`Newer version of CDK is available [${colors.green(laterVersion as string)}]`,
98+
`Upgrade recommended`,
99+
]);
100+
bannerMsg.forEach((e) => print(e));
101+
}
102+
} catch (err) {
103+
warning(`Could not run version check due to error ${err.message}`);
104+
}
105+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Test } from 'nodeunit';
2+
import { setTimeout as _setTimeout } from 'timers';
3+
import { promisify } from 'util';
4+
import { latestVersionIfHigher, TimestampFile } from '../lib/version';
5+
6+
const setTimeout = promisify(_setTimeout);
7+
8+
function tmpfile(): string {
9+
return `/tmp/version-${Math.floor(Math.random() * 10000)}`;
10+
}
11+
12+
export = {
13+
async 'cache file responds correctly when file is not present'(test: Test) {
14+
const cache = new TimestampFile(tmpfile(), 1);
15+
test.strictEqual(await cache.hasExpired(), true);
16+
test.done();
17+
},
18+
19+
async 'cache file honours the specified TTL'(test: Test) {
20+
const cache = new TimestampFile(tmpfile(), 1);
21+
await cache.update();
22+
test.strictEqual(await cache.hasExpired(), false);
23+
await setTimeout(1000); // 1 sec in ms
24+
test.strictEqual(await cache.hasExpired(), true);
25+
test.done();
26+
},
27+
28+
async 'Skip version check if cache has not expired'(test: Test) {
29+
const cache = new TimestampFile(tmpfile(), 100);
30+
await cache.update();
31+
test.equal(await latestVersionIfHigher('0.0.0', cache), null);
32+
test.done();
33+
},
34+
35+
async 'Return later version when exists & skip recent re-check'(test: Test) {
36+
const cache = new TimestampFile(tmpfile(), 100);
37+
const result = await latestVersionIfHigher('0.0.0', cache);
38+
test.notEqual(result, null);
39+
test.ok((result as string).length > 0);
40+
41+
const result2 = await latestVersionIfHigher('0.0.0', cache);
42+
test.equal(result2, null);
43+
test.done();
44+
},
45+
46+
async 'Return null if version is higher than npm'(test: Test) {
47+
const cache = new TimestampFile(tmpfile(), 100);
48+
const result = await latestVersionIfHigher('100.100.100', cache);
49+
test.equal(result, null);
50+
test.done();
51+
},
52+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import colors = require('colors/safe');
2+
import { Test } from 'nodeunit';
3+
import { formatAsBanner } from '../../lib/util/console-formatters';
4+
5+
function reportBanners(actual: string[], expected: string[]): string {
6+
return 'Assertion failed.\n' +
7+
'Expected banner: \n' + expected.join('\n') + '\n' +
8+
'Actual banner: \n' + actual.join('\n');
9+
}
10+
11+
export = {
12+
'no banner on empty msg list'(test: Test) {
13+
test.strictEqual(formatAsBanner([]).length, 0);
14+
test.done();
15+
},
16+
17+
'banner works as expected'(test: Test) {
18+
const msgs = [ 'msg1', 'msg2' ];
19+
const expected = [
20+
'************',
21+
'*** msg1 ***',
22+
'*** msg2 ***',
23+
'************'
24+
];
25+
26+
const actual = formatAsBanner(msgs);
27+
28+
test.strictEqual(formatAsBanner(msgs).length, expected.length, reportBanners(actual, expected));
29+
for (let i = 0; i < expected.length; i++) {
30+
test.strictEqual(actual[i], expected[i], reportBanners(actual, expected));
31+
}
32+
test.done();
33+
},
34+
35+
'banner works for formatted msgs'(test: Test) {
36+
const msgs = [
37+
'hello msg1',
38+
colors.yellow('hello msg2'),
39+
colors.bold('hello msg3'),
40+
];
41+
const expected = [
42+
'******************',
43+
'*** hello msg1 ***',
44+
`*** ${colors.yellow('hello msg2')} ***`,
45+
`*** ${colors.bold('hello msg3')} ***`,
46+
'******************',
47+
];
48+
49+
const actual = formatAsBanner(msgs);
50+
51+
test.strictEqual(formatAsBanner(msgs).length, expected.length, reportBanners(actual, expected));
52+
for (let i = 0; i < expected.length; i++) {
53+
test.strictEqual(actual[i], expected[i], reportBanners(actual, expected));
54+
}
55+
56+
test.done();
57+
}
58+
};

0 commit comments

Comments
 (0)