-
Notifications
You must be signed in to change notification settings - Fork 4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
9 changed files
with
288 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
}; |