Repository: serverless/serverless (JavaScript) — Feature

Issue:
Add alias support for plugin commands to the PluginManager
# This is a Feature Proposal

## Description

The plugin manager should support `alias` so that the CLI usage can be expressed through different commands (e.g. `serverless deploy function` or `serverless function deploy`).

@HyperBrain has already done some great research on this. The implementation proposal (and some more discussion around that) can be found here: https://github.com/serverless/serverless/issues/4135#issuecomment-323974007

Related to https://github.com/serverless/serverless/issues/4135


Discussion:

Resolving patch:
diff --git a/lib/classes/PluginManager.js b/lib/classes/PluginManager.js
index 2b306946120..5e0b3f7d41f 100644
--- a/lib/classes/PluginManager.js
+++ b/lib/classes/PluginManager.js
@@ -34,6 +34,7 @@ class PluginManager {
 
     this.plugins = [];
     this.commands = {};
+    this.aliases = {};
     this.hooks = {};
     this.deprecatedEvents = {};
   }
@@ -130,10 +131,66 @@ class PluginManager {
     this.loadPlugins(servicePlugins);
   }
 
-  loadCommand(pluginName, details, key) {
+  createCommandAlias(alias, command) {
+    // Deny self overrides
+    if (_.startsWith(command, alias)) {
+      throw new this.serverless.classes
+        .Error(`Command "${alias}" cannot be overriden by an alias`);
+    }
+
+    const splitAlias = _.split(alias, ':');
+    const aliasTarget = _.reduce(splitAlias, (__, aliasPath) => {
+      const currentAlias = __;
+      if (!currentAlias[aliasPath]) {
+        currentAlias[aliasPath] = {};
+      }
+      return currentAlias[aliasPath];
+    }, this.aliases);
+    // Check if the alias is already defined
+    if (aliasTarget.command) {
+      throw new this.serverless.classes
+        .Error(`Alias "${alias}" is already defined for command ${aliasTarget.command}`);
+    }
+    // Check if the alias would overwrite an exiting command
+    if (_.reduce(splitAlias, (__, aliasPath) => {
+      if (!__ || !__.commands || !__.commands[aliasPath]) {
+        return null;
+      }
+      return __.commands[aliasPath];
+    }, this)) {
+      throw new this.serverless.classes
+        .Error(`Command "${alias}" cannot be overriden by an alias`);
+    }
+    aliasTarget.command = command;
+  }
+
+  loadCommand(pluginName, details, key, isEntryPoint) {
+    const commandIsEntryPoint = details.type === 'entrypoint' || isEntryPoint;
+    if (process.env.SLS_DEBUG && !commandIsEntryPoint) {
+      this.serverless.cli.log(`Load command ${key}`);
+    }
+    // Check if there is already an alias for the same path as the command
+    const aliasCommand = this.getAliasCommandTarget(_.split(key, ':'));
+    if (aliasCommand) {
+      throw new this.serverless.classes
+        .Error(`Command "${key}" cannot override an existing alias`);
+    }
+    // Load the command
     const commands = _.mapValues(details.commands, (subDetails, subKey) =>
-      this.loadCommand(pluginName, subDetails, `${key}:${subKey}`)
+      this.loadCommand(
+        pluginName,
+        subDetails,
+        `${key}:${subKey}`,
+        commandIsEntryPoint
+      )
     );
+    // Handle command aliases
+    _.forEach(details.aliases, alias => {
+      if (process.env.SLS_DEBUG) {
+        this.serverless.cli.log(`  -> @${alias}`);
+      }
+      this.createCommandAlias(alias, key);
+    });
     return _.assign({}, details, { key, pluginName, commands });
   }
 
@@ -199,9 +256,46 @@ class PluginManager {
         }
       });
     }
+    // Iterate through the existing aliases and add them as commands
+    _.remove(stack);
+    stack.push({ aliases: this.aliases, target: result });
+    while (!_.isEmpty(stack)) {
+      const currentAlias = stack.pop();
+      const aliases = currentAlias.aliases;
+      const target = currentAlias.target;
+      _.forOwn(aliases, (alias, name) => {
+        if (name === 'command') {
+          return;
+        }
+        if (alias.command) {
+          const commandPath = _.join(_.split(alias.command, ':'), '.commands.');
+          _.set(target, name, _.get(this.commands, commandPath));
+        } else {
+          target[name] = target[name] || {};
+          target[name].commands = target[name].commands || {};
+        }
+        stack.push({ aliases: alias, target: target[name].commands });
+      });
+    }
     return result;
   }
 
+  getAliasCommandTarget(aliasArray) {
+    // Check if the command references an alias
+    const aliasCommand = _.reduce(
+      aliasArray,
+      (__, commandPath) => {
+        if (!__ || !__[commandPath]) {
+          return null;
+        }
+        return __[commandPath];
+      },
+      this.aliases
+    );
+
+    return _.get(aliasCommand, 'command');
+  }
+
   /**
    * Retrieve the command specified by a command list. The method can be configured
    * to include entrypoint commands (which are invisible to the CLI and can only
@@ -211,12 +305,15 @@ class PluginManager {
    * @returns {Object} Command
    */
   getCommand(commandsArray, allowEntryPoints) {
-    return _.reduce(commandsArray, (current, name, index) => {
+    // Check if the command references an alias
+    const aliasCommandTarget = this.getAliasCommandTarget(commandsArray);
+    const commandOrAlias = aliasCommandTarget ? _.split(aliasCommandTarget, ':') : commandsArray;
+    return _.reduce(commandOrAlias, (current, name, index) => {
       if (name in current.commands &&
          (allowEntryPoints || current.commands[name].type !== 'entrypoint')) {
         return current.commands[name];
       }
-      const commandName = commandsArray.slice(0, index + 1).join(' ');
+      const commandName = commandOrAlias.slice(0, index + 1).join(' ');
       const errorMessage = `Serverless command "${commandName}" not found
 
   Run "serverless help" for a list of all available commands.`;


Test patch:
diff --git a/lib/classes/PluginManager.test.js b/lib/classes/PluginManager.test.js
index b784c58ddf6..9fec8833b45 100644
--- a/lib/classes/PluginManager.test.js
+++ b/lib/classes/PluginManager.test.js
@@ -1,5 +1,7 @@
 'use strict';
 
+/* eslint-disable no-unused-expressions */
+
 const chai = require('chai');
 let PluginManager = require('../../lib/classes/PluginManager');
 const Serverless = require('../../lib/Serverless');
@@ -205,6 +207,66 @@ describe('PluginManager', () => {
     }
   }
 
+  class AliasPluginMock {
+    constructor() {
+      this.commands = {
+        deploy: {
+          usage: 'Deploy to the default infrastructure',
+          lifecycleEvents: [
+            'resources',
+            'functions',
+          ],
+          options: {
+            resource: {
+              usage: 'The resource you want to deploy (e.g. --resource db)',
+            },
+            function: {
+              usage: 'The function you want to deploy (e.g. --function create)',
+            },
+          },
+          commands: {
+            onpremises: {
+              usage: 'Deploy to your On-Premises infrastructure',
+              lifecycleEvents: [
+                'resources',
+                'functions',
+              ],
+              aliases: [
+                'on:premise',
+                'premise',
+              ],
+              options: {
+                resource: {
+                  usage: 'The resource you want to deploy (e.g. --resource db)',
+                },
+                function: {
+                  usage: 'The function you want to deploy (e.g. --function create)',
+                },
+              },
+            },
+          },
+        },
+      };
+
+      this.hooks = {
+        'deploy:functions': this.functions.bind(this),
+        'before:deploy:onpremises:functions': this.resources.bind(this),
+      };
+
+      // used to test if the function was executed correctly
+      this.deployedFunctions = 0;
+      this.deployedResources = 0;
+    }
+
+    functions() {
+      this.deployedFunctions = this.deployedFunctions + 1;
+    }
+
+    resources() {
+      this.deployedResources = this.deployedResources + 1;
+    }
+  }
+
   class EntrypointPluginMock {
     constructor() {
       this.commands = {
@@ -381,7 +443,7 @@ describe('PluginManager', () => {
     let getCommandsStub;
 
     beforeEach(() => {
-      writeFileStub = sinon.stub().resolves();
+      writeFileStub = sinon.stub().returns(BbPromise.resolve());
       PluginManager = proxyquire('./PluginManager.js', {
         '../utils/fs/writeFile': writeFileStub,
       });
@@ -586,7 +648,9 @@ describe('PluginManager', () => {
     });
   });
 
-  describe('#loadAllPlugins()', () => {
+  describe('#loadAllPlugins()', function () {
+    this.timeout(5000);
+
     beforeEach(function () { // eslint-disable-line prefer-arrow-callback
       mockRequire('ServicePluginMock1', ServicePluginMock1);
       mockRequire('ServicePluginMock2', ServicePluginMock2);
@@ -683,6 +747,98 @@ describe('PluginManager', () => {
     });
   });
 
+  describe('command aliases', () => {
+    describe('#getAliasCommandTarget', () => {
+      it('should return an alias target', () => {
+        pluginManager.aliases = {
+          cmd1: {
+            cmd2: {
+              command: 'command1',
+            },
+            cmd3: {
+              cmd4: {
+                command: 'command2',
+              },
+            },
+          },
+        };
+        expect(pluginManager.getAliasCommandTarget(['cmd1', 'cmd2']))
+          .to.equal('command1');
+        expect(pluginManager.getAliasCommandTarget(['cmd1', 'cmd3', 'cmd4']))
+          .to.equal('command2');
+      });
+
+      it('should return undefined if alias does not exist', () => {
+        pluginManager.aliases = {
+          cmd1: {
+            cmd2: {
+              command: 'command1',
+            },
+            cmd3: {
+              cmd4: {
+                command: 'command2',
+              },
+            },
+          },
+        };
+        expect(pluginManager.getAliasCommandTarget(['cmd1']))
+          .to.be.undefined;
+        expect(pluginManager.getAliasCommandTarget(['cmd1', 'cmd3']))
+          .to.be.undefined;
+      });
+    });
+
+    describe('#createCommandAlias', () => {
+      it('should create an alias for a command', () => {
+        pluginManager.aliases = {};
+        expect(pluginManager.createCommandAlias('cmd1:alias2', 'cmd2:cmd3:cmd4'))
+          .to.not.throw;
+        expect(pluginManager.createCommandAlias('cmd1:alias2:alias3', 'cmd2:cmd3:cmd5'))
+          .to.not.throw;
+        expect(pluginManager.aliases)
+          .to.deep.equal({
+            cmd1: {
+              alias2: {
+                command: 'cmd2:cmd3:cmd4',
+                alias3: {
+                  command: 'cmd2:cmd3:cmd5',
+                },
+              },
+            },
+          });
+      });
+
+      it('should fail if the alias already exists', () => {
+        pluginManager.aliases = {
+          cmd1: {
+            alias2: {
+              command: 'cmd2:cmd3:cmd4',
+              alias3: {
+                command: 'cmd2:cmd3:cmd5',
+              },
+            },
+          },
+        };
+        expect(() => pluginManager.createCommandAlias('cmd1:alias2', 'mycmd'))
+          .to.throw(/Alias "cmd1:alias2" is already defined/);
+      });
+
+      it('should fail if the alias overwrites a command', () => {
+        const synchronousPluginMockInstance = new SynchronousPluginMock();
+        pluginManager.loadCommands(synchronousPluginMockInstance);
+        expect(() => pluginManager.createCommandAlias('deploy', 'mycmd'))
+          .to.throw(/Command "deploy" cannot be overriden/);
+      });
+
+      it('should fail if the alias overwrites the very own command', () => {
+        const synchronousPluginMockInstance = new SynchronousPluginMock();
+        synchronousPluginMockInstance.commands.deploy.commands.onpremises.aliases = ['deploy'];
+        expect(() => pluginManager.loadCommands(synchronousPluginMockInstance))
+          .to.throw(/Command "deploy" cannot be overriden/);
+      });
+    });
+  });
+
   describe('#loadCommands()', () => {
     it('should load the plugin commands', () => {
       const synchronousPluginMockInstance = new SynchronousPluginMock();
@@ -730,6 +886,28 @@ describe('PluginManager', () => {
         .that.deep.equals(['one', 'two']);
       expect(pluginManager.commands.deploy.commands).to.have.property('fn');
     });
+
+    it('should fail if there is already an alias for a command', () => {
+      pluginManager.aliases = {
+        deploy: {
+          command: 'my:deploy',
+        },
+      };
+
+      const synchronousPluginMockInstance = new SynchronousPluginMock();
+      expect(() => pluginManager.loadCommands(synchronousPluginMockInstance))
+        .to.throw(/Command "deploy" cannot override an existing alias/);
+    });
+
+    it('should log the alias when SLS_DEBUG is set', () => {
+      const consoleLogStub = sinon.stub(pluginManager.serverless.cli, 'log').returns();
+      const synchronousPluginMockInstance = new SynchronousPluginMock();
+      synchronousPluginMockInstance.commands.deploy.aliases = ['info'];
+      _.set(process.env, 'SLS_DEBUG', '*');
+      pluginManager.loadCommands(synchronousPluginMockInstance);
+      _.unset(process.env, 'SLS_DEBUG');
+      expect(consoleLogStub).to.have.been.calledWith('  -> @info');
+    });
   });
 
   describe('#loadHooks()', () => {
@@ -1161,6 +1339,15 @@ describe('PluginManager', () => {
       expect(commands).to.not.have.a.deep.property('myep.commands.mysubep');
       expect(commands).to.not.have.a.deep.property('mycmd.commands.spawnep');
     });
+
+    it('should return aliases', () => {
+      pluginManager.addPlugin(AliasPluginMock);
+
+      const commands = pluginManager.getCommands();
+      expect(commands).to.have.a.property('on')
+        .that.has.a.deep.property('commands.premise');
+      expect(commands).to.have.a.property('premise');
+    });
   });
 
   describe('#spawn()', () => {