From 929af074ac560048ccac91823f3578674efd819e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 9 Jan 2018 15:58:15 +0100 Subject: [PATCH] feat(cli): lb4 example [] Implement a new CLI command for cloning an example project from our monorepo. Usage: lb4 example [options] [] Arguments: example-name # Name of the example to clone Type: String Required: false Available examples: codehub: A GitHub-like application we used to use to model LB4 API. The command downloads current master branch as a ZIP file from GitHub to a temp file, creates a local directory called `loopback4-example-${name}` and extracts all files from `packages/example-${name}` to this new directory. --- packages/cli/.gitignore | 3 +- packages/cli/bin/cli.js | 4 + .../cli/generators/example/clone-example.js | 69 +++++++++++++ packages/cli/generators/example/index.js | 98 +++++++++++++++++++ packages/cli/lib/promisify.js | 33 +++++++ packages/cli/package.json | 5 + packages/cli/test/clone-example.test.js | 48 +++++++++ packages/cli/test/example.test.js | 90 +++++++++++++++++ packages/cli/test/test-utils.js | 29 ++++++ packages/core/src/promisify.ts | 5 +- 10 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 packages/cli/generators/example/clone-example.js create mode 100644 packages/cli/generators/example/index.js create mode 100644 packages/cli/lib/promisify.js create mode 100644 packages/cli/test/clone-example.test.js create mode 100644 packages/cli/test/example.test.js diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index ba2a97b57aca..279e02706dd3 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -1,2 +1,3 @@ -node_modules coverage +node_modules +test/sandbox diff --git a/packages/cli/bin/cli.js b/packages/cli/bin/cli.js index e1b5abd39af5..ee637fd119f3 100755 --- a/packages/cli/bin/cli.js +++ b/packages/cli/bin/cli.js @@ -37,6 +37,10 @@ env.register( path.join(__dirname, '../generators/controller'), 'loopback4:controller' ); +env.register( + path.join(__dirname, '../generators/example'), + 'loopback4:example' +); // list generators if (opts.commands) { diff --git a/packages/cli/generators/example/clone-example.js b/packages/cli/generators/example/clone-example.js new file mode 100644 index 000000000000..4bc3ab1dbf60 --- /dev/null +++ b/packages/cli/generators/example/clone-example.js @@ -0,0 +1,69 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const promisify = require('../../lib/promisify'); + +const gunzip = require('gunzip-maybe'); +const path = require('path'); +const request = require('request'); +const tar = require('tar-fs'); + +const GITHUB_ARCHIVE_URL = + 'https://github.com/strongloop/loopback-next/tarball/master'; + +module.exports = function cloneExampleFromGitHub(exampleName, cwd) { + const outDir = path.join(cwd, `loopback4-example-${exampleName}`); + + return new Promise((resolve, reject) => { + request(GITHUB_ARCHIVE_URL) + .pipe(gunzip()) + .pipe(untar(outDir, exampleName)) + .on('error', reject) + .on('finish', () => resolve(outDir)); + }); +}; + +function untar(outDir, exampleName) { + // The top directory is in the format "{org}-{repo}-{sha1}" + // I am intentionally not matching an exact repository name, because I expect + // it will change in the future, e.g. from "loopback-next" to "loopback4". + // I am also assuming that example names never contain special RegExp + // characters. + const matchTopDir = /^strongloop-loopback[^\/]*\//; + + const sourceDir = `packages/example-${exampleName}/`; + + // Unfortunately the tar-fs is designed in such way that "map" is called + // before "ignore" and there is no way how "map" can mark an entry for + // skipping. + // As a workaround, we are renaming all entries we want to ignore to a file name + // containing this placeholder value. The value is crafted in such way + // that the probability of a conflict with a real file in LoopBack repo + // is minimal. + const DISCARD_THIS_ENTRY = 'IGNORE_THIS_ENTRY_1B6DAPkxt3'; + + const DISCARD_ABSOLUTE_PATH = path.resolve(outDir, DISCARD_THIS_ENTRY); + const tarOptions = { + map: header => { + // Remove the top dir like "strongloop-loopback-next-a50405a" + let name = header.name.replace(matchTopDir, ''); + + // Remove "packages/example-{name}" of files we want to keep, + // rename the entry to a special value for files we want to discard. + header.name = name.startsWith(sourceDir) + ? name.slice(sourceDir.length) + : DISCARD_THIS_ENTRY; + + return header; + }, + + // Ignore files outside of our example package + ignore: absolutePath => absolutePath === DISCARD_ABSOLUTE_PATH, + }; + + return tar.extract(outDir, tarOptions); +} diff --git a/packages/cli/generators/example/index.js b/packages/cli/generators/example/index.js new file mode 100644 index 000000000000..5ba3ab184106 --- /dev/null +++ b/packages/cli/generators/example/index.js @@ -0,0 +1,98 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +('use strict'); + +const BaseGenerator = require('../../lib/base-generator'); +const chalk = require('chalk'); +const cloneExampleFromGitHub = require('./clone-example'); +const path = require('path'); +const utils = require('../../lib/utils'); + +const EXAMPLES = { + codehub: 'A GitHub-like application we used to use to model LB4 API.', +}; +Object.freeze(EXAMPLES); + +module.exports = class extends BaseGenerator { + static getAllExamples() { + return EXAMPLES; + } + + // Note: arguments and options should be defined in the constructor. + constructor(args, opts) { + super(args, opts); + } + + _setupGenerator() { + this.projectType = 'example'; + this.argument('example-name', { + type: String, + description: 'Name of the example to clone', + required: false, + }); + + return super._setupGenerator(); + } + + help() { + const examplesHelp = Object.keys(EXAMPLES) + .map(name => ` ${name}: ${EXAMPLES[name]}`) + .join('\n'); + + return super.help() + `\nAvailable examples:\n${examplesHelp}\n`; + } + + _describeExamples() {} + + promptExampleName() { + if (this.options['example-name']) { + this.exampleName = this.options['example-name']; + return; + } + + const choices = Object.keys(EXAMPLES).map(k => { + return { + name: `${k}: ${EXAMPLES[k]}`, + value: `${k}`, + short: `${k}`, + }; + }); + const prompts = [ + { + name: 'name', + message: 'What example would you like to clone?', + type: 'list', + choices, + }, + ]; + return this.prompt(prompts).then( + answers => (this.exampleName = answers.name) + ); + } + + validateExampleName() { + if (this.exampleName in EXAMPLES) return; + this.exit( + `Invalid example name: ${this.exampleName}\n` + + 'Run "lb4 example --help" to print the list of available example names.' + ); + } + + cloneExampleFromGitHub() { + if (this.shouldExit()) return false; + const cwd = process.cwd(); + return cloneExampleFromGitHub(this.exampleName, cwd).then(o => { + this.outDir = path.relative(cwd, o); + }); + } + + end() { + if (!super.end()) return false; + this.log(); + this.log(`The example was cloned to ${chalk.green(this.outDir)}.`); + this.log(); + } +}; diff --git a/packages/cli/lib/promisify.js b/packages/cli/lib/promisify.js new file mode 100644 index 000000000000..ea14bda00bc6 --- /dev/null +++ b/packages/cli/lib/promisify.js @@ -0,0 +1,33 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// A temporary polyfill for util.promisify on Node.js 6.x +// Remove it as part of https://github.com/strongloop/loopback-next/issues/611 + +'use strict'; + +const nativePromisify = require('util').promisify; + +/** + * Polyfill promisify and use `util.promisify` if available + * @param func A callback-style function + */ +module.exports = function promisify(func) { + if (nativePromisify) return nativePromisify(func); + + // The simplest implementation of Promisify + return (...args) => { + return new Promise((resolve, reject) => { + try { + func(...args, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + } catch (err) { + reject(err); + } + }); + }; +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index d2746902efa8..774f375bad3a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,10 +26,12 @@ "devDependencies": { "@loopback/testlab": "^4.0.0-alpha.18", "eslint-config-google": "^0.9.1", + "glob": "^7.1.2", "mem-fs": "^1.1.3", "mem-fs-editor": "^3.0.2", "mocha": "^4.0.1", "nsp": "^3.1.0", + "rimraf": "^2.6.2", "sinon": "^4.1.2", "yeoman-assert": "^3.1.0", "yeoman-test": "^1.7.0" @@ -39,9 +41,12 @@ "chalk": "^2.3.0", "change-case": "^3.0.1", "debug": "^3.1.0", + "gunzip-maybe": "^1.4.1", "lodash": "^4.17.4", "minimist": "^1.2.0", "regenerate": "^1.3.3", + "request": "^2.83.0", + "tar-fs": "^1.16.0", "unicode-10.0.0": "^0.7.4", "validate-npm-package-name": "^3.0.0", "yeoman-generator": "^2.0.1" diff --git a/packages/cli/test/clone-example.test.js b/packages/cli/test/clone-example.test.js new file mode 100644 index 000000000000..96555b6f7cf1 --- /dev/null +++ b/packages/cli/test/clone-example.test.js @@ -0,0 +1,48 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const promisify = require('../lib/promisify'); + +const cloneExampleFromGitHub = require('../generators/example/clone-example'); +const expect = require('@loopback/testlab').expect; +const fs = require('fs'); +const glob = promisify(require('glob')); +const path = require('path'); +const rimraf = promisify(require('rimraf')); + +const VALID_EXAMPLE = 'codehub'; +const SANDBOX = path.resolve(__dirname, 'sandbox'); + +describe('cloneExampleFromGitHub', function() { + this.timeout(10000); + + beforeEach(resetSandbox); + + it('extracts all project files', () => { + return cloneExampleFromGitHub(VALID_EXAMPLE, SANDBOX) + .then(outDir => { + return Promise.all([ + glob('**', { + cwd: path.join(__dirname, `../../example-${VALID_EXAMPLE}`), + ignore: 'node_modules/**', + }), + glob('**', { + cwd: outDir, + ignore: 'node_modules/**', + }), + ]); + }) + .then(found => { + const [expected, actual] = found; + expect(actual).to.deepEqual(expected); + }); + }); + + function resetSandbox() { + return rimraf(SANDBOX); + } +}); diff --git a/packages/cli/test/example.test.js b/packages/cli/test/example.test.js new file mode 100644 index 000000000000..27d8dca5a36e --- /dev/null +++ b/packages/cli/test/example.test.js @@ -0,0 +1,90 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const assert = require('yeoman-assert'); +const fs = require('fs'); +const expect = require('@loopback/testlab').expect; +const path = require('path'); + +const generator = path.join(__dirname, '../generators/example'); +const baseTests = require('./base-generator')(generator); +const testUtils = require('./test-utils'); + +const ALL_EXAMPLES = require(generator).getAllExamples(); +const VALID_EXAMPLE = 'codehub'; + +describe('lb4 example', function() { + this.timeout(10000); + + describe('correctly extends BaseGenerator', baseTests); + + describe('_setupGenerator', () => { + it('has name argument set up', () => { + const helpText = getHelpText(); + expect(helpText) + .to.match(/\[\]/) + .and.match(/# Name of the example/) + .and.match(/Type: String/) + .and.match(/Required: false/); + }); + + it('lists all example names in help', () => { + const helpText = getHelpText(); + expect(helpText).to.match( + new RegExp(Object.keys(ALL_EXAMPLES).join('.*')) + ); + }); + + function getHelpText() { + return testUtils.testSetUpGen(generator).help(); + } + }); + + it('accepts the example name via interactive prompt', () => { + return testUtils + .executeGenerator(generator) + .withPrompts({name: VALID_EXAMPLE}) + .then(() => { + const targetPkgFile = `loopback4-example-${VALID_EXAMPLE}/package.json`; + const originalPkgMeta = require('../../example-codehub/package.json'); + assert.file(targetPkgFile); + assert.jsonFileContent(targetPkgFile, { + name: originalPkgMeta.name, + version: originalPkgMeta.version, + }); + }); + }); + + it('accepts the example name as a CLI argument', () => { + return testUtils + .executeGenerator(generator) + .withArguments([VALID_EXAMPLE]) + .then(() => { + const targetPkgFile = `loopback4-example-${VALID_EXAMPLE}/package.json`; + const originalPkgMeta = require('../../example-codehub/package.json'); + assert.file(targetPkgFile); + assert.jsonFileContent(targetPkgFile, { + name: originalPkgMeta.name, + version: originalPkgMeta.version, + }); + }); + }); + + it('rejects invalid example names', () => { + return testUtils + .executeGenerator(generator) + .withArguments(['example-does-not-exist']) + .then( + () => { + throw new Error('Generator should have failed.'); + }, + err => { + expect(err).to.match(/Invalid example name/); + } + ); + }); +}); diff --git a/packages/cli/test/test-utils.js b/packages/cli/test/test-utils.js index cf362194e360..5ba386ada440 100644 --- a/packages/cli/test/test-utils.js +++ b/packages/cli/test/test-utils.js @@ -7,6 +7,7 @@ const yeoman = require('yeoman-environment'); const path = require('path'); +const helpers = require('yeoman-test'); exports.testSetUpGen = function(genName, arg) { arg = arg || {}; @@ -15,3 +16,31 @@ exports.testSetUpGen = function(genName, arg) { env.register(genName, 'loopback4:' + name); return env.create('loopback4:' + name, arg); }; + +/** + * Execute the generator via yeoman-test's run() helper, + * detect exitGeneration flag and convert it into promise rejection. + * + * @param {string} GeneratorOrNamespace + * @param {object} [settings] + */ +exports.executeGenerator = function(GeneratorOrNamespace, settings) { + const runner = helpers.run(GeneratorOrNamespace, settings); + + // Override .then() and .catch() methods to detect our custom + // "exit with error" handling + runner.toPromise = function() { + return new Promise((resolve, reject) => { + this.on('end', () => { + if (this.generator.exitGeneration) { + reject(this.generator.exitGeneration); + } else { + resolve(this.targetDirectory); + } + }); + this.on('error', reject); + }); + }; + + return runner; +}; diff --git a/packages/core/src/promisify.ts b/packages/core/src/promisify.ts index 3bc75fcac352..d64984548379 100644 --- a/packages/core/src/promisify.ts +++ b/packages/core/src/promisify.ts @@ -3,9 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -// TODO(bajtos) Move this file to a standalone module, or find an existing -// npm module that we could use instead. Just make sure the existing -// module is using native utils.promisify() when available. +// A temporary polyfill for util.promisify on Node.js 6.x +// Remove it as part of https://github.com/strongloop/loopback-next/issues/611 // tslint:disable:no-any