Repository: opencomponents/oc
Pull request #872 (2018-05-04 15:54:10)

[DX-497] OC Clean
This introduces an handy oc clean command, pretty useful with monorepos of OCs that now contain templates and subdeps. The functionality is pretty similar to lerna's clean.


Patch:
diff --git a/src/cli/commands.js b/src/cli/commands.js
index 2f426428..3181e036 100644
--- a/src/cli/commands.js
+++ b/src/cli/commands.js
@@ -3,6 +3,20 @@
 module.exports = {
   usage: 'Usage: $0 <command> [options]',
   commands: {
+    clean: {
+      cmd: 'clean <dirPath>',
+      example: { cmd: '$0 clean components --yes' },
+      description: `Remove the node_modules directory from each component's subfolder`,
+      options: {
+        yes: {
+          boolean: true,
+          description: 'Skip all confirmation prompts',
+          default: false
+        }
+      },
+      usage: 'Usage: $0 clean <dirPath>'
+    },
+
     dev: {
       cmd: 'dev <dirPath> [port] [baseUrl]',
       example: {
@@ -10,7 +24,7 @@ module.exports = {
           '$0 dev ../all-components 3001 127.0.0.1:3001 --fallbackRegistryUrl=http://anotherhost:anotherport/'
       },
       description:
-        'Runs a local oc test registry in order to develop and test components',
+        'Run a local oc test registry in order to develop and test components',
       options: {
         fallbackRegistryUrl: {
           description:
@@ -19,7 +33,7 @@ module.exports = {
         hotReloading: {
           boolean: true,
           description:
-            'Enables hot reloading. Note: when hot reloading is set to true, each request to the component will make the registry to create a new instance for the javascript closures to be loaded, while when false the instance will be recycled between components executions',
+            'Enable hot reloading. Note: when hot reloading is set to true, each request to the component will make the registry to create a new instance for the javascript closures to be loaded, while when false the instance will be recycled between components executions',
           default: true
         },
         verbose: {
@@ -43,7 +57,7 @@ module.exports = {
           default: ''
         }
       },
-      usage: 'Usage: $0 dev <dirName> [port] [baseUrl] [options]'
+      usage: 'Usage: $0 dev <dirPath> [port] [baseUrl] [options]'
     },
 
     init: {
@@ -52,7 +66,7 @@ module.exports = {
         cmd: '$0 init test-component oc-template-es6'
       },
       description:
-        'Creates a new empty component of a specific template type in the current folder [templateType default: oc-template-es6]',
+        'Create a new empty component of a specific template type in the current folder [templateType default: oc-template-es6]',
       usage: 'Usage: $0 init <componentName> [templateType]'
     },
 
@@ -61,7 +75,7 @@ module.exports = {
       example: {
         cmd: '$0 mock plugin hash "always-returned-value"',
         description:
-          'Creates static mock for a "hash" plugin which always returns "always-returned-value" value'
+          'Create static mock for a "hash" plugin which always returns "always-returned-value" value'
       },
       description:
         'Allows to mock configuration in order to facilitate local development',
@@ -74,7 +88,7 @@ module.exports = {
         cmd:
           '$0 preview "http://localhost:3000/my-new-component/1.0.0/?param1=hello&name=Arthur"'
       },
-      description: 'Runs a test page consuming a component',
+      description: 'Run a test page consuming a component',
       usage: 'Usage: $0 preview <componentHref>'
     },
 
@@ -90,7 +104,7 @@ module.exports = {
           default: false
         }
       },
-      description: 'Creates the packaged component ready to be published',
+      description: 'Create the packaged component ready to be published',
       usage: 'Usage: $0 package <componentPath>'
     },
 
@@ -115,21 +129,21 @@ module.exports = {
 
     registry: {
       cmd: 'registry <command>',
-      description: 'Manages oc registries in the current project',
+      description: 'Manage oc registries in the current project',
       commands: {
         add: {
           cmd: 'add <registryUrl>',
           example: {
             cmd: '$0 registry add http://my-registry.in.my.domain/'
           },
-          description: 'Adds oc registries to the current project',
+          description: 'Add oc registries to the current project',
           usage: 'Usage: $0 registry add <registryUrl>'
         },
         ls: {
           example: {
             cmd: '$0 registry ls'
           },
-          description: 'Shows oc registries added to the current project',
+          description: 'Show oc registries added to the current project',
           usage: 'Usage: $0 registry ls'
         },
         remove: {
@@ -137,7 +151,7 @@ module.exports = {
           example: {
             cmd: '$0 registry remove http://my-registry.in.my.domain/'
           },
-          description: 'Removes oc registries from the current project',
+          description: 'Remove oc registries from the current project',
           usage: 'Usage: $0 registry remove <registryUrl>'
         }
       },
diff --git a/src/cli/domain/clean.js b/src/cli/domain/clean.js
new file mode 100644
index 00000000..b8a3ac56
--- /dev/null
+++ b/src/cli/domain/clean.js
@@ -0,0 +1,20 @@
+const async = require('async');
+const fs = require('fs-extra');
+const path = require('path');
+
+const getComponentsByDir = require('./get-components-by-dir')();
+
+module.exports = {
+  fetchList: (dirPath, callback) =>
+    getComponentsByDir(dirPath, (err, list) => {
+      if (err) return callback(err);
+      if (list.length === 0) return callback(null, []);
+
+      const toRemove = list.map(folder => path.join(folder, 'node_modules'));
+      const folderExists = (folder, cb) =>
+        fs.exists(folder, exists => cb(null, exists));
+
+      async.filterSeries(toRemove, folderExists, callback);
+    }),
+  remove: (list, callback) => async.eachSeries(list, fs.remove, callback)
+};
diff --git a/src/cli/domain/local.js b/src/cli/domain/local.js
index 623e0277..505a6149 100644
--- a/src/cli/domain/local.js
+++ b/src/cli/domain/local.js
@@ -5,6 +5,7 @@ const path = require('path');
 const targz = require('targz');
 const _ = require('lodash');
 
+const clean = require('./clean');
 const getComponentsByDir = require('./get-components-by-dir');
 const initTemplate = require('./init-template');
 const isTemplateLegacy = require('../../utils/is-template-legacy');
@@ -15,6 +16,7 @@ const validator = require('../../registry/domain/validators');
 
 module.exports = function() {
   return _.extend(this, {
+    clean,
     cleanup: function(compressedPackagePath, callback) {
       return fs.unlink(compressedPackagePath, callback);
     },
@@ -26,7 +28,7 @@ module.exports = function() {
           tar: {
             map: function(file) {
               return _.extend(file, {
-                name: '_package/' + file.name
+                name: `_package/${file.name}`
               });
             }
           }
diff --git a/src/cli/facade/clean.js b/src/cli/facade/clean.js
new file mode 100644
index 00000000..3cac6f84
--- /dev/null
+++ b/src/cli/facade/clean.js
@@ -0,0 +1,69 @@
+'use strict';
+
+const format = require('stringformat');
+const read = require('read');
+
+const strings = require('../../resources/index');
+const wrapCliCallback = require('../wrap-cli-callback');
+
+module.exports = function(dependencies) {
+  const { local, logger } = dependencies;
+  const { fetchList, remove } = local.clean;
+  const {
+    cleanAlreadyClean,
+    cleanList,
+    cleanPrompt,
+    cleanPromptDefault,
+    cleanSuccess
+  } = strings.messages.cli;
+
+  const prompt = cb =>
+    read(
+      { prompt: cleanPrompt, default: cleanPromptDefault },
+      (err, result) => {
+        if (err) return cb(false);
+        const lowered = result.toLowerCase().trim();
+        const proceed = lowered === 'y' || lowered === 'yes';
+        cb(proceed);
+      }
+    );
+
+  const removeFolders = (list, cb) =>
+    remove(list, (err, result) => {
+      if (err) {
+        logger.err(strings.errors.cli.cleanRemoveError(err));
+        return cb(err);
+      }
+
+      logger.ok(cleanSuccess);
+      cb();
+    });
+
+  return function(opts, callback) {
+    callback = wrapCliCallback(callback);
+
+    fetchList(opts.dirPath, (err, list) => {
+      if (err) {
+        logger.err(format(strings.errors.generic, err));
+        return callback(err);
+      }
+
+      if (list.length === 0) {
+        logger.ok(cleanAlreadyClean);
+        return callback();
+      }
+
+      logger.warn(cleanList(list));
+      const shouldConfirm = !opts.yes;
+
+      if (shouldConfirm) {
+        return prompt(confirmed => {
+          if (!confirmed) return callback();
+          return removeFolders(list, callback);
+        });
+      }
+
+      removeFolders(list, callback);
+    });
+  };
+};
diff --git a/src/resources/index.js b/src/resources/index.js
index fe740b58..cba6b2ed 100644
--- a/src/resources/index.js
+++ b/src/resources/index.js
@@ -1,5 +1,7 @@
 'use strict';
-const colors = require('colors/safe');
+const { green, yellow } = require('colors/safe');
+
+// Kind of long multi-string messages and functions
 
 const validFunctionMock = `// simplified mock signature
 module.exports = (a, b) => a+b;`;
@@ -20,6 +22,37 @@ module.exports.register = (options, dependencies, next) => {
 module.exports.execute = key => cache[key];
 `;
 
+const mockPluginIsNotValid = `Looks like you are trying to register a dynamic mock plugin but the file you specified is not a valid mock.
+The entry point should be a synchronous function or an object containing an asynchronous register() function and a synchronous execute() function.
+Example:
+
+${yellow(validFunctionMock)}
+
+${yellow(validMockObject)}`;
+
+const initSuccess = (componentName, componentPath) => {
+  const success = `Success! Created ${componentName} at ${componentPath}`;
+  return `${green(success)} 
+
+From here you can run several commands
+
+${green('oc --help')}
+To see a detailed list of all the commands available
+
+We suggest that you begin by typing:
+
+${green('oc dev . 3030')}
+
+If you have questions, issues or feedback about OpenComponents, please, join us on Gitter:
+${green('https://gitter.im/opentable/oc')}
+
+Happy coding
+
+`;
+};
+
+// Kind of concise string messages and functions
+
 module.exports = {
   commands: {
     cli: {
@@ -109,6 +142,8 @@ module.exports = {
       TEMPLATE_NOT_SUPPORTED: '{0} is not a supported oc-template'
     },
     cli: {
+      cleanRemoveError: err =>
+        `An error happened when removing the folders: ${err}`,
       scaffoldError: (url, error) =>
         `Scaffolding failed. Please open an issue on ${url} with the following information: ${error}`,
       COMPONENT_HREF_NOT_FOUND:
@@ -116,18 +151,12 @@ module.exports = {
       COMPONENTS_NOT_FOUND: 'no components found in specified path',
       DEPENDENCIES_INSTALL_FAIL:
         'An error happened when installing the dependencies',
+      DEV_FAIL: 'An error happened when initialising the dev runner: {0}',
       FOLDER_IS_NOT_A_FOLDER: '"{0}" must be a directory',
       FOLDER_NOT_FOUND: '"{0}" not found',
-      DEV_FAIL: 'An error happened when initialising the dev runner: {0}',
       INIT_FAIL: 'An error happened when initialising the component: {0}',
       INVALID_CREDENTIALS: 'Invalid credentials',
-      MOCK_PLUGIN_IS_NOT_VALID: `Looks like you are trying to register a dynamic mock plugin but the file you specified is not a valid mock.
-The entry point should be a synchronous function or an object containing an asynchronous register() function and a synchronous execute() function.
-Example:
-
-${colors.yellow(validFunctionMock)}
-
-${colors.yellow(validMockObject)}`,
+      MOCK_PLUGIN_IS_NOT_VALID: mockPluginIsNotValid,
       NAME_NOT_VALID:
         'the name is not valid. Allowed characters are alphanumeric, _, -',
       NODE_CLI_VERSION_NEEDS_UPGRADE:
@@ -165,28 +194,16 @@ ${colors.yellow(validMockObject)}`,
   },
   messages: {
     cli: {
-      initSuccess: (componentName, componentPath) => `${colors.green(
-        'Success! Created ' + componentName + ' at ' + componentPath
-      )} 
-
-From here you can run several commands
-
-  ${colors.green('oc --help')}
-    To see a detailed list of all the commands available
-
-We suggest that you begin by typing:
-
-  ${colors.green('oc dev . 3030')}
-
-If you have questions, issues or feedback about OpenComponents, please, join us on Gitter:
-  ${colors.green('https://gitter.im/opentable/oc')}
-
-Happy coding
-
-`,
+      cleanAlreadyClean: `The folders are already clean`,
+      cleanList: list =>
+        `The following folders will be removed:\n${list.join('\n')}`,
+      cleanPrompt: 'Proceed? [Y/n]',
+      cleanPromptDefault: 'Y',
+      cleanSuccess: `Folders removed`,
+      initSuccess,
       installCompiler: compiler => `Installing ${compiler} from npm...`,
       installCompilerSuccess: (template, compiler, version) =>
-        `${colors.green('✔')} Installed ${compiler} [${template} v${version}]`,
+        `${green('✔')} Installed ${compiler} [${template} v${version}]`,
       legacyTemplateDeprecationWarning: (legacyType, newType) =>
         `Template-type "${legacyType}" has been deprecated and is now replaced by "${newType}"`,
       CHANGES_DETECTED: 'Changes detected on file: {0}',


Test patch:
diff --git a/test/unit/cli-domain-clean.js b/test/unit/cli-domain-clean.js
new file mode 100644
index 00000000..ab56dd44
--- /dev/null
+++ b/test/unit/cli-domain-clean.js
@@ -0,0 +1,107 @@
+'use strict';
+
+const expect = require('chai').expect;
+const injectr = require('injectr');
+const sinon = require('sinon');
+
+describe('cli : domain : clean', () => {
+  const initialize = options => {
+    options = options || {};
+    return injectr('../../src/cli/domain/clean.js', {
+      './get-components-by-dir': () => (dir, cb) =>
+        cb(options.getComponentsByDirError, [
+          'path/to/my-component1',
+          'path/to/my-component2'
+        ]),
+      'fs-extra': {
+        exists: (dir, cb) => cb(dir.indexOf('my-component1') >= 0),
+        remove: options.removeMock
+      },
+      path: { join: (...params) => params.join('/') }
+    });
+  };
+
+  describe('when fetching the list of folders to clean', () => {
+    describe('happy path', () => {
+      let error, result;
+      beforeEach(done => {
+        const clean = initialize();
+        clean.fetchList('my-components-folder', (err, res) => {
+          error = err;
+          result = res;
+          done();
+        });
+      });
+
+      it('should return no error', () => expect(error).to.be.null);
+
+      it('should return the list', () =>
+        expect(result).to.eql(['path/to/my-component1/node_modules']));
+    });
+
+    describe('getComponentsByDir error', () => {
+      let error, result;
+      beforeEach(done => {
+        const clean = initialize({
+          getComponentsByDirError: new Error('oops')
+        });
+
+        clean.fetchList('my-components-folder', (err, res) => {
+          error = err;
+          result = res;
+          done();
+        });
+      });
+
+      it('should return error', () =>
+        expect(error.toString()).to.equal('Error: oops'));
+    });
+  });
+
+  describe('when removing the folders to clean', () => {
+    describe('happy path', () => {
+      let error, result, removeMock;
+      beforeEach(done => {
+        removeMock = sinon.stub().yields(null, 'ok');
+        const clean = initialize({ removeMock });
+        clean.remove(['path/to/my-component1/node_modules'], (err, res) => {
+          error = err;
+          result = res;
+          done();
+        });
+      });
+
+      it('should return no error', () => expect(error).to.be.null);
+
+      it('should remove all the folders', () => {
+        expect(removeMock.args.length).to.equal(1);
+        expect(removeMock.args[0][0]).to.eql(
+          'path/to/my-component1/node_modules'
+        );
+      });
+    });
+
+    describe('fs.remove error', () => {
+      let error, result, removeMock;
+      beforeEach(done => {
+        removeMock = sinon.stub().yields(new Error('nope'));
+        const clean = initialize({ removeMock });
+        clean.remove(['path/to/my-component1/node_modules'], (err, res) => {
+          error = err;
+          result = res;
+          done();
+        });
+      });
+
+      it('should return the error', () =>
+        expect(error.toString()).to.be.equal('Error: nope'));
+
+      it('should try removing all the folders', () => {
+        expect(removeMock.args.length).to.equal(1);
+        expect(removeMock.args[0][0]).to.eql(
+          'path/to/my-component1/node_modules'
+        );
+      });
+    });
+  });
+});
diff --git a/test/unit/cli-facade-clean.js b/test/unit/cli-facade-clean.js
new file mode 100644
index 00000000..6b20a2c6
--- /dev/null
+++ b/test/unit/cli-facade-clean.js
@@ -0,0 +1,173 @@
+'use strict';
+
+const expect = require('chai').expect;
+const injectr = require('injectr');
+const sinon = require('sinon');
+
+describe('cli : facade : clean', () => {
+  const logSpy = {};
+
+  let error, result, readMock;
+  const execute = (options, done) => {
+    logSpy.ok = sinon.spy();
+    logSpy.err = sinon.spy();
+    logSpy.warn = sinon.spy();
+
+    readMock = sinon.mock().yields(null, options.prompt || 'yes');
+
+    const local = {
+      clean: {
+        fetchList: options.mocks.fetchList,
+        remove: options.mocks.remove
+      }
+    };
+
+    const CleanFacade = injectr('../../src/cli/facade/clean.js', {
+      read: readMock
+    });
+
+    const cleanFacade = new CleanFacade({ local, logger: logSpy });
+    cleanFacade(options.params, (err, res) => {
+      error = err;
+      result = res;
+      done();
+    });
+  };
+
+  describe('when cleaning folder', () => {
+    describe('when folder is already clean', () => {
+      let removeMock;
+      beforeEach(done => {
+        removeMock = sinon.mock();
+        const options = {
+          mocks: { fetchList: (dir, cb) => cb(null, []), remove: removeMock },
+          params: { dirPath: 'path/to/components' }
+        };
+        execute(options, done);
+      });
+
+      it('should show a message', () =>
+        expect(logSpy.ok.args[0][0]).to.equal('The folders are already clean'));
+
+      it(`shouldn't call local.remove`, () => {
+        expect(removeMock.args.length).to.equal(0);
+      });
+    });
+
+    describe('when folder needs cleaning and --yes flag passed down', () => {
+      let removeMock;
+      beforeEach(done => {
+        removeMock = sinon.mock().yields(null, 'ok');
+        const options = {
+          mocks: {
+            fetchList: (dir, cb) => cb(null, ['/folder/to/remove']),
+            remove: removeMock
+          },
+          params: { dirPath: 'path/to/components', yes: true }
+        };
+        execute(options, done);
+      });
+
+      it('should show a message before removing', () =>
+        expect(logSpy.warn.args[0][0]).to.equal(
+          'The following folders will be removed:\n/folder/to/remove'
+        ));
+
+      it(`should remove the folder`, () =>
+        expect(removeMock.args[0][0]).to.eql(['/folder/to/remove']));
+
+      it('should show a message when done', () =>
+        expect(logSpy.ok.args[0][0]).to.equal('Folders removed'));
+    });
+
+    describe('when folder needs cleaning and no --yes flag passed down and users says yes', () => {
+      let removeMock;
+      beforeEach(done => {
+        removeMock = sinon.mock().yields(null, 'ok');
+        const options = {
+          mocks: {
+            fetchList: (dir, cb) => cb(null, ['/folder/to/remove']),
+            remove: removeMock
+          },
+          params: { dirPath: 'path/to/components' }
+        };
+        execute(options, done);
+      });
+
+      it('should show a message before removing', () =>
+        expect(logSpy.warn.args[0][0]).to.equal(
+          'The following folders will be removed:\n/folder/to/remove'
+        ));
+
+      it('should prompt the user to confirm', () =>
+        expect(readMock.args[0][0]).to.eql({
+          default: 'Y',
+          prompt: 'Proceed? [Y/n]'
+        }));
+
+      it(`should then remove the folder`, () =>
+        expect(removeMock.args[0][0]).to.eql(['/folder/to/remove']));
+
+      it('should show a message when done', () =>
+        expect(logSpy.ok.args[0][0]).to.equal('Folders removed'));
+    });
+
+    describe('when folder needs cleaning and no --yes flag passed down and users says no', () => {
+      let removeMock;
+      beforeEach(done => {
+        removeMock = sinon.mock().yields(null, 'ok');
+        const options = {
+          mocks: {
+            fetchList: (dir, cb) => cb(null, ['/folder/to/remove']),
+            remove: removeMock
+          },
+          params: { dirPath: 'path/to/components' },
+          prompt: 'no'
+        };
+        execute(options, done);
+      });
+
+      it('should show a message before removing', () =>
+        expect(logSpy.warn.args[0][0]).to.equal(
+          'The following folders will be removed:\n/folder/to/remove'
+        ));
+
+      it('should prompt the user to confirm', () =>
+        expect(readMock.args[0][0]).to.eql({
+          default: 'Y',
+          prompt: 'Proceed? [Y/n]'
+        }));
+
+      it(`shouldn't remove the folder`, () =>
+        expect(removeMock.args.length).to.equal(0));
+    });
+
+    describe('when removing causes an error', () => {
+      let removeMock;
+      beforeEach(done => {
+        removeMock = sinon.mock().yields(new Error('permission error'));
+        const options = {
+          mocks: {
+            fetchList: (dir, cb) => cb(null, ['/folder/to/remove']),
+            remove: removeMock
+          },
+          params: { dirPath: 'path/to/components', yes: true }
+        };
+        execute(options, done);
+      });
+
+      it('should show a message before removing', () =>
+        expect(logSpy.warn.args[0][0]).to.equal(
+          'The following folders will be removed:\n/folder/to/remove'
+        ));
+
+      it(`should try removing the folder`, () =>
+        expect(removeMock.args[0][0]).to.eql(['/folder/to/remove']));
+
+      it('should show an error message at the end', () =>
+        expect(logSpy.err.args[0][0]).to.equal(
+          'An error happened when removing the folders: Error: permission error'
+        ));
+    });
+  });
+});