diff --git a/packages/aws-cdk/lib/cli/util/npm.ts b/packages/aws-cdk/lib/cli/util/npm.ts index 02887503d..9f8434f36 100644 --- a/packages/aws-cdk/lib/cli/util/npm.ts +++ b/packages/aws-cdk/lib/cli/util/npm.ts @@ -1,22 +1,34 @@ import { exec as _exec } from 'child_process'; import { promisify } from 'util'; -import * as semver from 'semver'; import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import { debug } from '../../logging'; const exec = promisify(_exec); -/* c8 ignore start */ // not called during unit tests -export async function getLatestVersionFromNpm(): Promise { - const { stdout, stderr } = await exec('npm view aws-cdk version', { timeout: 3000 }); - 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 ToolkitError(`npm returned an invalid semver ${latestVersion}`); - } +/* c8 ignore start */ +export async function execNpmView(currentVersion: string) { + try { + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const [latestResult, currentResult] = await Promise.all([ + exec('npm view aws-cdk@latest version', { timeout: 3000 }), + exec(`npm view aws-cdk@${currentVersion} name version deprecated --json`, { timeout: 3000 }), + ]); + + if (latestResult.stderr && latestResult.stderr.trim().length > 0) { + throw new ToolkitError(`npm view command for latest version failed: ${latestResult.stderr.trim()}`); + } + if (currentResult.stderr && currentResult.stderr.trim().length > 0) { + throw new ToolkitError(`npm view command for current version failed: ${currentResult.stderr.trim()}`); + } - return latestVersion; + const latestVersion = latestResult.stdout; + const currentInfo = JSON.parse(currentResult.stdout); + + return { + latestVersion: latestVersion, + deprecated: currentInfo.deprecated, + }; + } catch (err: unknown) { + throw err; + } } /* c8 ignore stop */ diff --git a/packages/aws-cdk/lib/cli/version.ts b/packages/aws-cdk/lib/cli/version.ts index 66e65a397..e23320384 100644 --- a/packages/aws-cdk/lib/cli/version.ts +++ b/packages/aws-cdk/lib/cli/version.ts @@ -8,7 +8,7 @@ import { debug, info } from '../logging'; import { cdkCacheDir } from '../util'; import { cliRootDir } from './root-dir'; import { formatAsBanner } from './util/console-formatters'; -import { getLatestVersionFromNpm } from './util/npm'; +import { execNpmView } from './util/npm'; const ONE_DAY_IN_SECONDS = 1 * 24 * 60 * 60; @@ -84,20 +84,28 @@ export class VersionCheckTTL { // Export for unit testing only. // Don't use directly, use displayVersionMessage() instead. -export async function latestVersionIfHigher(currentVersion: string, cacheFile: VersionCheckTTL): Promise { +export async function getVersionMessages(currentVersion: string, cacheFile: VersionCheckTTL): Promise { if (!(await cacheFile.hasExpired())) { - return null; + return []; } - const latestVersion = await getLatestVersionFromNpm(); - const isNewer = semver.gt(latestVersion, currentVersion); - await cacheFile.update(latestVersion); + const packageInfo = await execNpmView(currentVersion); + const latestVersion = packageInfo.latestVersion; + await cacheFile.update(JSON.stringify(packageInfo)); - if (isNewer) { - return latestVersion; - } else { - return null; + // If the latest version is the same as the current version, there is no need to display a message + if (semver.eq(latestVersion, currentVersion)) { + return []; } + + const versionMessage = [ + packageInfo.deprecated ? `${chalk.red(packageInfo.deprecated as string)}` : undefined, + `Newer version of CDK is available [${chalk.green(latestVersion as string)}]`, + getMajorVersionUpgradeMessage(currentVersion), + 'Upgrade recommended (npm install -g aws-cdk)', + ].filter(Boolean) as string[]; + + return versionMessage; } function getMajorVersionUpgradeMessage(currentVersion: string): string | void { @@ -107,25 +115,14 @@ function getMajorVersionUpgradeMessage(currentVersion: string): string | void { } } -function getVersionMessage(currentVersion: string, laterVersion: string): string[] { - return [ - `Newer version of CDK is available [${chalk.green(laterVersion as string)}]`, - getMajorVersionUpgradeMessage(currentVersion), - 'Upgrade recommended (npm install -g aws-cdk)', - ].filter(Boolean) as string[]; -} - export async function displayVersionMessage(currentVersion = versionNumber(), versionCheckCache?: VersionCheckTTL): Promise { if (!process.stdout.isTTY || process.env.CDK_DISABLE_VERSION_CHECK) { return; } try { - const laterVersion = await latestVersionIfHigher(currentVersion, versionCheckCache ?? new VersionCheckTTL()); - if (laterVersion) { - const bannerMsg = formatAsBanner(getVersionMessage(currentVersion, laterVersion)); - bannerMsg.forEach((e) => info(e)); - } + const versionMessages = await getVersionMessages(currentVersion, versionCheckCache ?? new VersionCheckTTL()); + formatAsBanner(versionMessages).forEach(e => info(e)); } catch (err: any) { debug(`Could not run version check - ${err.message}`); } diff --git a/packages/aws-cdk/lib/legacy-exports-source.ts b/packages/aws-cdk/lib/legacy-exports-source.ts index 45cc81224..0c21809b7 100644 --- a/packages/aws-cdk/lib/legacy-exports-source.ts +++ b/packages/aws-cdk/lib/legacy-exports-source.ts @@ -24,7 +24,7 @@ export { cli, exec } from './cli/cli'; export { cliRootDir as rootDir } from './cli/root-dir'; export { Command, Configuration, PROJECT_CONTEXT } from './cli/user-configuration'; export { formatAsBanner } from './cli/util/console-formatters'; -export { latestVersionIfHigher, versionNumber } from './cli/version'; +export { versionNumber } from './cli/version'; // Commands export { RequireApproval } from './commands/diff'; diff --git a/packages/aws-cdk/lib/legacy-exports.ts b/packages/aws-cdk/lib/legacy-exports.ts index 9ca2731b6..1eb2572de 100644 --- a/packages/aws-cdk/lib/legacy-exports.ts +++ b/packages/aws-cdk/lib/legacy-exports.ts @@ -65,7 +65,6 @@ export const { deepMerge, Deployments, rootDir, - latestVersionIfHigher, versionNumber, availableInitTemplates, cached, diff --git a/packages/aws-cdk/test/cli/version.test.ts b/packages/aws-cdk/test/cli/version.test.ts index a13d945a0..bd6a1512a 100644 --- a/packages/aws-cdk/test/cli/version.test.ts +++ b/packages/aws-cdk/test/cli/version.test.ts @@ -1,16 +1,17 @@ /* eslint-disable import/order */ -import * as path from 'path'; -import { setTimeout as _setTimeout } from 'timers'; -import { promisify } from 'util'; import * as fs from 'fs-extra'; import * as os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; -import * as logging from '../../lib/logging'; +import { setTimeout as _setTimeout } from 'timers'; +import { promisify } from 'util'; import * as npm from '../../lib/cli/util/npm'; -import { latestVersionIfHigher, VersionCheckTTL, displayVersionMessage, isDeveloperBuild } from '../../lib/cli/version'; +import { displayVersionMessage, getVersionMessages, isDeveloperBuild, VersionCheckTTL } from '../../lib/cli/version'; +import * as logging from '../../lib/logging'; jest.setTimeout(10_000); + const setTimeout = promisify(_setTimeout); function tmpfile(): string { @@ -18,7 +19,6 @@ function tmpfile(): string { } beforeEach(() => { - jest.spyOn(npm, 'getLatestVersionFromNpm').mockRejectedValue(new Error('expected version cache to be used, but call to npm was attempted')); process.chdir(os.tmpdir()); // Need a chdir because in the workspace 'npm view' will take a long time }); @@ -49,25 +49,7 @@ test('cache file honours the specified TTL', async () => { test('Skip version check if cache has not expired', async () => { const cache = new VersionCheckTTL(tmpfile(), 100); await cache.update(); - expect(await latestVersionIfHigher('0.0.0', cache)).toBeNull(); -}); - -test('Return later version when exists & skip recent re-check', async () => { - const cache = new VersionCheckTTL(tmpfile(), 100); - jest.spyOn(npm, 'getLatestVersionFromNpm').mockResolvedValueOnce('1.1.0'); - const result = await latestVersionIfHigher('0.0.0', cache); - expect(result).not.toBeNull(); - expect((result as string).length).toBeGreaterThan(0); - - const result2 = await latestVersionIfHigher('0.0.0', cache); - expect(result2).toBeNull(); -}); - -test('Return null if version is higher than npm', async () => { - const cache = new VersionCheckTTL(tmpfile(), 100); - jest.spyOn(npm, 'getLatestVersionFromNpm').mockResolvedValueOnce('1.1.0'); - const result = await latestVersionIfHigher('100.100.100', cache); - expect(result).toBeNull(); + expect(await getVersionMessages('0.0.0', cache)).toEqual([]); }); test('Version specified is stored in the TTL file', async () => { @@ -104,45 +86,62 @@ describe('version message', () => { process.stdout.isTTY = previousIsTty; }); - test('Prints a message when a new version is available', async () => { - // Given the current version is 1.0.0 and the latest version is 1.1.0 - const currentVersion = '1.0.0'; - jest.spyOn(npm, 'getLatestVersionFromNpm').mockResolvedValueOnce('1.1.0'); - const printSpy = jest.spyOn(logging, 'info'); - - // When displayVersionMessage is called - await displayVersionMessage(currentVersion, new VersionCheckTTL(tmpfile(), 0)); - - // Then the new version message is printed to stdout - expect(printSpy).toHaveBeenCalledWith(expect.stringContaining('1.1.0')); - }); - - test('Includes major upgrade documentation when available', async() => { - // Given the current version is 1.0.0 and the latest version is 2.0.0 - const currentVersion = '1.0.0'; - jest.spyOn(npm, 'getLatestVersionFromNpm').mockResolvedValueOnce('2.0.0'); - const printSpy = jest.spyOn(logging, 'info'); - - // When displayVersionMessage is called - await displayVersionMessage(currentVersion, new VersionCheckTTL(tmpfile(), 0)); - - // Then the V1 -> V2 documentation is printed - expect(printSpy).toHaveBeenCalledWith(expect.stringContaining('Information about upgrading from version 1.x to version 2.x is available here: https://docs.aws.amazon.com/cdk/v2/guide/migrating-v2.html')); - }); - - test('Does not include major upgrade documentation when unavailable', async() => { - // Given current version is 99.0.0 and the latest version is 100.0.0 - const currentVersion = '99.0.0'; - jest.spyOn(npm, 'getLatestVersionFromNpm').mockResolvedValueOnce('100.0.0'); - const printSpy = jest.spyOn(logging, 'info'); - - // When displayVersionMessage is called - await displayVersionMessage(currentVersion, new VersionCheckTTL(tmpfile(), 0)); - - // Then no upgrade documentation is printed - expect(printSpy).toHaveBeenCalledWith(expect.stringContaining('100.0.0')); - expect(printSpy).not.toHaveBeenCalledWith(expect.stringContaining('Information about upgrading from 99.x to 100.x')); - }); + test('Prints messages when a new version is available', async () => { + const mockCache = new VersionCheckTTL(tmpfile()); + jest.spyOn(mockCache, 'hasExpired').mockResolvedValue(true); + + jest.spyOn(npm, 'execNpmView').mockResolvedValue({ + latestVersion: '2.0.0', + deprecated: undefined, + }); + + const messages = await getVersionMessages('1.0.0', mockCache); + expect(messages.some(msg => msg.includes('Newer version of CDK is available'))).toBeTruthy(); + expect(messages.some(msg => msg.includes('Information about upgrading from version 1.x to version 2.x'))).toBeTruthy(); + expect(messages.some(msg => msg.includes('Upgrade recommended (npm install -g aws-cdk)'))).toBeTruthy(); + }) + + test('Does not include major upgrade documentation when unavailable', async () => { + const mockCache = new VersionCheckTTL(tmpfile()); + jest.spyOn(mockCache, 'hasExpired').mockResolvedValue(true); + + jest.spyOn(npm, 'execNpmView').mockResolvedValue({ + latestVersion: '2.1000.0', + deprecated: undefined, + }); + + const messages = await getVersionMessages('2.179.0', mockCache); + const hasUpgradeDoc = messages.some(msg => + msg.includes('Information about upgrading from version 1.x to version 2.x') + ); + expect(hasUpgradeDoc).toBeFalsy(); + }) + + test('Prints a message when a deprecated version is used', async () => { + const mockCache = new VersionCheckTTL(tmpfile()); + jest.spyOn(mockCache, 'hasExpired').mockResolvedValue(true); + + jest.spyOn(npm, 'execNpmView').mockResolvedValue({ + latestVersion: '2.0.0', + deprecated: 'This version is deprecated.', + }); + + const messages = await getVersionMessages('1.0.0', mockCache); + expect(messages.some(msg => msg.includes('This version is deprecated'))).toBeTruthy(); + }) + + test('Does not print message when current version is up to date', async () => { + const mockCache = new VersionCheckTTL(tmpfile()); + jest.spyOn(mockCache, 'hasExpired').mockResolvedValue(true); + + jest.spyOn(npm, 'execNpmView').mockResolvedValue({ + latestVersion: '1.0.0', + deprecated: undefined, + }); + + const messages = await getVersionMessages('1.0.0', mockCache); + expect(messages).toEqual([]); + }) }); test('isDeveloperBuild call does not throw an error', () => {