Repository: serverless/serverless (JavaScript)
Date: 2016-09-14 15:27:11+00:00
Task type: Bug Fix

Issue:
Show help without validating opts
# This is a Bug Report
## Description

I get error about populating values when `serverless help`

For bug reports:
- What went wrong?

```
> serverless help

  Serverless Error ---------------------------------------

     Trying to populate non string value into a string for
     variable ${opt:stage}. Please make sure the value of
     the property is a string.

  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
```

I need to run `serverless help --stage dev` to see help
- What did you expect should have happened?

HALP info



Issue discussion:

Solution patch:
diff --git a/lib/Serverless.js b/lib/Serverless.js
index 6b31a26444e..0877af0aecb 100644
--- a/lib/Serverless.js
+++ b/lib/Serverless.js
@@ -62,12 +62,10 @@ class Serverless {
         // load all plugins
         this.pluginManager.loadAllPlugins(this.service.plugins);
 
-        // give the CLI the plugins so that it can print out plugin information
-        // such as options when the user enters --help
+        // give the CLI the plugins and commands so that it can print out
+        // information such as options when the user enters --help
         this.cli.setLoadedPlugins(this.pluginManager.getPlugins());
-
-        // populate variables after processing options
-        return this.variables.populateService(this.pluginManager.cliOptions);
+        this.cli.setLoadedCommands(this.pluginManager.getCommands());
       });
   }
 
@@ -78,12 +76,19 @@ class Serverless {
       this.utils.track(this);
     }
 
-    if (!this.cli.displayHelp(this.processedInput) && this.processedInput.commands.length) {
-      // trigger the plugin lifecycle when there's something which should be processed
-      return this.pluginManager.run(this.processedInput.commands);
+    if (this.cli.displayHelp(this.processedInput)) {
+      return BbPromise.resolve();
     }
 
-    return BbPromise.resolve();
+    // make sure the command exists before doing anything else
+    this.pluginManager.validateCommand(this.processedInput.commands);
+
+    // populate variables after --help, otherwise help may fail to print
+    // (https://github.com/serverless/serverless/issues/2041)
+    this.variables.populateService(this.pluginManager.cliOptions);
+
+    // trigger the plugin lifecycle when there's something which should be processed
+    return this.pluginManager.run(this.processedInput.commands);
   }
 
   getVersion() {
diff --git a/lib/classes/CLI.js b/lib/classes/CLI.js
index b5d25566121..3cb93933b57 100644
--- a/lib/classes/CLI.js
+++ b/lib/classes/CLI.js
@@ -11,12 +11,17 @@ class CLI {
     this.serverless = serverless;
     this.inputArray = inputArray || null;
     this.loadedPlugins = [];
+    this.loadedCommands = {};
   }
 
   setLoadedPlugins(plugins) {
     this.loadedPlugins = plugins;
   }
 
+  setLoadedCommands(commands) {
+    this.loadedCommands = commands;
+  }
+
   processInput() {
     let inputArray;
 
@@ -63,6 +68,52 @@ class CLI {
     return false;
   }
 
+  displayCommandUsage(commandObject, command) {
+    const dotsLength = 30;
+
+    // check if command has lifecycleEvents (can be executed)
+    if (commandObject.lifecycleEvents) {
+      const usage = commandObject.usage;
+      const dots = _.repeat('.', dotsLength - command.length);
+      this.consoleLog(`${chalk.yellow(command)} ${chalk.dim(dots)} ${usage}`);
+    }
+
+    _.forEach(commandObject.commands, (subcommandObject, subcommand) => {
+      this.displayCommandUsage(subcommandObject, `${command} ${subcommand}`);
+    });
+  }
+
+  displayCommandOptions(commandObject) {
+    const dotsLength = 40;
+    _.forEach(commandObject.options, (optionsObject, option) => {
+      let optionsDots = _.repeat('.', dotsLength - option.length);
+      const optionsUsage = optionsObject.usage;
+
+      if (optionsObject.required) {
+        optionsDots = optionsDots.slice(0, optionsDots.length - 18);
+      } else {
+        optionsDots = optionsDots.slice(0, optionsDots.length - 7);
+      }
+      if (optionsObject.shortcut) {
+        optionsDots = optionsDots.slice(0, optionsDots.length - 5);
+      }
+
+      const optionInfo = `    --${option}`;
+      let shortcutInfo = '';
+      let requiredInfo = '';
+      if (optionsObject.shortcut) {
+        shortcutInfo = ` / -${optionsObject.shortcut}`;
+      }
+      if (optionsObject.required) {
+        requiredInfo = ' (required)';
+      }
+
+      const thingsToLog = `${optionInfo}${shortcutInfo}${requiredInfo} ${
+        chalk.dim(optionsDots)} ${optionsUsage}`;
+      this.consoleLog(chalk.yellow(thingsToLog));
+    });
+  }
+
   generateMainHelp() {
     this.consoleLog('');
 
@@ -73,153 +124,36 @@ class CLI {
 
     this.consoleLog('');
 
-    const sortedPlugins = _.sortBy(
-      this.loadedPlugins,
-      (plugin) => plugin.constructor.name
-    );
-
-    // TODO: implement recursive command exploration (now only 2 steps are possible)
-    const dotsLength = 25;
-    sortedPlugins.forEach((plugin) => {
-      _.forEach(plugin.commands,
-        (firstLevelCommandObject, firstLevelCommand) => {
-          // check if command has lifecycleEvents (can be execute)
-          if (firstLevelCommandObject.lifecycleEvents) {
-            const command = firstLevelCommand;
-            const usage = firstLevelCommandObject.usage;
-            const dots = _.repeat('.', dotsLength - command.length);
-            this.consoleLog(`${chalk
-              .yellow(command)} ${chalk
-              .dim(dots)} ${usage}`);
-          }
-          _.forEach(firstLevelCommandObject.commands,
-            (secondLevelCommandObject, secondLevelCommand) => {
-              // check if command has lifecycleEvents (can be executed)
-              if (secondLevelCommandObject.lifecycleEvents) {
-                const command = `${firstLevelCommand} ${secondLevelCommand}`;
-                const usage = secondLevelCommandObject.usage;
-                const dots = _.repeat('.', dotsLength - command.length);
-                this.consoleLog(`${chalk
-                  .yellow(command)} ${chalk
-                  .dim(dots)} ${usage}`);
-              }
-            });
-        });
+    _.forEach(this.loadedCommands, (details, command) => {
+      this.displayCommandUsage(details, command);
     });
 
     this.consoleLog('');
 
     // print all the installed plugins
     this.consoleLog(chalk.yellow.underline('Plugins'));
-    if (sortedPlugins.length) {
+
+    if (this.loadedPlugins.length) {
+      const sortedPlugins = _.sortBy(
+        this.loadedPlugins,
+        (plugin) => plugin.constructor.name
+      );
+
       this.consoleLog(sortedPlugins.map((plugin) => plugin.constructor.name).join(', '));
     } else {
       this.consoleLog('No plugins added yet');
     }
   }
 
-  generateCommandsHelp(commands) {
-    const dotsLength = 40;
+  generateCommandsHelp(commandsArray) {
+    const command = this.serverless.pluginManager.getCommand(commandsArray);
+    const commandName = commandsArray.join(' ');
 
-    // TODO: use lodash utility functions to reduce loop usage
-    // TODO: support more than 2 levels of nested commands
-    if (commands.length === 1) {
-      this.loadedPlugins.forEach((plugin) => {
-        _.forEach(plugin.commands, (commandObject, command) => {
-          if (command === commands[0]) {
-            if (commandObject.lifecycleEvents) {
-              // print the name of the plugin
-              this.consoleLog(chalk.yellow.underline(`Plugin: ${plugin.constructor.name}`));
-              // print the command with the corresponding usage
-              const commandsDots = _.repeat('.', dotsLength - command.length);
-              const commandsUsage = commandObject.usage;
-              this.consoleLog(`${chalk
-                .yellow(command)} ${chalk
-                .dim(commandsDots)} ${commandsUsage}`);
-              // print all options
-              _.forEach(commandObject.options, (optionsObject, option) => {
-                let optionsDots = _.repeat('.', dotsLength - option.length);
-                const optionsUsage = optionsObject.usage;
-
-                if (optionsObject.required) {
-                  optionsDots = optionsDots.slice(0, optionsDots.length - 17);
-                } else {
-                  optionsDots = optionsDots.slice(0, optionsDots.length - 7);
-                }
-                if (optionsObject.shortcut) {
-                  optionsDots = optionsDots.slice(0, optionsDots.length - 5);
-                }
-
-                const optionInfo = `    --${option}`;
-                let shortcutInfo = '';
-                let requiredInfo = '';
-                if (optionsObject.shortcut) {
-                  shortcutInfo = ` / -${optionsObject.shortcut}`;
-                }
-                if (optionsObject.required) {
-                  requiredInfo = ' (required)';
-                }
-
-                const thingsToLog = `${optionInfo}${shortcutInfo}${requiredInfo} ${
-                  chalk.dim(optionsDots)} ${optionsUsage}`;
-                this.consoleLog(chalk.yellow(thingsToLog));
-              });
-            }
-          }
-        });
-      });
-    } else {
-      this.loadedPlugins.forEach((plugin) => {
-        _.forEach(plugin.commands,
-          (firstLevelCommandObject, firstLevelCommand) => {
-            if (firstLevelCommand === commands[0]) {
-              _.forEach(firstLevelCommandObject.commands,
-                (secondLevelCommandObject, secondLevelCommand) => {
-                  if (secondLevelCommand === commands[1]) {
-                    if (secondLevelCommandObject.lifecycleEvents) {
-                      // print the name of the plugin
-                      this.consoleLog(chalk.yellow.underline(`Plugin: ${plugin.constructor.name}`));
-                      // print the command with the corresponding usage
-                      const commandsDots = _.repeat('.', dotsLength - secondLevelCommand.length);
-                      const commandsUsage = secondLevelCommandObject.usage;
-                      this.consoleLog(`${chalk
-                        .yellow(secondLevelCommand)} ${chalk
-                        .dim(commandsDots)} ${commandsUsage}`);
-                      // print all options
-                      _.forEach(secondLevelCommandObject.options, (optionsObject, option) => {
-                        let optionsDots = _.repeat('.', dotsLength - option.length);
-                        const optionsUsage = optionsObject.usage;
-
-                        if (optionsObject.required) {
-                          optionsDots = optionsDots.slice(0, optionsDots.length - 17);
-                        } else {
-                          optionsDots = optionsDots.slice(0, optionsDots.length - 7);
-                        }
-                        if (optionsObject.shortcut) {
-                          optionsDots = optionsDots.slice(0, optionsDots.length - 5);
-                        }
-
-                        const optionInfo = `    --${option}`;
-                        let shortcutInfo = '';
-                        let requiredInfo = '';
-                        if (optionsObject.shortcut) {
-                          shortcutInfo = ` / -${optionsObject.shortcut}`;
-                        }
-                        if (optionsObject.required) {
-                          requiredInfo = ' (required)';
-                        }
-
-                        const thingsToLog = `${optionInfo}${shortcutInfo}${requiredInfo} ${
-                          chalk.dim(optionsDots)} ${optionsUsage}`;
-                        this.consoleLog(chalk.yellow(thingsToLog));
-                      });
-                    }
-                  }
-                });
-            }
-          });
-      });
-    }
+    // print the name of the plugin
+    this.consoleLog(chalk.yellow.underline(`Plugin: ${command.pluginName}`));
+
+    this.displayCommandUsage(command, commandName);
+    this.displayCommandOptions(command);
 
     this.consoleLog('');
   }
diff --git a/lib/classes/PluginManager.js b/lib/classes/PluginManager.js
index c990941c526..e075f0559ed 100644
--- a/lib/classes/PluginManager.js
+++ b/lib/classes/PluginManager.js
@@ -1,19 +1,20 @@
 'use strict';
 
 const path = require('path');
-const _ = require('lodash');
 const BbPromise = require('bluebird');
+const _ = require('lodash');
 
 class PluginManager {
   constructor(serverless) {
     this.serverless = serverless;
     this.provider = null;
+
     this.cliOptions = {};
     this.cliCommands = [];
 
     this.plugins = [];
-    this.commandsList = [];
     this.commands = {};
+    this.hooks = {};
   }
 
   setProvider(provider) {
@@ -28,148 +29,41 @@ class PluginManager {
     this.cliCommands = commands;
   }
 
-  loadAllPlugins(servicePlugins) {
-    this.loadCorePlugins();
-    this.loadServicePlugins(servicePlugins);
-  }
-
-  validateCommands(commandsArray) {
-    // TODO: implement an option to get deeper than one level
-    if (!this.commands[commandsArray[0]]) {
-      const errorMessage = [
-        `command "${commandsArray[0]}" not found`,
-        ' Run "serverless help" for a list of all available commands.',
-      ].join();
-      throw new this.serverless.classes.Error(errorMessage);
-    }
-  }
-
-  validateOptions(commandsArray) {
-    let options;
+  addPlugin(Plugin) {
+    const pluginInstance = new Plugin(this.serverless, this.cliOptions);
 
-    // TODO: implement an option to get deeper than two levels
-    if (commandsArray.length === 1) {
-      options = this.commands[commandsArray[0]].options;
-    } else {
-      options = this.commands[commandsArray[0]].commands[commandsArray[1]].options;
+    // ignore plugins that specify a different provider than the current one
+    if (pluginInstance.provider && (pluginInstance.provider !== this.provider)) {
+      return;
     }
 
-    _.forEach(options, (value, key) => {
-      if (value.required && (this.cliOptions[key] === true || !(this.cliOptions[key]))) {
-        let requiredThings = `the --${key} option`;
-        if (value.shortcut) {
-          requiredThings += ` / -${value.shortcut} shortcut`;
-        }
-        const errorMessage = `This command requires ${requiredThings}.`;
-
-        throw new this.serverless.classes.Error(errorMessage);
-      }
-
-      if (_.isPlainObject(value.customValidation) &&
-        value.customValidation.regularExpression instanceof RegExp &&
-        typeof value.customValidation.errorMessage === 'string' &&
-        !value.customValidation.regularExpression.test(this.cliOptions[key])) {
-        throw new this.serverless.classes.Error(value.customValidation.errorMessage);
-      }
-    });
-  }
-
-  run(commandsArray) {
-    // check if the command the user has entered is provided through a plugin
-    this.validateCommands(commandsArray);
-
-    // check if all options are passed
-    this.validateOptions(commandsArray);
-
-    const events = this.getEvents(commandsArray, this.commands);
-    const hooks = events.reduce((memo, event) => {
-      this.plugins.forEach((pluginInstance) => {
-        // if a provider is given it should only add the hook when the plugins provider matches
-        // the services provider
-        if (!pluginInstance.provider || (pluginInstance.provider === this.provider)) {
-          _.forEach(pluginInstance.hooks, (hook, hookKey) => {
-            if (hookKey === event) {
-              memo.push(hook);
-            }
-          });
-        }
-      });
-      return memo;
-    }, []);
-
-    if (hooks.length === 0) {
-      const errorMessage = `The command you entered was not found.
-     Did you spell it correctly?`;
-      throw new this.serverless.classes.Error(errorMessage);
-    }
+    this.loadCommands(pluginInstance);
+    this.loadHooks(pluginInstance);
 
-    return BbPromise.reduce(hooks, (__, hook) => hook(), null);
+    this.plugins.push(pluginInstance);
   }
 
-  convertShortcutsIntoOptions(cliOptions, commands) {
-    // TODO: implement an option to get deeper than two levels
-    // check if the command entered is the one in the commands object which holds all commands
-    // this is necessary so that shortcuts are not treated like global citizens but command
-    // bound properties
-    if (this.cliCommands.length === 1) {
-      _.forEach(commands, (firstCommand, firstCommandKey) => {
-        if (_.includes(this.cliCommands, firstCommandKey)) {
-          _.forEach(firstCommand.options, (optionObject, optionKey) => {
-            if (optionObject.shortcut && _.includes(Object.keys(cliOptions),
-                optionObject.shortcut)) {
-              Object.keys(cliOptions).forEach((option) => {
-                if (option === optionObject.shortcut) {
-                  this.cliOptions[optionKey] = this.cliOptions[option];
-                }
-              });
-            }
-          });
-        }
-      });
-    } else if (this.cliCommands.length === 2) {
-      _.forEach(commands, (firstCommand) => {
-        _.forEach(firstCommand.commands, (secondCommand, secondCommandKey) => {
-          if (_.includes(this.cliCommands, secondCommandKey)) {
-            _.forEach(secondCommand.options, (optionObject, optionKey) => {
-              if (optionObject.shortcut && _.includes(Object.keys(cliOptions),
-                  optionObject.shortcut)) {
-                Object.keys(cliOptions).forEach((option) => {
-                  if (option === optionObject.shortcut) {
-                    this.cliOptions[optionKey] = this.cliOptions[option];
-                  }
-                });
-              }
-            });
-          }
-        });
-      });
-    }
+  loadAllPlugins(servicePlugins) {
+    this.loadCorePlugins();
+    this.loadServicePlugins(servicePlugins);
   }
 
-  addPlugin(Plugin) {
-    const pluginInstance = new Plugin(this.serverless, this.cliOptions);
-
-    this.loadCommands(pluginInstance);
-
-    // shortcuts should be converted into options so that the plugin
-    // author can use the option (instead of the shortcut)
-    this.convertShortcutsIntoOptions(this.cliOptions, this.commands);
+  loadPlugins(plugins) {
+    plugins.forEach((plugin) => {
+      const Plugin = require(plugin); // eslint-disable-line global-require
 
-    this.plugins.push(pluginInstance);
+      this.addPlugin(Plugin);
+    });
   }
 
   loadCorePlugins() {
     const pluginsDirectoryPath = path.join(__dirname, '../plugins');
 
     const corePlugins = this.serverless.utils
-      .readFileSync(path.join(pluginsDirectoryPath, 'Plugins.json')).plugins;
+      .readFileSync(path.join(pluginsDirectoryPath, 'Plugins.json')).plugins
+      .map((corePluginPath) => path.join(pluginsDirectoryPath, corePluginPath));
 
-    corePlugins.forEach((corePlugin) => {
-      const Plugin = require(path // eslint-disable-line global-require
-        .join(pluginsDirectoryPath, corePlugin));
-
-      this.addPlugin(Plugin);
-    });
+    this.loadPlugins(corePlugins);
   }
 
   loadServicePlugins(servicePlugs) {
@@ -180,11 +74,7 @@ class PluginManager {
       module.paths.unshift(path.join(this.serverless.config.servicePath, 'node_modules'));
     }
 
-    servicePlugins.forEach((servicePlugin) => {
-      const Plugin = require(servicePlugin); // eslint-disable-line global-require
-
-      this.addPlugin(Plugin);
-    });
+    this.loadPlugins(servicePlugins);
 
     // restore module paths
     if (this.serverless && this.serverless.config && this.serverless.config.servicePath) {
@@ -192,45 +82,114 @@ class PluginManager {
     }
   }
 
+  loadCommand(pluginName, details, key) {
+    const commands = _.mapValues(details.commands, (subDetails, subKey) =>
+      this.loadCommand(pluginName, subDetails, `${key}:${subKey}`)
+    );
+    return _.assign({}, details, { key, pluginName, commands });
+  }
+
   loadCommands(pluginInstance) {
-    this.commandsList.push(pluginInstance.commands);
-
-    // TODO: refactor ASAP as it slows down overall performance
-    // rebuild the commands
-    _.forEach(this.commandsList, (commands) => {
-      _.forEach(commands, (commandDetails, command) => {
-        this.commands[command] = commandDetails;
-      });
+    const pluginName = pluginInstance.constructor.name;
+    _.forEach(pluginInstance.commands, (details, key) => {
+      const command = this.loadCommand(pluginName, details, key);
+      this.commands[key] = _.merge({}, this.commands[key], command);
     });
   }
 
-  getEvents(commandsArray, availableCommands, pre) {
-    const prefix = (typeof pre !== 'undefined' ? pre : '');
-    const commandPart = commandsArray[0];
+  loadHooks(pluginInstance) {
+    _.forEach(pluginInstance.hooks, (hook, event) => {
+      this.hooks[event] = this.hooks[event] || [];
+      this.hooks[event].push(hook);
+    });
+  }
 
-    if (_.has(availableCommands, commandPart)) {
-      const commandDetails = availableCommands[commandPart];
-      if (commandsArray.length === 1) {
-        const events = [];
-        commandDetails.lifecycleEvents.forEach((event) => {
-          events.push(`before:${prefix}${commandPart}:${event}`);
-          events.push(`${prefix}${commandPart}:${event}`);
-          events.push(`after:${prefix}${commandPart}:${event}`);
-        });
-        return events;
-      }
-      if (_.has(commandDetails, 'commands')) {
-        return this.getEvents(commandsArray.slice(1, commandsArray.length),
-          commandDetails.commands, `${commandPart}:`);
+  getCommands() {
+    return this.commands;
+  }
+
+  getCommand(commandsArray) {
+    return _.reduce(commandsArray, (current, name, index) => {
+      if (name in current.commands) {
+        return current.commands[name];
       }
-    }
+      const commandName = commandsArray.slice(0, index + 1).join(' ');
+      const errorMessage = [
+        `Command "${commandName}" not found`,
+        ' Run "serverless help" for a list of all available commands.',
+      ].join();
+      throw new this.serverless.classes.Error(errorMessage);
+    }, { commands: this.commands });
+  }
 
-    return [];
+  getEvents(command) {
+    return _.flatMap(command.lifecycleEvents, (event) => [
+      `before:${command.key}:${event}`,
+      `${command.key}:${event}`,
+      `after:${command.key}:${event}`,
+    ]);
   }
 
   getPlugins() {
     return this.plugins;
   }
+
+  run(commandsArray) {
+    const command = this.getCommand(commandsArray);
+
+    this.convertShortcutsIntoOptions(command);
+    this.validateOptions(command);
+
+    const events = this.getEvents(command);
+    const hooks = _.flatMap(events, (event) => this.hooks[event] || []);
+
+    if (hooks.length === 0) {
+      const errorMessage = 'The command you entered did not catch on any hooks';
+      throw new this.serverless.classes.Error(errorMessage);
+    }
+
+    return BbPromise.reduce(hooks, (__, hook) => hook(), null);
+  }
+
+  validateCommand(commandsArray) {
+    this.getCommand(commandsArray);
+  }
+
+  validateOptions(command) {
+    _.forEach(command.options, (value, key) => {
+      if (value.required && (this.cliOptions[key] === true || !(this.cliOptions[key]))) {
+        let requiredThings = `the --${key} option`;
+
+        if (value.shortcut) {
+          requiredThings += ` / -${value.shortcut} shortcut`;
+        }
+        const errorMessage = `This command requires ${requiredThings}.`;
+
+        throw new this.serverless.classes.Error(errorMessage);
+      }
+
+      if (_.isPlainObject(value.customValidation) &&
+        value.customValidation.regularExpression instanceof RegExp &&
+        typeof value.customValidation.errorMessage === 'string' &&
+        !value.customValidation.regularExpression.test(this.cliOptions[key])) {
+        throw new this.serverless.classes.Error(value.customValidation.errorMessage);
+      }
+    });
+  }
+
+  convertShortcutsIntoOptions(command) {
+    _.forEach(command.options, (optionObject, optionKey) => {
+      if (optionObject.shortcut && _.includes(Object.keys(this.cliOptions),
+          optionObject.shortcut)) {
+        Object.keys(this.cliOptions).forEach((option) => {
+          if (option === optionObject.shortcut) {
+            this.cliOptions[optionKey] = this.cliOptions[option];
+          }
+        });
+      }
+    });
+  }
+
 }
 
 module.exports = PluginManager;


Test patch:
diff --git a/tests/classes/CLI.js b/tests/classes/CLI.js
index 6695353f7a9..e21325848c5 100644
--- a/tests/classes/CLI.js
+++ b/tests/classes/CLI.js
@@ -116,10 +116,11 @@ describe('CLI', () => {
           };
         }
       }
-      const pluginMock = new PluginMock();
-      const plugins = [pluginMock];
+      serverless.pluginManager.addPlugin(PluginMock);
+
+      cli.setLoadedPlugins(serverless.pluginManager.getPlugins());
+      cli.setLoadedCommands(serverless.pluginManager.getCommands());
 
-      cli.setLoadedPlugins(plugins);
       const processedInput = cli.processInput();
       const helpDisplayed = cli.displayHelp(processedInput);
 
@@ -180,10 +181,11 @@ describe('CLI', () => {
           };
         }
       }
-      const pluginMock = new PluginMock();
-      const plugins = [pluginMock];
+      serverless.pluginManager.addPlugin(PluginMock);
+
+      cli.setLoadedPlugins(serverless.pluginManager.getPlugins());
+      cli.setLoadedCommands(serverless.pluginManager.getCommands());
 
-      cli.setLoadedPlugins(plugins);
       const processedInput = cli.processInput();
       const helpDisplayed = cli.displayHelp(processedInput);
 
@@ -228,10 +230,11 @@ describe('CLI', () => {
           };
         }
       }
-      const pluginMock = new PluginMock();
-      const plugins = [pluginMock];
+      serverless.pluginManager.addPlugin(PluginMock);
+
+      cli.setLoadedPlugins(serverless.pluginManager.getPlugins());
+      cli.setLoadedCommands(serverless.pluginManager.getCommands());
 
-      cli.setLoadedPlugins(plugins);
       const processedInput = cli.processInput();
       const helpDisplayed = cli.displayHelp(processedInput);
 
diff --git a/tests/classes/PluginManager.js b/tests/classes/PluginManager.js
index d146f933152..b95af932372 100644
--- a/tests/classes/PluginManager.js
+++ b/tests/classes/PluginManager.js
@@ -214,10 +214,6 @@ describe('PluginManager', () => {
       expect(pluginManager.plugins.length).to.equal(0);
     });
 
-    it('should create an empty commandsList array', () => {
-      expect(pluginManager.commandsList.length).to.equal(0);
-    });
-
     it('should create an empty commands object', () => {
       expect(pluginManager.commands).to.deep.equal({});
     });
@@ -254,81 +250,33 @@ describe('PluginManager', () => {
     it('should convert shortcuts into options when a one level deep command matches', () => {
       const cliOptionsMock = { r: 'eu-central-1', region: 'us-east-1' };
       const cliCommandsMock = ['deploy']; // command with one level deepness
-      const commandsMock = {
-        deploy: {
-          options: {
-            region: {
-              shortcut: 'r',
-            },
+      const commandMock = {
+        options: {
+          region: {
+            shortcut: 'r',
           },
         },
       };
       pluginManager.setCliCommands(cliCommandsMock);
       pluginManager.setCliOptions(cliOptionsMock);
 
-      pluginManager.convertShortcutsIntoOptions(cliOptionsMock, commandsMock);
+      pluginManager.convertShortcutsIntoOptions(commandMock);
 
       expect(pluginManager.cliOptions.region).to.equal(cliOptionsMock.r);
     });
 
-    it('should convert shortcuts into options when a two level deep command matches', () => {
-      const cliOptionsMock = { f: 'function-1', function: 'function-2' };
-      const cliCommandsMock = ['deploy', 'function']; // command with two level deepness
-      const commandsMock = {
-        deploy: {
-          commands: {
-            function: {
-              options: {
-                function: {
-                  shortcut: 'f',
-                },
-              },
-            },
-          },
-        },
-      };
-      pluginManager.setCliCommands(cliCommandsMock);
-      pluginManager.setCliOptions(cliOptionsMock);
-
-      pluginManager.convertShortcutsIntoOptions(cliOptionsMock, commandsMock);
-
-      expect(pluginManager.cliOptions.function).to.equal(cliOptionsMock.f);
-    });
-
-    it('should not convert shortcuts into options when the command does not match', () => {
-      const cliOptionsMock = { r: 'eu-central-1', region: 'us-east-1' };
-      const cliCommandsMock = ['foo'];
-      const commandsMock = {
-        deploy: {
-          options: {
-            region: {
-              shortcut: 'r',
-            },
-          },
-        },
-      };
-      pluginManager.setCliCommands(cliCommandsMock);
-      pluginManager.setCliOptions(cliOptionsMock);
-
-      pluginManager.convertShortcutsIntoOptions(cliOptionsMock, commandsMock);
-
-      expect(pluginManager.cliOptions.region).to.equal(cliOptionsMock.region);
-    });
-
     it('should not convert shortcuts into options when the shortcut is not given', () => {
       const cliOptionsMock = { r: 'eu-central-1', region: 'us-east-1' };
       const cliCommandsMock = ['deploy'];
-      const commandsMock = {
-        deploy: {
-          options: {
-            region: {},
-          },
+      const commandMock = {
+        options: {
+          region: {},
         },
       };
       pluginManager.setCliCommands(cliCommandsMock);
       pluginManager.setCliOptions(cliOptionsMock);
 
-      pluginManager.convertShortcutsIntoOptions(cliOptionsMock, commandsMock);
+      pluginManager.convertShortcutsIntoOptions(commandMock);
 
       expect(pluginManager.cliOptions.region).to.equal(cliOptionsMock.region);
     });
@@ -344,7 +292,7 @@ describe('PluginManager', () => {
     it('should load the plugin commands', () => {
       pluginManager.addPlugin(SynchronousPluginMock);
 
-      expect(pluginManager.commandsList[0]).to.have.property('deploy');
+      expect(pluginManager.commands).to.have.property('deploy');
     });
   });
 
@@ -438,19 +386,58 @@ describe('PluginManager', () => {
       const synchronousPluginMockInstance = new SynchronousPluginMock();
       pluginManager.loadCommands(synchronousPluginMockInstance);
 
-      expect(pluginManager.commandsList[0]).to.have.property('deploy');
+      expect(pluginManager.commands).to.have.property('deploy');
+    });
+
+    it('should merge plugin commands', () => {
+      pluginManager.loadCommands({
+        commands: {
+          deploy: {
+            lifecycleEvents: [
+              'one',
+            ],
+            options: {
+              foo: {},
+            },
+          },
+        },
+      });
+
+      pluginManager.loadCommands({
+        commands: {
+          deploy: {
+            lifecycleEvents: [
+              'one',
+              'two',
+            ],
+            options: {
+              bar: {},
+            },
+            commands: {
+              fn: {
+              },
+            },
+          },
+        },
+      });
+
+      expect(pluginManager.commands.deploy).to.have.property('options')
+        .that.has.all.keys('foo', 'bar');
+      expect(pluginManager.commands.deploy).to.have.property('lifecycleEvents')
+        .that.is.an('array')
+        .that.deep.equals(['one', 'two']);
+      expect(pluginManager.commands.deploy.commands).to.have.property('fn');
     });
   });
 
   describe('#getEvents()', () => {
     beforeEach(function () { // eslint-disable-line prefer-arrow-callback
-      const synchronousPluginMockInstance = new SynchronousPluginMock();
-      pluginManager.loadCommands(synchronousPluginMockInstance);
+      pluginManager.addPlugin(SynchronousPluginMock);
     });
 
     it('should get all the matching events for a root level command in the correct order', () => {
-      const commandsArray = ['deploy'];
-      const events = pluginManager.getEvents(commandsArray, pluginManager.commands);
+      const command = pluginManager.getCommand(['deploy']);
+      const events = pluginManager.getEvents(command);
 
       expect(events[0]).to.equal('before:deploy:resources');
       expect(events[1]).to.equal('deploy:resources');
@@ -461,8 +448,8 @@ describe('PluginManager', () => {
     });
 
     it('should get all the matching events for a nested level command in the correct order', () => {
-      const commandsArray = ['deploy', 'onpremises'];
-      const events = pluginManager.getEvents(commandsArray, pluginManager.commands);
+      const command = pluginManager.getCommand(['deploy', 'onpremises']);
+      const events = pluginManager.getEvents(command);
 
       expect(events[0]).to.equal('before:deploy:onpremises:resources');
       expect(events[1]).to.equal('deploy:onpremises:resources');
@@ -471,13 +458,6 @@ describe('PluginManager', () => {
       expect(events[4]).to.equal('deploy:onpremises:functions');
       expect(events[5]).to.equal('after:deploy:onpremises:functions');
     });
-
-    it('should return an empty events array when the command is not defined', () => {
-      const commandsArray = ['foo'];
-      const events = pluginManager.getEvents(commandsArray, pluginManager.commands);
-
-      expect(events.length).to.equal(0);
-    });
   });
 
   describe('#getPlugins()', () => {
@@ -500,53 +480,34 @@ describe('PluginManager', () => {
     });
   });
 
-  describe('#validateCommands()', () => {
-    it('should throw an error if a first level command is not found in the commands object', () => {
-      pluginManager.commands = {
-        foo: {},
-      };
-      const commandsArray = ['bar'];
-
-      expect(() => { pluginManager.validateCommands(commandsArray); }).to.throw(Error);
-    });
-  });
-
   describe('#validateOptions()', () => {
-    it('should throw an error if a required option is not set in a plain commands object', () => {
+    it('should throw an error if a required option is not set', () => {
       pluginManager.commands = {
         foo: {
           options: {
-            bar: {
+            baz: {
+              shortcut: 'b',
               required: true,
             },
           },
         },
-      };
-      const commandsArray = ['foo'];
-
-      expect(() => { pluginManager.validateOptions(commandsArray); }).to.throw(Error);
-    });
-
-    it('should throw an error if a required option is not set in a nested commands object', () => {
-      pluginManager.commands = {
-        foo: {
-          commands: {
-            bar: {
-              options: {
-                baz: {
-                  required: true,
-                },
-              },
+        bar: {
+          options: {
+            baz: {
+              required: true,
             },
           },
         },
       };
-      const commandsArray = ['foo', 'bar'];
 
-      expect(() => { pluginManager.validateOptions(commandsArray); }).to.throw(Error);
+      const foo = pluginManager.commands.foo;
+      const bar = pluginManager.commands.bar;
+
+      expect(() => { pluginManager.validateOptions(foo); }).to.throw(Error);
+      expect(() => { pluginManager.validateOptions(bar); }).to.throw(Error);
     });
 
-    it('should throw an error if a customValidation is not set in a plain commands object', () => {
+    it('should throw an error if a customValidation is not met', () => {
       pluginManager.setCliOptions({ bar: 'dev' });
 
       pluginManager.commands = {
@@ -561,33 +522,9 @@ describe('PluginManager', () => {
           },
         },
       };
-      const commandsArray = ['foo'];
+      const command = pluginManager.commands.foo;
 
-      expect(() => { pluginManager.validateOptions(commandsArray); }).to.throw(Error);
-    });
-
-    it('should throw an error if a customValidation is not set in a nested commands object', () => {
-      pluginManager.setCliOptions({ baz: 100 });
-
-      pluginManager.commands = {
-        foo: {
-          commands: {
-            bar: {
-              options: {
-                baz: {
-                  customValidation: {
-                    regularExpression: /^[a-zA-z¥s]+$/,
-                    errorMessage: 'Custom Error Message',
-                  },
-                },
-              },
-            },
-          },
-        },
-      };
-      const commandsArray = ['foo', 'bar'];
-
-      expect(() => { pluginManager.validateOptions(commandsArray); }).to.throw(Error);
+      expect(() => { pluginManager.validateOptions(command); }).to.throw(Error);
     });
 
     it('should succeeds if a custom regex matches in a plain commands object', () => {
@@ -609,30 +546,6 @@ describe('PluginManager', () => {
 
       expect(() => { pluginManager.validateOptions(commandsArray); }).to.not.throw(Error);
     });
-
-    it('should succeeds if a custom regex matches in a nested commands object', () => {
-      pluginManager.setCliOptions({ baz: 'dev' });
-
-      pluginManager.commands = {
-        foo: {
-          commands: {
-            bar: {
-              options: {
-                baz: {
-                  customValidation: {
-                    regularExpression: /^[a-zA-z¥s]+$/,
-                    errorMessage: 'Custom Error Message',
-                  },
-                },
-              },
-            },
-          },
-        },
-      };
-      const commandsArray = ['foo', 'bar'];
-
-      expect(() => { pluginManager.validateOptions(commandsArray); }).to.not.throw(Error);
-    });
   });
 
   describe('#run()', () => {
@@ -644,6 +557,22 @@ describe('PluginManager', () => {
       expect(() => { pluginManager.run(commandsArray); }).to.throw(Error);
     });
 
+    it('should throw an error when the given command has no hooks', () => {
+      class HooklessPlugin {
+        constructor() {
+          this.commands = {
+            foo: {},
+          };
+        }
+      }
+
+      pluginManager.addPlugin(HooklessPlugin);
+
+      const commandsArray = ['foo'];
+
+      expect(() => { pluginManager.run(commandsArray); }).to.throw(Error);
+    });
+
     it('should run the hooks in the correct order', () => {
       class CorrectHookOrderPluginMock {
         constructor() {