Repository: serverless/serverless (JavaScript)
Date: 2019-09-20 16:02:10+00:00
Task type: Feature

Issue:
Why is `plugin install` limited to those in serverless plugin registry?
Hey folks, thanks for the great technology! 🤖 

It appears that `sls plugin install --name <plugin>` first checks that the plugin is in the serverless registry and only npm installs if it is. Why is that? Are you open to loosening that restriction?

I work for a fairly large enterprise and am setting up a CICD pipeline for lambdas. I have a serverless plugin that I want to install and add to the serverless config for any lambda repo that uses the pipeline. I don't need or want end users to have to install this plugin since it is an implementation detail of the pipeline. Right now each lambda repo has to install it and add it to their serverless.yml manually. If `sls plugin install` loosened the aforementioned restriction, I could install it at deploy time.

Specifically, the plugin is published to our enterprise npm repository. It is scoped with an npm namespace.

Thanks!


Issue discussion:
Hey @mcjfunk thanks for opening the issue :+1:

Good question. The main idea for `plugin install` is to abstract `npm` away. In the future we don't want to expose the npm dependency to end users. That's also the reason why we're reaching out to our own registry (which currently is just a `.json` file we `fetch`).

The best way to be as flexible as possible is to use plain `npm` (since our `plugin install` is just a wrapper around `npm install --save-dev <plugin-name>` right now).
There's also the https://www.npmjs.com/package/serverless-plugin-scripts and https://www.npmjs.com/package/serverless-scriptable-plugin plugin which might be helpful in your case.
The key piece to `plugin install` that I don't get with `npm install` is the plugin not only gets installed, but it also gets registered in the serverless config. Is there any workaround to that?

Again, we need the installation of certain plugins to be done in the CICD pipeline as they are an implementation detail of the pipeline. `plugin install` allows for that but limits our enterprise use case due to this registry checking.

Solution patch:
diff --git a/lib/plugins/plugin/install/install.js b/lib/plugins/plugin/install/install.js
index c2df8ffa77b..3b5982e92cf 100644
--- a/lib/plugins/plugin/install/install.js
+++ b/lib/plugins/plugin/install/install.js
@@ -7,8 +7,8 @@ const path = require('path');
 const _ = require('lodash');
 const userStats = require('../../../utils/userStats');
 const yamlAstParser = require('../../../utils/yamlAstParser');
-const pluginUtils = require('../lib/utils');
 const fileExists = require('../../../utils/fs/fileExists');
+const pluginUtils = require('../lib/utils');
 
 class PluginInstall {
   constructor(serverless, options) {
@@ -43,13 +43,7 @@ class PluginInstall {
   }
 
   install() {
-    let pluginInfo;
-    if (this.options.name.startsWith('@')) {
-      pluginInfo = _.split(this.options.name.slice(1), '@', 2);
-      pluginInfo[0] = `@${pluginInfo[0]}`;
-    } else {
-      pluginInfo = _.split(this.options.name, '@', 2);
-    }
+    const pluginInfo = pluginUtils.getPluginInfo(this.options.name);
     this.options.pluginName = pluginInfo[0];
     this.options.pluginVersion = pluginInfo[1] || 'latest';
 
@@ -58,21 +52,20 @@ class PluginInstall {
       .then(this.getPlugins)
       .then(plugins => {
         const plugin = plugins.find(item => item.name === this.options.pluginName);
-        if (plugin) {
-          return BbPromise.bind(this)
-            .then(this.pluginInstall)
-            .then(this.addPluginToServerlessFile)
-            .then(this.installPeerDependencies)
-            .then(() => {
-              const message = [
-                'Successfully installed',
-                ` "${this.options.pluginName}@${this.options.pluginVersion}"`,
-              ].join('');
-              this.serverless.cli.log(message);
-            });
+        if (!plugin) {
+          this.serverless.cli.log('Plugin not found in serverless registry, continuing to install');
         }
-        const message = `Plugin "${this.options.pluginName}" not found. Did you spell it correct?`;
-        throw new this.serverless.classes.Error(message);
+        return BbPromise.bind(this)
+          .then(this.pluginInstall)
+          .then(this.addPluginToServerlessFile)
+          .then(this.installPeerDependencies)
+          .then(() => {
+            const message = [
+              'Successfully installed',
+              ` "${this.options.pluginName}@${this.options.pluginVersion}"`,
+            ].join('');
+            this.serverless.cli.log(message);
+          });
       });
   }
 
diff --git a/lib/plugins/plugin/lib/utils.js b/lib/plugins/plugin/lib/utils.js
index f4eeffe3dc4..4558925c1cc 100644
--- a/lib/plugins/plugin/lib/utils.js
+++ b/lib/plugins/plugin/lib/utils.js
@@ -69,6 +69,17 @@ module.exports = {
       .then(json => json);
   },
 
+  getPluginInfo(name) {
+    let pluginInfo;
+    if (name.startsWith('@')) {
+      pluginInfo = _.split(name.slice(1), '@', 2);
+      pluginInfo[0] = `@${pluginInfo[0]}`;
+    } else {
+      pluginInfo = _.split(name, '@', 2);
+    }
+    return pluginInfo;
+  },
+
   display(plugins) {
     let message = '';
     if (plugins && plugins.length) {
diff --git a/lib/plugins/plugin/uninstall/uninstall.js b/lib/plugins/plugin/uninstall/uninstall.js
index 5d539c66aaa..bc61114b1f6 100644
--- a/lib/plugins/plugin/uninstall/uninstall.js
+++ b/lib/plugins/plugin/uninstall/uninstall.js
@@ -5,9 +5,9 @@ const childProcess = BbPromise.promisifyAll(require('child_process'));
 const fse = require('../../../utils/fs/fse');
 const path = require('path');
 const _ = require('lodash');
-const pluginUtils = require('../lib/utils');
 const userStats = require('../../../utils/userStats');
 const yamlAstParser = require('../../../utils/yamlAstParser');
+const pluginUtils = require('../lib/utils');
 
 class PluginUninstall {
   constructor(serverless, options) {
@@ -43,7 +43,7 @@ class PluginUninstall {
   }
 
   uninstall() {
-    const pluginInfo = _.split(this.options.name, '@', 2);
+    const pluginInfo = pluginUtils.getPluginInfo(this.options.name);
     this.options.pluginName = pluginInfo[0];
 
     return BbPromise.bind(this)
@@ -51,18 +51,19 @@ class PluginUninstall {
       .then(this.getPlugins)
       .then(plugins => {
         const plugin = plugins.find(item => item.name === this.options.pluginName);
-        if (plugin) {
-          return BbPromise.bind(this)
-            .then(this.uninstallPeerDependencies)
-            .then(this.pluginUninstall)
-            .then(this.removePluginFromServerlessFile)
-            .then(() => {
-              this.serverless.cli.log(`Successfully uninstalled "${this.options.pluginName}"`);
-              return BbPromise.resolve();
-            });
+        if (!plugin) {
+          this.serverless.cli.log(
+            'Plugin not found in serverless registry, continuing to uninstall'
+          );
         }
-        const message = `Plugin "${this.options.pluginName}" not found. Did you spell it correct?`;
-        throw new this.serverless.classes.Error(message);
+        return BbPromise.bind(this)
+          .then(this.uninstallPeerDependencies)
+          .then(this.pluginUninstall)
+          .then(this.removePluginFromServerlessFile)
+          .then(() => {
+            this.serverless.cli.log(`Successfully uninstalled "${this.options.pluginName}"`);
+            return BbPromise.resolve();
+          });
       });
   }
 


Test patch:
diff --git a/lib/plugins/plugin/install/install.test.js b/lib/plugins/plugin/install/install.test.js
index d9666cc83d2..8c96653e0d7 100644
--- a/lib/plugins/plugin/install/install.test.js
+++ b/lib/plugins/plugin/install/install.test.js
@@ -178,7 +178,7 @@ describe('PluginInstall', () => {
       });
     });
 
-    it('should not install the plugin if it can not be found in the registry', () => {
+    it('should install the plugin even if it can not be found in the registry', () => {
       // serverless.yml
       const serverlessYml = {
         service: 'plugin-service',
@@ -186,15 +186,15 @@ describe('PluginInstall', () => {
       };
       serverless.utils.writeFileSync(serverlessYmlFilePath, YAML.dump(serverlessYml));
 
-      pluginInstall.options.name = 'serverless-not-available-plugin';
-      return expect(pluginInstall.install()).to.be.rejected.then(() => {
+      pluginInstall.options.name = 'serverless-not-in-registry-plugin';
+      return expect(pluginInstall.install()).to.be.fulfilled.then(() => {
         expect(validateStub.calledOnce).to.equal(true);
         expect(getPluginsStub.calledOnce).to.equal(true);
-        expect(pluginInstallStub.calledOnce).to.equal(false);
-        expect(consoleLogStub.called).to.equal(false);
-        expect(serverlessErrorStub.calledOnce).to.equal(true);
-        expect(addPluginToServerlessFileStub.calledOnce).to.equal(false);
-        expect(installPeerDependenciesStub.calledOnce).to.equal(false);
+        expect(pluginInstallStub.calledOnce).to.equal(true);
+        expect(consoleLogStub.called).to.equal(true);
+        expect(serverlessErrorStub.calledOnce).to.equal(false);
+        expect(addPluginToServerlessFileStub.calledOnce).to.equal(true);
+        expect(installPeerDependenciesStub.calledOnce).to.equal(true);
       });
     });
 
diff --git a/lib/plugins/plugin/lib/utils.test.js b/lib/plugins/plugin/lib/utils.test.js
index 1a797062f86..055ef876bf8 100644
--- a/lib/plugins/plugin/lib/utils.test.js
+++ b/lib/plugins/plugin/lib/utils.test.js
@@ -149,6 +149,23 @@ describe('PluginUtils', () => {
     });
   });
 
+  describe('#getPluginInfo()', () => {
+    it('should return the plugins name', () => {
+      expect(pluginUtils.getPluginInfo('some-plugin')).to.deep.equal(['some-plugin']);
+    });
+
+    it('should return the plugins name and version', () => {
+      expect(pluginUtils.getPluginInfo('some-plugin@0.1.0')).to.deep.equal([
+        'some-plugin',
+        '0.1.0',
+      ]);
+    });
+
+    it('should support scoped names', () => {
+      expect(pluginUtils.getPluginInfo('@acme/some-plugin')).to.deep.equal(['@acme/some-plugin']);
+    });
+  });
+
   describe('#display()', () => {
     it('should display the plugins if present', () => {
       let expectedMessage = '';
diff --git a/lib/plugins/plugin/uninstall/uninstall.test.js b/lib/plugins/plugin/uninstall/uninstall.test.js
index 2c84c7c9b97..a88cb9c8731 100644
--- a/lib/plugins/plugin/uninstall/uninstall.test.js
+++ b/lib/plugins/plugin/uninstall/uninstall.test.js
@@ -155,7 +155,7 @@ describe('PluginUninstall', () => {
       });
     });
 
-    it('should not uninstall the plugin if it can not be found in the registry', () => {
+    it('should uninstall the plugin even if it can not be found in the registry', () => {
       // serverless.yml
       const serverlessYml = {
         service: 'plugin-service',
@@ -163,15 +163,16 @@ describe('PluginUninstall', () => {
       };
       serverless.utils.writeFileSync(serverlessYmlFilePath, YAML.dump(serverlessYml));
 
-      pluginUninstall.options.name = 'serverless-not-available-plugin';
-      return expect(pluginUninstall.uninstall()).to.be.rejected.then(() => {
+      pluginUninstall.options.name = 'serverless-not-in-registry-plugin';
+
+      return expect(pluginUninstall.uninstall()).to.be.fulfilled.then(() => {
         expect(validateStub.calledOnce).to.equal(true);
         expect(getPluginsStub.calledOnce).to.equal(true);
-        expect(pluginUninstallStub.calledOnce).to.equal(false);
-        expect(consoleLogStub.called).to.equal(false);
-        expect(serverlessErrorStub.calledOnce).to.equal(true);
-        expect(removePluginFromServerlessFileStub.calledOnce).to.equal(false);
-        expect(uninstallPeerDependenciesStub.calledOnce).to.equal(false);
+        expect(pluginUninstallStub.calledOnce).to.equal(true);
+        expect(consoleLogStub.called).to.equal(true);
+        expect(serverlessErrorStub.calledOnce).to.equal(false);
+        expect(removePluginFromServerlessFileStub.calledOnce).to.equal(true);
+        expect(uninstallPeerDependenciesStub.calledOnce).to.equal(true);
       });
     });