Repository: serverless/serverless (JavaScript) — Feature

Issue:
Add support for directories in url of install command
# This is a Feature Proposal

## Description

It would be great to have the possibility to point to a directory when installing a service through the `install` command (rather than only supporting the URL to the whole git repository).

/cc @ac360 



Discussion:
If you upload Serverless project files to a http endpoint, you can install as follow

```
serverless install <http endpoint>
```

Does this sound good?

Sounds good. The idea is that something like this would work:

```
serverless install https://github.com/serverless/examples/tree/master/rest-api-with-dynamodb
```

@horike37, hi, did you start to implement this?
@laardee 
I have not started implementing it yet. However, I think that I will finish the implementation next week and send PR.
Great 👍 this is a nice feature - I was about to refactor one of my repos, but with this feature I dont have to :smile: In github, can you download a zip from certain path of the repository or do you have to download whole thing first?
@horike37 thanks for jumping into this! 💯  🎉 

Also curious how you plan to implement this. Do you know about any specific package which supports this?

Resolving patch:
diff --git a/docs/providers/aws/cli-reference/install.md b/docs/providers/aws/cli-reference/install.md
index aa2046c7b41..8edaa36bb39 100644
--- a/docs/providers/aws/cli-reference/install.md
+++ b/docs/providers/aws/cli-reference/install.md
@@ -29,7 +29,15 @@ serverless install --url https://github.com/some/service
 ### Installing a service from a GitHub URL
 
 ```bash
-serverless install --url https://github.com/johndoe/authentication
+serverless install --url https://github.com/pmuens/serverless-crud
 ```
 
-This example will download the .zip file of the `authentication` service from GitHub, create a new directory with the name `authentication` in the current working directory and unzips the files in this directory.
\ No newline at end of file
+This example will download the .zip file of the `examples` service from GitHub, create a new directory with the name `examples` in the current working directory and unzips the files in this directory.
+
+### Installing a service from a directory in a GitHub URL
+
+```bash
+serverless install --url https://github.com/serverless/examples/tree/master/rest-api-with-dynamodb
+```
+
+This example will download the `rest-api-with-dynamodb` service from GitHub.
diff --git a/lib/plugins/install/install.js b/lib/plugins/install/install.js
index cf50d0d9fd6..8bd46c18d42 100644
--- a/lib/plugins/install/install.js
+++ b/lib/plugins/install/install.js
@@ -4,6 +4,8 @@ const BbPromise = require('bluebird');
 const path = require('path');
 const URL = require('url');
 const download = require('download');
+const os = require('os');
+const fse = require('fs-extra');
 
 class Install {
   constructor(serverless, options) {
@@ -33,7 +35,7 @@ class Install {
   }
 
   install() {
-    const url = URL.parse(this.options.url);
+    const url = URL.parse(this.options.url.replace(/\/$/, ''));
 
     // check if url parameter is a valid url
     if (!url.host) {
@@ -44,9 +46,8 @@ class Install {
     const parsedGitHubUrl = {
       owner: parts[1],
       repo: parts[2],
-      branch: 'master',
+      branch: parts[4] || 'master',
     };
-
     // validate if given url is a valid GitHub url
     if (url.hostname !== 'github.com' || !parsedGitHubUrl.owner || !parsedGitHubUrl.repo) {
       const errorMessage = [
@@ -66,15 +67,27 @@ class Install {
       '.zip',
     ].join('');
 
-    const servicePath = path.join(process.cwd(), parsedGitHubUrl.repo);
+    const endIndex = parts.length - 1;
+    let dirName;
+    let servicePath;
+
+    // check if it's a directory or the whole repository
+    if (parts.length > 4) {
+      dirName = parts[endIndex];
+      // download the repo into a temporary directory
+      servicePath = path.join(os.tmpdir(), parsedGitHubUrl.repo);
+    } else {
+      dirName = parsedGitHubUrl.repo;
+      servicePath = path.join(process.cwd(), dirName);
+    }
 
-    // throw an error if service path already exists
-    if (this.serverless.utils.dirExistsSync(servicePath)) {
-      const errorMessage = `A folder named "${parsedGitHubUrl.repo}" already exists.`;
+    if (this.serverless.utils.dirExistsSync(path.join(process.cwd(), dirName))) {
+      const errorMessage = `A folder named "${dirName}" already exists.`;
       throw new this.serverless.classes.Error(errorMessage);
     }
 
-    this.serverless.cli.log(`Downloading and installing "${parsedGitHubUrl.repo}"...`);
+    this.serverless.cli.log(`Downloading and installing "${dirName}"...`);
+
     const that = this;
 
     // download service
@@ -83,7 +96,17 @@ class Install {
       servicePath,
       { timeout: 30000, extract: true, strip: 1, mode: '755' }
     ).then(() => {
-      that.serverless.cli.log(`Successfully installed "${parsedGitHubUrl.repo}".`);
+      // if it's a directory inside of git
+      if (parts.length > 4) {
+        let directory = servicePath;
+        for (let i = 5; i <= endIndex; i++) {
+          directory = path.join(directory, parts[i]);
+        }
+        that.serverless.utils
+          .copyDirContentsSync(directory, path.join(process.cwd(), parts[endIndex]));
+        fse.removeSync(servicePath);
+      }
+      that.serverless.cli.log(`Successfully installed service "${dirName}".`);
     });
   }
 }


Test patch:
diff --git a/lib/plugins/install/install.test.js b/lib/plugins/install/install.test.js
index 362a811aed0..09bc56f2bb8 100644
--- a/lib/plugins/install/install.test.js
+++ b/lib/plugins/install/install.test.js
@@ -9,16 +9,17 @@ const fse = require('fs-extra');
 const path = require('path');
 const proxyquire = require('proxyquire');
 
-const downloadStub = sinon.stub().returns(BbPromise.resolve());
-const Install = proxyquire('./install.js', {
-  download: downloadStub,
-});
-
 describe('Install', () => {
   let install;
   let serverless;
+  let downloadStub;
+  let Install;
 
   beforeEach(() => {
+    downloadStub = sinon.stub().returns(BbPromise.resolve());
+    Install = proxyquire('./install.js', {
+      download: downloadStub,
+    });
     serverless = new Serverless();
     install = new Install(serverless);
     serverless.init();
@@ -77,5 +78,34 @@ describe('Install', () => {
         expect(downloadStub.args[0][0]).to.equal(`${install.options.url}/archive/master.zip`);
       });
     });
+
+    it('should download the service based on directories in the GitHub URL', () => {
+      install.options = { url: 'https://github.com/serverless/examples/tree/master/rest-api-with-dynamodb' };
+      sinon.stub(serverless.utils, 'copyDirContentsSync').returns(true);
+      sinon.stub(fse, 'removeSync').returns(true);
+
+      return install.install().then(() => {
+        expect(downloadStub.calledOnce).to.equal(true);
+        expect(downloadStub.args[0][0]).to.equal('https://github.com/serverless/examples/archive/master.zip');
+        expect(serverless.utils.copyDirContentsSync.calledOnce).to.equal(true);
+        expect(fse.removeSync.calledOnce).to.equal(true);
+
+        serverless.utils.copyDirContentsSync.restore();
+        fse.removeSync.restore();
+      });
+    });
+
+    it('should throw an error if the same service name exists as directory in Github', () => {
+      install.options = { url: 'https://github.com/serverless/examples/tree/master/rest-api-with-dynamodb' };
+      const tmpDir = testUtils.getTmpDirPath();
+      const serviceDirName = path.join(tmpDir, 'rest-api-with-dynamodb');
+      fse.mkdirsSync(serviceDirName);
+
+      const cwd = process.cwd();
+      process.chdir(tmpDir);
+
+      expect(() => install.install()).to.throw(Error);
+      process.chdir(cwd);
+    });
   });
 });