From 8db8ad9b2019cdfd506a094833589bf3197c24f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Mon, 25 Jun 2018 14:23:49 -0700 Subject: [PATCH] Start re-factoring the CLI codebase (#177) Breaking down the monolithic CLI infrastructure by using `yarg`'s `commandDir` directive (see #176). This allows modelling each command in a separate module for a cleaner interface. Migrated the `docs` command, and created a prototype of a `doctor` command (see #154). The new commands also have basic unit tests that verify the handlers honor their promises. --- packages/aws-cdk/bin/cdk.ts | 52 +++++++++----------- packages/aws-cdk/lib/commands/docs.ts | 45 ++++++++++++++++++ packages/aws-cdk/lib/commands/doctor.ts | 55 ++++++++++++++++++++++ packages/aws-cdk/package-lock.json | 10 ++++ packages/aws-cdk/package.json | 5 +- packages/aws-cdk/test/test.cdk-docs.ts | 60 ++++++++++++++++++++++++ packages/aws-cdk/test/test.cdk-doctor.ts | 56 ++++++++++++++++++++++ 7 files changed, 251 insertions(+), 32 deletions(-) create mode 100644 packages/aws-cdk/lib/commands/docs.ts create mode 100644 packages/aws-cdk/lib/commands/doctor.ts create mode 100644 packages/aws-cdk/test/test.cdk-docs.ts create mode 100644 packages/aws-cdk/test/test.cdk-doctor.ts diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 4d1d2f3593949..db49e3685f781 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -6,7 +6,7 @@ import '../lib/api/util/sdk-load-aws-config'; import * as cxapi from '@aws-cdk/cx-api'; import { deepMerge, isEmpty, partition } from '@aws-cdk/util'; -import { exec, spawn } from 'child_process'; +import { spawn } from 'child_process'; import { blue, green } from 'colors/safe'; import * as fs from 'fs-extra'; import * as minimatch from 'minimatch'; @@ -50,10 +50,6 @@ async function parseCommandLineArguments() { .option('json', { type: 'boolean', alias: 'j', desc: 'Use JSON output instead of YAML' }) .option('verbose', { type: 'boolean', alias: 'v', desc: 'Show debug logs' }) .demandCommand(1) - .command('docs', 'Opens the documentation in a browser', yargs => yargs - // tslint:disable-next-line:max-line-length - .option('browser', { type: 'string', alias: 'b', desc: 'the command to use to open the browser, using %u as a placeholder for the path of the file to open', - default: process.platform === 'win32' ? 'start %u' : 'open %u' })) .command('list', 'Lists all stacks in the cloud executable (alias: ls)') // tslint:disable-next-line:max-line-length .command('synth [STACKS..]', 'Synthesizes and prints the cloud formation template for this stack (alias: synthesize, construct, cons)', yargs => yargs @@ -73,6 +69,7 @@ async function parseCommandLineArguments() { // tslint:disable-next-line:max-line-length .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: /^_.*/, visit: decorateCommand }) .version(VERSION) .epilogue([ 'If your app has a single stack, there is no need to specify the stack name', @@ -82,6 +79,25 @@ async function parseCommandLineArguments() { } // tslint:enable:no-shadowed-variable +/** + * Decorates commands discovered by ``yargs.commandDir`` in order to apply global + * options as appropriate. + * + * @param commandObject is the command to be decorated. + * @returns a decorated ``CommandModule``. + */ +function decorateCommand(commandObject: yargs.CommandModule): yargs.CommandModule { + return { + ...commandObject, + handler(args: yargs.Arguments) { + if (args.verbose) { + setVerbose(); + } + return commandObject.handler(args); + } + }; +} + async function initCommandLine() { const argv = await parseCommandLineArguments(); if (argv.verbose) { @@ -148,9 +164,6 @@ async function initCommandLine() { const toolkitStackName = completeConfig().get(['toolkitStackName']) || DEFAULT_TOOLKIT_STACK_NAME; switch (command) { - case 'docs': - return await openDocsite(completeConfig().get(['browser'])); - case 'ls': case 'list': return await listStacks(); @@ -217,29 +230,6 @@ async function initCommandLine() { return found; } - async function openDocsite(commandTemplate: string): Promise { - let documentationIndexPath: string; - try { - // tslint:disable-next-line:no-var-require Taking an un-declared dep on aws-cdk-docs, to avoid a dependency circle - const docs = require('aws-cdk-docs'); - documentationIndexPath = docs.documentationIndexPath; - } catch (err) { - error('Unable to open CDK documentation - the aws-cdk-docs package appears to be missing. Please run `npm install -g aws-cdk-docs`'); - return -1; - } - - const browserCommand = commandTemplate.replace(/%u/g, documentationIndexPath); - debug(`Opening documentation ${green(browserCommand)}`); - return await new Promise((resolve, reject) => { - exec(browserCommand, (err, stdout, stderr) => { - if (err) { return reject(err); } - if (stdout) { debug(stdout); } - if (stderr) { warning(stderr); } - resolve(0); - }); - }); - } - /** * Bootstrap the CDK Toolkit stack in the accounts used by the specified stack(s). * diff --git a/packages/aws-cdk/lib/commands/docs.ts b/packages/aws-cdk/lib/commands/docs.ts new file mode 100644 index 0000000000000..bdfc42058f166 --- /dev/null +++ b/packages/aws-cdk/lib/commands/docs.ts @@ -0,0 +1,45 @@ +import { exec } from 'child_process'; +import { green } from 'colors/safe'; +import * as process from 'process'; +import * as yargs from 'yargs'; +import { debug, error, warning } from '../../lib/logging'; + +export const command = 'docs'; +export const describe = 'Opens the documentation in a browser'; +export const aliases = ['doc']; +export const builder = { + browser: { + alias: 'b', + desc: 'the command to use to open the browser, using %u as a placeholder for the path of the file to open', + type: 'string', + default: process.platform === 'win32' ? 'start %u' : 'open %u' + } +}; + +export interface Arguments extends yargs.Arguments { + browser: string +} + +export async function handler(argv: Arguments) { + let documentationIndexPath: string; + try { + // tslint:disable-next-line:no-var-require Taking an un-declared dep on aws-cdk-docs, to avoid a dependency circle + const docs = require('aws-cdk-docs'); + documentationIndexPath = docs.documentationIndexPath; + } catch (err) { + error('Unable to open CDK documentation - the aws-cdk-docs package appears to be missing. Please run `npm install -g aws-cdk-docs`'); + process.exit(-1); + return; + } + + const browserCommand = argv.browser.replace(/%u/g, documentationIndexPath); + debug(`Opening documentation ${green(browserCommand)}`); + process.exit(await new Promise((resolve, reject) => { + exec(browserCommand, (err, stdout, stderr) => { + if (err) { return reject(err); } + if (stdout) { debug(stdout); } + if (stderr) { warning(stderr); } + resolve(0); + }); + })); +} diff --git a/packages/aws-cdk/lib/commands/doctor.ts b/packages/aws-cdk/lib/commands/doctor.ts new file mode 100644 index 0000000000000..b53d3cddcdba2 --- /dev/null +++ b/packages/aws-cdk/lib/commands/doctor.ts @@ -0,0 +1,55 @@ +import { blue, green } from 'colors/safe'; +import * as process from 'process'; +import { print } from '../../lib/logging'; +import { VERSION } from '../../lib/version'; + +export const command = 'doctor'; +export const describe = 'Check your set-up for potential problems'; +export const builder = {}; + +export async function handler(): Promise { + let exitStatus: number = 0; + for (const verification of verifications) { + if (!await verification()) { + exitStatus = -1; + } + } + return process.exit(exitStatus); +} + +const verifications: Array<() => boolean | Promise> = [ + displayVersionInformation, + displayAwsEnvironmentVariables, + checkDocumentationIsAvailable +]; + +// ### Verifications ### + +function displayVersionInformation() { + print(`ℹ️ CDK Version: ${green(VERSION)}`); + return true; +} + +function displayAwsEnvironmentVariables() { + const keys = Object.keys(process.env).filter(s => s.startsWith('AWS_')); + if (keys.length === 0) { + print('ℹ️ No AWS environment variables'); + return true; + } + print('ℹ️ AWS environment variables:'); + for (const key of keys) { + print(` - ${blue(key)} = ${green(process.env[key]!)}`); + } + return true; +} + +function checkDocumentationIsAvailable() { + try { + const version = require('aws-cdk-docs/package.json').version; + print(`✅ AWS CDK Documentation: ${version}`); + return true; + } catch (e) { + print(`❌ AWS CDK Documentation: install using ${green('y-npm install --global aws-cdk-docs')}`); + return false; + } +} diff --git a/packages/aws-cdk/package-lock.json b/packages/aws-cdk/package-lock.json index 156c39ac9c2ca..4427cef0316e9 100644 --- a/packages/aws-cdk/package-lock.json +++ b/packages/aws-cdk/package-lock.json @@ -28,6 +28,11 @@ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==" }, + "@types/mockery": { + "version": "1.4.29", + "resolved": "https://registry.npmjs.org/@types/mockery/-/mockery-1.4.29.tgz", + "integrity": "sha1-m6It838H43gP/4Ux0aOOYz+UV6U=" + }, "@types/node": { "version": "8.10.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.17.tgz", @@ -670,6 +675,11 @@ "brace-expansion": "^1.1.7" } }, + "mockery": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mockery/-/mockery-2.1.0.tgz", + "integrity": "sha512-9VkOmxKlWXoDO/h1jDZaS4lH33aWfRiJiNT/tKj+8OGzrcFDLo8d0syGdbsc3Bc4GvRXPb+NMMvojotmuGJTvA==" + }, "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index b467a704111bb..e8ced9a0aad7f 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -11,7 +11,8 @@ "prepare": "/bin/bash generate.sh && tslint -p . && tsc && chmod +x bin/cdk && pkglint", "watch": "tsc -w", "lint": "tsc && tslint -p . --force", - "pkglint": "pkglint -f" + "pkglint": "pkglint -f", + "test": "nodeunit test/test.*.js" }, "author": { "name": "Amazon Web Services", @@ -21,10 +22,12 @@ "devDependencies": { "@types/fs-extra": "^4.0.8", "@types/minimatch": "^3.0.3", + "@types/mockery": "^1.4.29", "@types/request": "^2.47.1", "@types/uuid": "^3.4.3", "@types/yamljs": "^0.2.0", "@types/yargs": "^8.0.3", + "mockery": "^2.1.0", "pkglint": "^0.7.1" }, "dependencies": { diff --git a/packages/aws-cdk/test/test.cdk-docs.ts b/packages/aws-cdk/test/test.cdk-docs.ts new file mode 100644 index 0000000000000..8f477327d6a4b --- /dev/null +++ b/packages/aws-cdk/test/test.cdk-docs.ts @@ -0,0 +1,60 @@ +import * as mockery from 'mockery'; +import { ICallbackFunction, Test, testCase } from 'nodeunit'; + +let exitCalled: boolean = false; +let exitStatus: undefined | number; +function fakeExit(status?: number) { + exitCalled = true; + exitStatus = status; +} + +const argv = { browser: 'echo %u' }; + +module.exports = testCase({ + '`cdk docs`': { + 'setUp'(cb: ICallbackFunction) { + exitCalled = false; + exitStatus = undefined; + mockery.registerMock('../../lib/logging', { + debug() { return; }, + error() { return; }, + warning() { return; } + }); + mockery.enable({ useCleanCache: true, warnOnReplace: true, warnOnUnregistered: false }); + cb(); + }, + 'tearDown'(cb: ICallbackFunction) { + mockery.disable(); + mockery.deregisterAll(); + + cb(); + }, + async 'exits with 0 when everything is OK'(test: Test) { + mockery.registerMock('process', { ...process, exit: fakeExit }); + mockery.registerMock('aws-cdk-docs', { documentationIndexPath: 'index.html' }); + + try { + await require('../lib/commands/docs').handler(argv); + test.ok(exitCalled, 'process.exit() was called'); + test.equal(exitStatus, 0, 'exit status was 0'); + } catch (e) { + test.ifError(e); + } finally { + test.done(); + } + }, + async 'exits with non-0 when documentation is missing'(test: Test) { + mockery.registerMock('process', { ...process, exit: fakeExit }); + + try { + await require('../lib/commands/docs').handler(argv); + test.ok(exitCalled, 'process.exit() was called'); + test.notEqual(exitStatus, 0, 'exit status was non-0'); + } catch (e) { + test.ifError(e); + } finally { + test.done(); + } + } + } +}); diff --git a/packages/aws-cdk/test/test.cdk-doctor.ts b/packages/aws-cdk/test/test.cdk-doctor.ts new file mode 100644 index 0000000000000..43e0a7d6a987a --- /dev/null +++ b/packages/aws-cdk/test/test.cdk-doctor.ts @@ -0,0 +1,56 @@ +import * as mockery from 'mockery'; +import { ICallbackFunction, Test, testCase } from 'nodeunit'; + +let exitCalled: boolean = false; +let exitStatus: undefined | number; +function fakeExit(status?: number) { + exitCalled = true; + exitStatus = status; +} + +module.exports = testCase({ + '`cdk doctor`': { + 'setUp'(cb: ICallbackFunction) { + exitCalled = false; + exitStatus = undefined; + mockery.registerMock('../../lib/logging', { + print: () => undefined + }); + mockery.enable({ useCleanCache: true, warnOnReplace: true, warnOnUnregistered: false }); + cb(); + }, + 'tearDown'(cb: ICallbackFunction) { + mockery.disable(); + mockery.deregisterAll(); + + cb(); + }, + async 'exits with 0 when everything is OK'(test: Test) { + mockery.registerMock('process', { ...process, exit: fakeExit }); + mockery.registerMock('aws-cdk-docs/package.json', { version: 'x.y.z' }); + + try { + await require('../lib/commands/doctor').handler(); + test.ok(exitCalled, 'process.exit() was called'); + test.equal(exitStatus, 0, 'exit status was 0'); + } catch (e) { + test.ifError(e); + } finally { + test.done(); + } + }, + async 'exits with non-0 when documentation is missing'(test: Test) { + mockery.registerMock('process', { ...process, exit: fakeExit }); + + try { + await require('../lib/commands/doctor').handler(); + test.ok(exitCalled, 'process.exit() was called'); + test.notEqual(exitStatus, 0, 'exit status was non-0'); + } catch (e) { + test.ifError(e); + } finally { + test.done(); + } + } + } +});