Repository: serverless/serverless-azure-functions
Pull request #159 (2019-06-05 13:02:26)

feat: Azure Functions Offline plugin
- Added `AzureOfflinePlugin`
- Added `OfflineService` that contains the logic
- Various questions and topics for discussion within team, will post those on each line

Update:
- Added fixes to `func` plugin to account for new package structure
- Added `FuncService` to contain logic and utility functions for plugin

pjlittle: Looking good - just a couple comments. Great work on the tests. :+1: 

Patch:
diff --git a/README.md b/README.md
index 0cfb151..f545fbb 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,33 @@ This will remove the `{functionName}` directory and remove the function from `se
 
 *Note: Add & remove currently only support HTTP triggered functions. For other triggers, you will need to update `serverless.yml` manually
 
+### Running Function App Locally (`offline` plugin)
+
+In order to run a Azure Function App locally, the `azure-functions-core-tools` package needs to be installed from NPM. Since it is only used for local development, we did not include it in the `devDependencies` of `package.json`. To install globally, run:
+
+```bash
+npm i azure-functions-core-tools -g
+```
+
+Then, at the root of your project directory, run:
+
+```bash
+# Builds necessary function bindings files
+sls offline
+# Starts the function app
+npm start
+```
+
+The build process will generate a directory for each of your functions, which will contain a file titled `function.json`. This will contain a relative reference to your handler file & exported function from that file as long as they are referenced correctly in `serverless.yml`.
+
+The `npm start` script just runs `func host start`, but we included the `npm` script for ease of use.
+
+To clean up files generated from the build, you can simply run:
+
+```bash
+sls offline cleanup
+```
+
 ### Deploy, test, and diagnose your Azure service
 
 1. Deploy your new service to Azure! The first time you do this, you will be asked to authenticate with your Azure account, so the `serverless` CLI can manage Functions on your behalf. Simply follow the provided instructions, and the deployment will continue as soon as the authentication process is completed.
diff --git a/package-lock.json b/package-lock.json
index 1181559..b5ab78e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1753,7 +1753,6 @@
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
       "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
-      "optional": true,
       "requires": {
         "kind-of": "^3.0.2",
         "longest": "^1.0.1",
@@ -1926,7 +1925,6 @@
       "version": "4.10.1",
       "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
       "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
-      "optional": true,
       "requires": {
         "bn.js": "^4.0.0",
         "inherits": "^2.0.1",
@@ -2396,8 +2394,7 @@
     "bn.js": {
       "version": "4.11.8",
       "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
-      "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==",
-      "optional": true
+      "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA=="
     },
     "body-parser": {
       "version": "1.19.0",
@@ -2502,8 +2499,7 @@
     "brorand": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
-      "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
-      "optional": true
+      "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
     },
     "browser-process-hrtime": {
       "version": "0.1.3",
@@ -2532,7 +2528,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
       "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
-      "optional": true,
       "requires": {
         "buffer-xor": "^1.0.3",
         "cipher-base": "^1.0.0",
@@ -2569,7 +2564,6 @@
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
       "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
-      "optional": true,
       "requires": {
         "bn.js": "^4.1.0",
         "randombytes": "^2.0.1"
@@ -2666,8 +2660,7 @@
     "buffer-xor": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
-      "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=",
-      "optional": true
+      "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk="
     },
     "builtin-modules": {
       "version": "1.1.1",
@@ -2849,7 +2842,6 @@
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
       "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -3227,7 +3219,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
       "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
-      "optional": true,
       "requires": {
         "cipher-base": "^1.0.1",
         "inherits": "^2.0.1",
@@ -3240,7 +3231,6 @@
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
       "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
-      "optional": true,
       "requires": {
         "cipher-base": "^1.0.3",
         "create-hash": "^1.1.0",
@@ -3699,7 +3689,6 @@
       "version": "6.4.1",
       "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz",
       "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==",
-      "optional": true,
       "requires": {
         "bn.js": "^4.4.0",
         "brorand": "^1.0.1",
@@ -3761,7 +3750,6 @@
       "version": "0.1.7",
       "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz",
       "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==",
-      "optional": true,
       "requires": {
         "prr": "~1.0.1"
       }
@@ -4141,7 +4129,6 @@
       "version": "0.3.5",
       "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
       "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
-      "optional": true,
       "requires": {
         "d": "1",
         "es5-ext": "~0.10.14"
@@ -4157,7 +4144,6 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
       "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
-      "optional": true,
       "requires": {
         "md5.js": "^1.3.4",
         "safe-buffer": "^5.1.1"
@@ -4687,8 +4673,7 @@
         },
         "ansi-regex": {
           "version": "2.1.1",
-          "bundled": true,
-          "optional": true
+          "bundled": true
         },
         "aproba": {
           "version": "1.2.0",
@@ -5053,8 +5038,7 @@
         },
         "safe-buffer": {
           "version": "5.1.2",
-          "bundled": true,
-          "optional": true
+          "bundled": true
         },
         "safer-buffer": {
           "version": "2.1.2",
@@ -5102,7 +5086,6 @@
         "strip-ansi": {
           "version": "3.0.1",
           "bundled": true,
-          "optional": true,
           "requires": {
             "ansi-regex": "^2.0.0"
           }
@@ -5141,13 +5124,11 @@
         },
         "wrappy": {
           "version": "1.0.2",
-          "bundled": true,
-          "optional": true
+          "bundled": true
         },
         "yallist": {
           "version": "3.0.3",
-          "bundled": true,
-          "optional": true
+          "bundled": true
         }
       }
     },
@@ -5455,7 +5436,6 @@
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
       "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -5465,7 +5445,6 @@
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
       "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.3",
         "minimalistic-assert": "^1.0.1"
@@ -5475,7 +5454,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
       "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
-      "optional": true,
       "requires": {
         "hash.js": "^1.0.3",
         "minimalistic-assert": "^1.0.0",
@@ -5794,8 +5772,7 @@
     "is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
-      "optional": true
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
     },
     "is-fullwidth-code-point": {
       "version": "1.0.0",
@@ -7435,8 +7412,7 @@
     "longest": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
-      "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=",
-      "optional": true
+      "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
     },
     "loose-envify": {
       "version": "1.4.0",
@@ -7527,7 +7503,6 @@
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
       "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
-      "optional": true,
       "requires": {
         "hash-base": "^3.0.0",
         "inherits": "^2.0.1",
@@ -7553,7 +7528,6 @@
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
       "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=",
-      "optional": true,
       "requires": {
         "errno": "^0.1.3",
         "readable-stream": "^2.0.1"
@@ -7644,14 +7618,12 @@
     "minimalistic-assert": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
-      "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
-      "optional": true
+      "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
     },
     "minimalistic-crypto-utils": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
-      "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
-      "optional": true
+      "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo="
     },
     "minimatch": {
       "version": "3.0.4",
@@ -8249,7 +8221,6 @@
       "version": "5.1.4",
       "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz",
       "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==",
-      "optional": true,
       "requires": {
         "asn1.js": "^4.0.0",
         "browserify-aes": "^1.0.0",
@@ -8358,7 +8329,6 @@
       "version": "3.0.17",
       "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz",
       "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==",
-      "optional": true,
       "requires": {
         "create-hash": "^1.1.2",
         "create-hmac": "^1.1.4",
@@ -8561,8 +8531,7 @@
     "prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
-      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=",
-      "optional": true
+      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY="
     },
     "pseudomap": {
       "version": "1.0.2",
@@ -8629,7 +8598,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
       "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
-      "optional": true,
       "requires": {
         "safe-buffer": "^5.1.0"
       }
@@ -9042,7 +9010,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
       "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
-      "optional": true,
       "requires": {
         "hash-base": "^3.0.0",
         "inherits": "^2.0.1"
@@ -9432,7 +9399,6 @@
       "version": "2.4.11",
       "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
       "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -9628,8 +9594,7 @@
     "source-list-map": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
-      "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
-      "optional": true
+      "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw=="
     },
     "source-map": {
       "version": "0.7.3",
@@ -10186,8 +10151,7 @@
     "tapable": {
       "version": "0.2.9",
       "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.9.tgz",
-      "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==",
-      "optional": true
+      "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A=="
     },
     "tar-stream": {
       "version": "1.6.2",
@@ -11037,7 +11001,6 @@
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz",
       "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==",
-      "optional": true,
       "requires": {
         "source-list-map": "^2.0.0",
         "source-map": "~0.6.1"
@@ -11046,8 +11009,7 @@
         "source-map": {
           "version": "0.6.1",
           "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-          "optional": true
+          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
         }
       }
     },
diff --git a/src/index.ts b/src/index.ts
index d455073..42c35e9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,6 +15,7 @@ import { AzureLoginPlugin } from "./plugins/login/loginPlugin";
 import { AzureApimServicePlugin } from "./plugins/apim/apimServicePlugin";
 import { AzureApimFunctionPlugin } from "./plugins/apim/apimFunctionPlugin";
 import { AzureFuncPlugin } from "./plugins/func/azureFunc";
+import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin"
 
 
 export default class AzureIndex {
@@ -32,6 +33,7 @@ export default class AzureIndex {
     this.serverless.pluginManager.addPlugin(AzureApimServicePlugin);
     this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin);
     this.serverless.pluginManager.addPlugin(AzureFuncPlugin);
+    this.serverless.pluginManager.addPlugin(AzureOfflinePlugin);
   }
 }
 
diff --git a/src/plugins/func/azureFunc.ts b/src/plugins/func/azureFunc.ts
index 86c448f..09a8201 100644
--- a/src/plugins/func/azureFunc.ts
+++ b/src/plugins/func/azureFunc.ts
@@ -1,12 +1,10 @@
-import fs from "fs";
-import path from "path";
-import rimraf from "rimraf";
 import Serverless from "serverless";
-import { FuncPluginUtils } from "./funcUtils";
+import { FuncService } from "../../services/funcService";
 
 export class AzureFuncPlugin {
   public hooks: { [eventName: string]: Promise<any> };
   public commands: any;
+  private service: FuncService;
 
 
   public constructor(private serverless: Serverless, private options: Serverless.Options) {
@@ -50,6 +48,8 @@ export class AzureFuncPlugin {
         }
       }
     }
+
+    this.service = new FuncService(serverless, options);
   }
 
   private async func() {
@@ -57,57 +57,10 @@ export class AzureFuncPlugin {
   }
 
   private async add() {
-    if (!("name" in this.options)) {
-      this.serverless.cli.log("Need to provide a name of function to add");
-      return;
-    }
-    const funcToAdd = this.options["name"]
-    const exists = fs.existsSync(funcToAdd);
-    if (exists) {
-      this.serverless.cli.log(`Function ${funcToAdd} already exists`);
-      return;
-    }
-    this.createFunctionDir(funcToAdd);
-    this.addToServerlessYml(funcToAdd);
-  }
-
-  private createFunctionDir(name: string) {
-    this.serverless.cli.log("Creating function dir");
-    try {
-      fs.mkdirSync(name);
-    } catch (e) {
-      this.serverless.cli.log(`Error making directory ${e}`);
-    }
-    this.serverless.utils.writeFileSync(path.join(name, "index.js"), FuncPluginUtils.getFunctionHandler(name));
-    this.serverless.utils.writeFileSync(path.join(name, "function.json"), FuncPluginUtils.getFunctionJsonString(name, this.options));
-  }
-
-  private addToServerlessYml(name: string) {
-    this.serverless.cli.log("Adding to serverless.yml");
-    const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless);
-    functionYml[name] = FuncPluginUtils.getFunctionSlsObject(name, this.options);
-    FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml);
+    this.service.add();
   }
 
   private async remove() {
-    if (!("name" in this.options)) {
-      this.serverless.cli.log("Need to provide a name of function to remove");
-      return;
-    }
-    const funcToRemove = this.options["name"];
-    const exists = fs.existsSync(funcToRemove);
-    if (!exists) {
-      this.serverless.cli.log(`Function ${funcToRemove} does not exist`);
-      return;
-    }
-    this.serverless.cli.log(`Removing ${funcToRemove}`);
-    rimraf.sync(funcToRemove);
-    await this.removeFromServerlessYml(funcToRemove);
-  }
-
-  private async removeFromServerlessYml(name: string) {
-    const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless);
-    delete functionYml[name];
-    FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml)
+    this.service.remove();
   }
 }
\ No newline at end of file
diff --git a/src/plugins/func/bindingTemplates/http.json b/src/plugins/func/bindingTemplates/http.json
deleted file mode 100644
index 9f2d4d8..0000000
--- a/src/plugins/func/bindingTemplates/http.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
-  "bindings": [
-    {
-      "authLevel": "anonymous",
-      "type": "httpTrigger",
-      "direction": "in",
-      "name": "req",
-      "methods": [
-        "get",
-        "post"
-      ]
-    },
-    {
-      "type": "http",
-      "direction": "out",
-      "name": "res"
-    }
-  ]
-}
\ No newline at end of file
diff --git a/src/plugins/func/funcUtils.ts b/src/plugins/func/funcUtils.ts
deleted file mode 100644
index afc1449..0000000
--- a/src/plugins/func/funcUtils.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import yaml from "js-yaml";
-import Serverless from "serverless";
-import httpBinding from "./bindingTemplates/http.json"
-
-export class FuncPluginUtils {
-
-  public static getServerlessYml(sls: Serverless) {
-    return sls.utils.readFileSync("serverless.yml");
-  }
-
-  public static getFunctionsYml(sls: Serverless, serverlessYml?: any) {
-    serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls);
-    return serverlessYml["functions"];
-  }
-
-  public static updateFunctionsYml(sls: Serverless, functionYml: any, serverlessYml?: any) {
-    serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls);
-    serverlessYml["functions"] = functionYml;
-    sls.utils.writeFileSync("serverless.yml", yaml.dump(serverlessYml));
-  }
-
-  public static getFunctionHandler(name: string) {
-    return `"use strict";
-
-module.exports.handler = async function (context, req) {
-  context.log("JavaScript HTTP trigger function processed a request.");
-
-  if (req.query.name || (req.body && req.body.name)) {
-    context.res = {
-      // status: 200, /* Defaults to 200 */
-      body: "${name} " + (req.query.name || req.body.name)
-    };
-  }
-  else {
-    context.res = {
-      status: 400,
-      body: "Please pass a name on the query string or in the request body"
-    };
-  }
-};`
-  }
-
-  public static getFunctionJsonString(name: string, options: any) {
-    // TODO: This is where we would just generate function JSON from SLS object
-    // using getFunctionSlsObject(name, options). Currently defaulting to http in and out
-    return JSON.stringify(httpBinding, null, 2);
-  }
-
-  public static getFunctionSlsObject(name: string, options: any) {
-    return FuncPluginUtils.defaultFunctionSlsObject(name);
-  }
-
-  private static defaultFunctionSlsObject(name: string) {
-    return {
-      handler: `src/handlers/${name}.handler`,
-      events: FuncPluginUtils.httpEvents()
-    }
-  }
-
-  private static httpEvents() {
-    return [
-      {
-        http: true,
-        "x-azure-settings": {
-          authLevel: "anonymous"
-        }
-      },
-      {
-        http: true,
-        "x-azure-settings": {
-          direction: "out",
-          name: "res"
-        }
-      },
-    ]
-  }
-}
\ No newline at end of file
diff --git a/src/plugins/offline/azureOfflinePlugin.ts b/src/plugins/offline/azureOfflinePlugin.ts
new file mode 100644
index 0000000..2a96789
--- /dev/null
+++ b/src/plugins/offline/azureOfflinePlugin.ts
@@ -0,0 +1,54 @@
+import Serverless from "serverless";
+import { OfflineService } from "../../services/offlineService";
+
+export class AzureOfflinePlugin {
+  public hooks: { [eventName: string]: Promise<any> };
+  public commands: any;
+  private offlineService: OfflineService;
+
+  public constructor(private serverless: Serverless, private options: Serverless.Options) {
+    this.offlineService = new OfflineService(this.serverless, this.options);
+
+    this.hooks = {
+      "before:offline:offline": this.azureOfflineBuild.bind(this),
+      "offline:build:build": this.azureOfflineBuild.bind(this),
+      "offline:offline": this.azureOfflineStart.bind(this),
+      "offline:cleanup:cleanup": this.azureOfflineCleanup.bind(this),
+    };
+
+    this.commands = {
+      offline: {
+        usage: "Start Azure Function App offline",
+        lifecycleEvents: [
+          "offline",
+        ],
+        commands: {
+          build: {
+            usage: "Build necessary files for running Azure Function App offline",
+            lifecycleEvents: [
+              "build",
+            ]
+          },
+          cleanup: {
+            usage: "Clean up files from offline development",
+            lifecycleEvents: [
+              "cleanup"
+            ]
+          }
+        }
+      }
+    }
+  }
+
+  private async azureOfflineBuild(){
+    this.offlineService.build();
+  }
+
+  private async azureOfflineStart(){
+    this.offlineService.start();
+  }
+
+  private async azureOfflineCleanup(){
+    this.offlineService.cleanup();
+  }
+}
\ No newline at end of file
diff --git a/src/services/baseService.ts b/src/services/baseService.ts
index cbaa9b9..d95bd16 100644
--- a/src/services/baseService.ts
+++ b/src/services/baseService.ts
@@ -1,7 +1,7 @@
-import Serverless from "serverless";
 import axios from "axios";
-import request from "request";
 import fs from "fs";
+import request from "request";
+import Serverless from "serverless";
 import { Guard } from "../shared/guard";
 
 export abstract class BaseService {
@@ -12,7 +12,7 @@ export abstract class BaseService {
   protected resourceGroup: string;
   protected deploymentName: string;
 
-  protected constructor(protected serverless: Serverless, protected options?: Serverless.Options) {
+  protected constructor(protected serverless: Serverless, protected options?: Serverless.Options, authenticate = true) {
     Guard.null(serverless);
 
     this.baseUrl = "https://management.azure.com";
@@ -22,14 +22,10 @@ export abstract class BaseService {
     this.resourceGroup = serverless.service.provider["resourceGroup"] || `${this.serviceName}-rg`;
     this.deploymentName = serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`;
 
-    if (!this.credentials) {
+    if (!this.credentials && authenticate) {
       throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`);
     }
   }
-
-  protected log(message: string) {
-    this.serverless.cli.log(message);
-  }
   
   /**
    * Sends an API request using axios HTTP library
@@ -73,4 +69,16 @@ export abstract class BaseService {
         }));
     });
   }
+
+  protected log(message: string) {
+    this.serverless.cli.log(message);
+  }
+
+  protected slsFunctions() {
+    return this.serverless.service["functions"];
+  }
+
+  protected slsConfigFile(): string {
+    return ("config" in this.options) ? this.options["config"] : "serverless.yml";
+  }
 }
diff --git a/src/services/funcService.ts b/src/services/funcService.ts
new file mode 100644
index 0000000..b84cda7
--- /dev/null
+++ b/src/services/funcService.ts
@@ -0,0 +1,128 @@
+import yaml from "js-yaml";
+import rimraf from "rimraf";
+import Serverless from "serverless";
+import { BaseService } from "./baseService";
+import fs from "fs";
+
+export class FuncService extends BaseService {
+  public constructor(serverless: Serverless, options: Serverless.Options){
+    super(serverless, options, false);
+  }
+
+  public add() {
+    const functionName = this.options["name"];
+    if (!functionName) {
+      this.log("Need to provide a name of function to add");
+      return;
+    }
+    if (this.exists(functionName)) {
+      this.serverless.cli.log(`Function ${functionName} already exists`);
+      return;
+    }
+    this.createHandler(functionName);
+    this.addToServerlessYml(functionName);
+  }
+
+  public remove() {
+    const functionName = this.options["name"];
+    if (!functionName) {
+      this.log("Need to provide a name of function to remove");
+      return;
+    }
+    if (!this.exists(functionName)) {
+      this.log(`Function ${functionName} does not exist`);
+      return;
+    }
+    const fileName = `${functionName}.js`;
+    if (fs.existsSync(fileName)) {
+      fs.unlinkSync(fileName);
+    }
+    if (fs.existsSync(functionName)) {
+      rimraf.sync(functionName);
+    }
+    this.removeFromServerlessYml(functionName);
+  }
+
+  private exists(functionName: string) {
+    return (functionName in this.slsFunctions());
+  }
+
+  private createHandler(functionName: string) {
+    this.serverless.utils.writeFileSync(`./${functionName}.js`, this.getFunctionHandler(functionName))
+  }
+
+  private addToServerlessYml(functionName: string) {
+    const functions = this.slsFunctions();
+    functions[functionName] = this.getFunctionSlsObject(functionName)
+    this.updateFunctionsYml(functions)
+  }
+
+  private removeFromServerlessYml(functionName: string) {
+    const functions = this.slsFunctions();
+    delete functions[functionName];
+    this.updateFunctionsYml(functions)
+  }
+
+  private getServerlessYml() {
+    return this.serverless.utils.readFileSync(this.slsConfigFile());
+  }
+
+  private updateFunctionsYml(functionYml: any) {
+    const serverlessYml = this.getServerlessYml();
+    serverlessYml["functions"] = functionYml;
+    this.serverless.utils.writeFileSync(
+      this.slsConfigFile(),
+      yaml.dump(serverlessYml)
+    );
+  }
+
+  private getFunctionHandler(name: string) {
+    return `"use strict";
+
+module.exports.handler = async function (context, req) {
+  context.log("JavaScript HTTP trigger function processed a request.");
+
+  if (req.query.name || (req.body && req.body.name)) {
+    context.res = {
+      // status: 200, /* Defaults to 200 */
+      body: "${name} " + (req.query.name || req.body.name)
+    };
+  }
+  else {
+    context.res = {
+      status: 400,
+      body: "Please pass a name on the query string or in the request body"
+    };
+  }
+};`
+  }
+
+  private getFunctionSlsObject(name: string) {
+    return this.defaultFunctionSlsObject(name);
+  }
+
+  private defaultFunctionSlsObject(name: string) {
+    return {
+      handler: `${name}.handler`,
+      events: this.httpEvents()
+    }
+  }
+
+  private httpEvents() {
+    return [
+      {
+        http: true,
+        "x-azure-settings": {
+          authLevel: "anonymous"
+        }
+      },
+      {
+        http: true,
+        "x-azure-settings": {
+          direction: "out",
+          name: "res"
+        }
+      },
+    ]
+  }
+}
\ No newline at end of file
diff --git a/src/services/offlineService.ts b/src/services/offlineService.ts
new file mode 100644
index 0000000..5661c83
--- /dev/null
+++ b/src/services/offlineService.ts
@@ -0,0 +1,58 @@
+import Serverless from "serverless";
+import fs from "fs";
+import { BaseService } from "./baseService";
+import { PackageService } from "./packageService";
+
+export class OfflineService extends BaseService {
+
+  private packageService: PackageService;
+
+  private localFiles = {
+    "local.settings.json": JSON.stringify({
+      IsEncrypted: false,
+      Values: {
+        AzureWebJobsStorage: "",
+        FUNCTIONS_WORKER_RUNTIME: "node"
+      }
+    }),
+  }
+  
+  public constructor(serverless: Serverless, options: Serverless.Options) {
+    super(serverless, options, false);
+    this.packageService = new PackageService(serverless);
+  }
+
+  public async build() {
+    this.log("Building offline service");
+    await this.packageService.createBindings();
+    const filenames = Object.keys(this.localFiles);
+    for (const filename of filenames) {
+      if (!fs.existsSync(filename)){
+        fs.writeFileSync(
+          filename,
+          this.localFiles[filename]
+        )
+      }
+    }
+    this.log("Finished building offline service");
+  }
+
+  public async cleanup() {
+    this.log("Cleaning up offline files")
+    await this.packageService.cleanUp();
+    const filenames = Object.keys(this.localFiles);
+    for (const filename of filenames) {
+      if (fs.existsSync(filename)){
+        this.log(`Removing file '${filename}'`);
+        fs.unlinkSync(filename)
+      }
+    }
+    this.log("Finished cleaning up offline files");
+  }
+
+  public start() {
+    this.log("Run 'npm start' or 'func host start' to run service locally");
+    this.log("Make sure you have Azure Functions Core Tools installed");
+    this.log("If not installed run 'npm i azure-functions-core-tools -g")
+  }
+}
\ No newline at end of file
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 979bb9a..4ed98f7 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -20,5 +20,5 @@ export const constants = {
   queue: "queue",
   queueName: "queueName",
   xAzureSettings: "x-azure-settings",
-  entryPoint: "entryPoint"
+  entryPoint: "entryPoint",
 }
\ No newline at end of file
diff --git a/src/shared/utils.ts b/src/shared/utils.ts
index a2b944f..9b8654a 100644
--- a/src/shared/utils.ts
+++ b/src/shared/utils.ts
@@ -83,19 +83,19 @@ export class Utils {
     functionsJson.bindings = bindings;
     params.functionsJson = functionsJson;
 
-    const entryPointAndHandlerPath = Utils.getEntryPointAndHandlerPath(handler);
+    let { handlerPath, entryPoint } = Utils.getEntryPointAndHandlerPath(handler);
     if (functionObject["scriptFile"]) {
-      entryPointAndHandlerPath.handlerPath = functionObject["scriptFile"];
+      handlerPath = functionObject["scriptFile"];
     }
 
     return {
-      entryPoint: entryPointAndHandlerPath.entryPoint,
-      handlerPath: relative(functionName, entryPointAndHandlerPath.handlerPath),
+      entryPoint,
+      handlerPath: relative(functionName, handlerPath),
       params: params
     };
   }
 
-  public static getEntryPointAndHandlerPath(handler) {
+  public static getEntryPointAndHandlerPath(handler: string) {
     let handlerPath = "handler.js";
     let entryPoint = handler;
     const handlerSplit = handler.split(".");
@@ -104,6 +104,7 @@ export class Utils {
       entryPoint = handlerSplit[handlerSplit.length - 1];
       handlerPath = `${handler.substring(0, handler.lastIndexOf("."))}.js`;
     }
+    
     const metaData = {
       entryPoint: entryPoint,
       handlerPath: handlerPath


Test patch:
diff --git a/src/plugins/func/azureFunc.test.ts b/src/plugins/func/azureFunc.test.ts
index dfe7ab9..9c490c1 100644
--- a/src/plugins/func/azureFunc.test.ts
+++ b/src/plugins/func/azureFunc.test.ts
@@ -1,10 +1,9 @@
 import fs from "fs";
 import mockFs from "mock-fs";
-import path from "path";
+import rimraf from "rimraf";
 import { MockFactory } from "../../test/mockFactory";
 import { invokeHook } from "../../test/utils";
 import { AzureFuncPlugin } from "./azureFunc";
-import rimraf from "rimraf";
 
 describe("Azure Func Plugin", () => {
 
@@ -34,6 +33,10 @@ describe("Azure Func Plugin", () => {
       mockFs.restore();
     });
 
+    afterEach(() => {
+      jest.clearAllMocks();
+    })
+
     it("returns with missing name", async () => {
       const sls = MockFactory.createTestServerless();
       const options = MockFactory.createTestServerlessOptions();
@@ -47,37 +50,31 @@ describe("Azure Func Plugin", () => {
     it("returns with pre-existing function", async () => {
       const sls = MockFactory.createTestServerless();
       const options = MockFactory.createTestServerlessOptions();
-      options["name"] = "myExistingFunction";
+      options["name"] = "hello";
 
       const plugin = new AzureFuncPlugin(sls, options);
       await invokeHook(plugin, "func:add:add");
 
-      expect(sls.cli.log).toBeCalledWith("Function myExistingFunction already exists");
+      expect(sls.cli.log).toBeCalledWith("Function hello already exists");
     });
 
-    it("creates function directory and updates serverless.yml", async () => {
+    it("creates function handler and updates serverless.yml", async () => {
       const sls = MockFactory.createTestServerless();
       const options = MockFactory.createTestServerlessOptions();
       const functionName = "myFunction";
       options["name"] = functionName;
-      const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata();
+      const expectedFunctionsYml = MockFactory.createTestSlsFunctionConfig();
       expectedFunctionsYml[functionName] = MockFactory.createTestFunctionMetadata(functionName);
 
       const plugin = new AzureFuncPlugin(sls, options);
-      const mkdirSpy = jest.spyOn(fs, "mkdirSync");
 
       await invokeHook(plugin, "func:add:add");
 
-      expect(mkdirSpy).toBeCalledWith(functionName);
-
       const writeFileCalls = (sls.utils.writeFileSync as any).mock.calls;
-      expect(writeFileCalls[0][0]).toBe(path.join(functionName, "index.js"));
-      expect(writeFileCalls[1][0]).toBe(path.join(functionName, "function.json"));
+      expect(writeFileCalls[0][0]).toBe(`./${functionName}.js`);
 
-      expect(writeFileCalls[2][0]).toBe("serverless.yml");
-      expect(writeFileCalls[2][1]).toBe(MockFactory.createTestServerlessYml(true, expectedFunctionsYml));
-
-      mkdirSpy.mockRestore();
+      expect(writeFileCalls[1][0]).toBe("serverless.yml");
+      expect(writeFileCalls[1][1]).toBe(MockFactory.createTestServerlessYml(true, expectedFunctionsYml));
     });
   });
 
@@ -85,8 +82,8 @@ describe("Azure Func Plugin", () => {
 
     beforeAll(() => {
       mockFs({
+        "index.js": "contents",
         "function1": {
-          "index.js": "contents",
           "function.json": "contents",
         },
       });
@@ -114,15 +111,25 @@ describe("Azure Func Plugin", () => {
     });
 
     it("deletes directory and updates serverless.yml", async () => {
+      mockFs({
+        "hello.js": "contents",
+        hello: {
+          "function.json": "contents",
+        }
+      })
       const sls = MockFactory.createTestServerless();
       const options = MockFactory.createTestServerlessOptions();
       const plugin = new AzureFuncPlugin(sls, options);
-      const functionName = "function1";
+      const functionName = "hello";
       options["name"] = functionName;
+      const unlinkSpy = jest.spyOn(fs, "unlinkSync");
       const rimrafSpy = jest.spyOn(rimraf, "sync");
       await invokeHook(plugin, "func:remove:remove");
+      expect(unlinkSpy).toBeCalledWith(`${functionName}.js`)
       expect(rimrafSpy).toBeCalledWith(functionName);
-      const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata();
+      unlinkSpy.mockRestore();
+      rimrafSpy.mockRestore();
+      const expectedFunctionsYml = MockFactory.createTestSlsFunctionConfig();
       delete expectedFunctionsYml[functionName];
       expect(sls.utils.writeFileSync).toBeCalledWith("serverless.yml", MockFactory.createTestServerlessYml(true, expectedFunctionsYml))
     });
diff --git a/src/plugins/func/funcUtils.test.ts b/src/plugins/func/funcUtils.test.ts
deleted file mode 100644
index 8348045..0000000
--- a/src/plugins/func/funcUtils.test.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { MockFactory } from "../../test/mockFactory";
-import { FuncPluginUtils } from "./funcUtils";
-
-describe("Func Utils", () => {
-  
-  it("gets functions yml", () => {
-    const sls = MockFactory.createTestServerless();
-    const funcYaml = FuncPluginUtils.getFunctionsYml(sls);
-    expect(funcYaml).toEqual(MockFactory.createTestFunctionsMetadata());
-  });
-
-  it("updates functions yml", () => {
-    const updatedFunctions = MockFactory.createTestFunctionsMetadata(3);
-    const originalSls = MockFactory.createTestServerlessYml(false, 2);
-    const sls = MockFactory.createTestServerless();
-    FuncPluginUtils.updateFunctionsYml(sls, updatedFunctions, originalSls);
-    const calls = (sls.utils.writeFileSync as any).mock.calls[0]
-    expect(calls[0]).toBe("serverless.yml");
-    const expected = MockFactory.createTestServerlessYml(
-      true, 
-      MockFactory.createTestFunctionsMetadata(3)
-    );
-    expect(calls[1]).toBe(expected);
-  });
-
-  it("adds new function name to function handler", () => {
-    const name = "This is my function name"
-    const handler = FuncPluginUtils.getFunctionHandler(name);
-    expect(handler)
-      .toContain(`body: "${name} " + (req.query.name || req.body.name)`);    
-  });
-});
\ No newline at end of file
diff --git a/src/plugins/offline/azureOfflinePlugin.test.ts b/src/plugins/offline/azureOfflinePlugin.test.ts
new file mode 100644
index 0000000..f786b78
--- /dev/null
+++ b/src/plugins/offline/azureOfflinePlugin.test.ts
@@ -0,0 +1,82 @@
+import fs from "fs";
+import mockFs from "mock-fs";
+import path from "path";
+import Serverless from "serverless";
+import { MockFactory } from "../../test/mockFactory";
+import { invokeHook } from "../../test/utils";
+import { AzureOfflinePlugin } from "./azureOfflinePlugin";
+
+describe("Azure Offline Plugin", () => {
+
+  function createPlugin(sls?: Serverless, options?: Serverless.Options) {
+    return new AzureOfflinePlugin(
+      sls || MockFactory.createTestServerless(),
+      options || MockFactory.createTestServerlessOptions(),
+    )
+  }
+
+  beforeEach(() => {
+    mockFs({})
+  });
+
+  afterEach(() => {
+    mockFs.restore();
+  })
+
+  it("invokes build hook", async () => {
+    const sls = MockFactory.createTestServerless();
+    const plugin = createPlugin(sls);
+    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
+    await invokeHook(plugin, "offline:build:build");
+    const calls = writeFileSpy.mock.calls;
+    const functionNames = sls.service.getAllFunctions();
+    expect(calls).toHaveLength(functionNames.length + 1);
+    for (let i = 0; i < functionNames.length; i++) {
+      const name = functionNames[i];
+      expect(calls[i][0]).toEqual(`${name}${path.sep}function.json`)
+      expect(
+        JSON.parse(calls[i][1])
+      ).toEqual(
+        MockFactory.createTestBindingsObject(`..${path.sep}${name}.js`)
+      );
+    }
+    expect(calls[calls.length - 1][0]).toEqual("local.settings.json");
+    writeFileSpy.mockRestore();
+  });
+
+  it("invokes offline hook", async () => {
+    const sls = MockFactory.createTestServerless();
+    const plugin = createPlugin(sls);
+    await invokeHook(plugin, "offline:offline");
+    // Trivial test for now. In the future, this process
+    // may spawn the start process itself rather than telling
+    // the user how to do it.
+    expect(sls.cli.log).toBeCalledTimes(3);
+  });
+
+  it("invokes cleanup hook", async () => {
+    mockFs({
+      hello: {
+        "function.json": "contents"
+      },
+      goodbye: {
+        "function.json": "contents"
+      },
+      "local.settings.json": "contents",
+    });
+    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
+    const rmdirSpy = jest.spyOn(fs, "rmdirSync")
+    const sls = MockFactory.createTestServerless();
+    const plugin = createPlugin(sls);
+    await invokeHook(plugin, "offline:cleanup:cleanup");
+    const unlinkCalls = unlinkSpy.mock.calls;
+    expect(unlinkCalls[0][0]).toBe(`hello${path.sep}function.json`);
+    expect(unlinkCalls[1][0]).toBe(`goodbye${path.sep}function.json`);
+    expect(unlinkCalls[2][0]).toBe("local.settings.json");
+    const rmdirCalls = rmdirSpy.mock.calls;
+    expect(rmdirCalls[0][0]).toBe("hello");
+    expect(rmdirCalls[1][0]).toBe("goodbye");
+    unlinkSpy.mockRestore();
+    rmdirSpy.mockRestore();
+  });
+});
\ No newline at end of file
diff --git a/src/services/offlineService.test.ts b/src/services/offlineService.test.ts
new file mode 100644
index 0000000..c416800
--- /dev/null
+++ b/src/services/offlineService.test.ts
@@ -0,0 +1,82 @@
+import fs from "fs";
+import mockFs from "mock-fs";
+import path from "path";
+import Serverless from "serverless";
+import { MockFactory } from "../test/mockFactory";
+import { OfflineService } from "./offlineService";
+
+describe("Offline Service", () => {
+
+  function createService(sls?: Serverless): OfflineService {
+    return new OfflineService(
+      sls || MockFactory.createTestServerless(),
+      MockFactory.createTestServerlessOptions(),
+    )
+  }
+
+  beforeEach(() => {
+    // Mocking the file system so that files are not created in project directory
+    mockFs({})
+  });
+
+  afterEach(() => {
+    mockFs.restore();
+  })
+
+  it("builds required files for offline execution", async () => {
+    const sls = MockFactory.createTestServerless();
+    const service = createService(sls);
+    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
+    await service.build();
+    const calls = writeFileSpy.mock.calls;
+    const functionNames = sls.service.getAllFunctions();
+    expect(calls).toHaveLength(functionNames.length + 1);
+    for (let i = 0; i < functionNames.length; i++) {
+      const name = functionNames[i];
+      expect(calls[i][0]).toEqual(`${name}${path.sep}function.json`)
+      expect(
+        JSON.parse(calls[i][1])
+      ).toEqual(
+        MockFactory.createTestBindingsObject(`..${path.sep}${name}.js`)
+      );
+    }
+    expect(calls[calls.length - 1][0]).toEqual("local.settings.json");
+    writeFileSpy.mockRestore();
+  });
+
+  it("cleans up functions files", async () => {
+    mockFs({
+      hello: {
+        "function.json": "contents"
+      },
+      goodbye: {
+        "function.json": "contents"
+      },
+      "local.settings.json": "contents",
+    })
+    const sls = MockFactory.createTestServerless();
+    const service = createService(sls);
+    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
+    const rmdirSpy = jest.spyOn(fs, "rmdirSync")
+    await service.cleanup();
+    const unlinkCalls = unlinkSpy.mock.calls;
+    expect(unlinkCalls[0][0]).toBe(`hello${path.sep}function.json`);
+    expect(unlinkCalls[1][0]).toBe(`goodbye${path.sep}function.json`);
+    expect(unlinkCalls[2][0]).toBe("local.settings.json");
+    const rmdirCalls = rmdirSpy.mock.calls;
+    expect(rmdirCalls[0][0]).toBe("hello");
+    expect(rmdirCalls[1][0]).toBe("goodbye");
+    unlinkSpy.mockRestore();
+    rmdirSpy.mockRestore();
+  });
+
+  it("instructs users how to run locally", async () => {
+    const sls = MockFactory.createTestServerless();
+    const service = createService(sls);
+    await service.start();
+    // Trivial test for now. In the future, this process
+    // may spawn the start process itself rather than telling
+    // the user how to do it.
+    expect(sls.cli.log).toBeCalledTimes(3);
+  });
+});
\ No newline at end of file
diff --git a/src/shared/binding.test.ts b/src/shared/binding.test.ts
index c78e19b..870385d 100644
--- a/src/shared/binding.test.ts
+++ b/src/shared/binding.test.ts
@@ -13,6 +13,7 @@ describe("Bindings", () => {
   beforeEach(() => {
     sls = MockFactory.createTestServerless();
     sls.config.servicePath = process.cwd();
+    jest.clearAllMocks();
   });
 
   it("should get bindings metadata from serverless", () => {
diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts
index d577d76..3f37d70 100644
--- a/src/shared/utils.test.ts
+++ b/src/shared/utils.test.ts
@@ -1,7 +1,7 @@
-import Serverless from "serverless";
 import path from "path";
+import Serverless from "serverless";
 import { MockFactory } from "../test/mockFactory";
-import { Utils, FunctionMetadata } from "./utils";
+import { FunctionMetadata, Utils } from "./utils";
 
 describe("utils", () => {
   let sls: Serverless;
@@ -19,6 +19,7 @@ describe("utils", () => {
 
   it("resolves handler when handler code is outside function folders", () => {
     sls.service["functions"].hello.handler = "src/handlers/hello.handler";
+    MockFactory.updateService(sls);
 
     const functions = sls.service.getAllFunctions();
     const metadata = Utils.getFunctionMetaData(functions[0], sls);
@@ -34,6 +35,7 @@ describe("utils", () => {
 
   it("resolves handler when code is in function folder", () => {
     sls.service["functions"].hello.handler = "hello/index.handler";
+    MockFactory.updateService(sls);
 
     const functions = sls.service.getAllFunctions();
     const metadata = Utils.getFunctionMetaData(functions[0], sls);
@@ -49,6 +51,7 @@ describe("utils", () => {
 
   it("resolves handler when code is at the project root", () => {
     sls.service["functions"].hello.handler = "hello.handler";
+    MockFactory.updateService(sls);
 
     const functions = sls.service.getAllFunctions();
     const metadata = Utils.getFunctionMetaData(functions[0], sls);
diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts
index bb73218..7efaaa3 100644
--- a/src/test/mockFactory.ts
+++ b/src/test/mockFactory.ts
@@ -29,10 +29,39 @@ export class MockFactory {
     sls.pluginManager = getAttribute(config, "pluginManager", MockFactory.createTestPluginManager());
     sls.variables = getAttribute(config, "variables", MockFactory.createTestVariables());
     sls.service = getAttribute(config, "service", MockFactory.createTestService());
-    sls.service.getFunction = jest.fn((functionName) => sls.service["functions"][functionName]);
+    sls.config.servicePath = "";
     return sls;
   }
 
+  public static createTestService(functions?): Service {
+    if (!functions) {
+      functions = MockFactory.createTestSlsFunctionConfig()
+    }
+    const serviceName = "serviceName";
+    return {
+      getAllFunctions: jest.fn(() => Object.keys(functions)),
+      getFunction: jest.fn((name: string) => functions[name]),
+      getAllEventsInFunction: jest.fn(),
+      getAllFunctionsNames: jest.fn(() => Object.keys(functions)),
+      getEventInFunction: jest.fn(),
+      getServiceName: jest.fn(() => serviceName),
+      load: jest.fn(),
+      mergeResourceArrays: jest.fn(),
+      setFunctionNames: jest.fn(),
+      update: jest.fn(),
+      validate: jest.fn(),
+      custom: null,
+      provider: MockFactory.createTestAzureServiceProvider(),
+      service: serviceName,
+      artifact: "app.zip",
+      functions
+    } as any as Service;
+  }
+
+  public static updateService(sls: Serverless) {
+    sls.service = MockFactory.createTestService(sls.service["functions"]);
+  }
+
   public static createTestServerlessOptions(): Serverless.Options {
     return {
       extraServicePath: null,
@@ -71,6 +100,25 @@ export class MockFactory {
     };
   }
 
+  public static createTestFunctions(functionCount = 3) {
+    const functions = []
+    for (let i = 0; i < functionCount; i++) {
+      functions.push(MockFactory.createTestFunction(`function${i + 1}`));
+    }
+    return functions;
+  }
+
+  public static createTestFunction(name: string = "TestFunction") {
+    return {
+      properties: {
+        name,
+        config: {
+          bindings: MockFactory.createTestBindings()
+        }
+      }
+    }
+  }
+
   public static createTestAzureCredentials(): TokenClientCredentials {
     const credentials = {
       getToken: jest.fn(() => {
@@ -141,19 +189,10 @@ export class MockFactory {
       plugins: [
         "serverless-azure-functions"
       ],
-      functions: functionMetadata || MockFactory.createTestFunctionsMetadata(2),
+      functions: functionMetadata || MockFactory.createTestSlsFunctionConfig(),
     }
     return (asYaml) ? yaml.dump(data) : data;
-  }
-
-  public static createTestFunctionsMetadata(functionCount = 2) {
-    const data = {}
-    for (let i = 0; i < functionCount; i++) {
-      const functionName = `function${i + 1}`;
-      data[functionName] = MockFactory.createTestFunctionMetadata(functionName);
-    }
-    return data;
-  }
+  } 
 
   public static createTestFunctionApimConfig(name: string) {
     return {
@@ -171,48 +210,27 @@ export class MockFactory {
 
   public static createTestFunctionMetadata(name: string) {
     return {
-      handler: `src/handlers/${name}.handler`,
-      events: [
-        {
-          http: true,
-          "x-azure-settings": {
-            authLevel: "anonymous"
-          }
-        },
-        {
-          http: true,
-          "x-azure-settings": {
-            direction: "out",
-            name: "res"
-          },
-        }
-      ]
-    };
+      "handler": `${name}.handler`,
+      "events": MockFactory.createTestFunctionEvents(),
+    }
   }
 
-  public static createTestService(functions?): Service {
-    if (!functions) {
-      functions = MockFactory.createTestSlsFunctionConfig()
-    }
-    const serviceName = "serviceName";
-    return {
-      getAllFunctions: jest.fn(() => Object.keys(functions)),
-      getFunction: jest.fn(),
-      getAllEventsInFunction: jest.fn(),
-      getAllFunctionsNames: jest.fn(() => Object.keys(functions)),
-      getEventInFunction: jest.fn(),
-      getServiceName: jest.fn(() => serviceName),
-      load: jest.fn(),
-      mergeResourceArrays: jest.fn(),
-      setFunctionNames: jest.fn(),
-      update: jest.fn(),
-      validate: jest.fn(),
-      custom: null,
-      provider: MockFactory.createTestAzureServiceProvider(),
-      service: serviceName,
-      artifact: "app.zip",
-      functions
-    } as any as Service;
+  public static createTestFunctionEvents() {
+    return [
+      {
+        "http": true,
+        "x-azure-settings": {
+          "authLevel": "anonymous"
+        }
+      },
+      {
+        "http": true,
+        "x-azure-settings": {
+          "direction": "out",
+          "name": "res"
+        }
+      }
+    ]
   }
 
   public static createTestFunctionsResponse(functions?) {
@@ -289,14 +307,31 @@ export class MockFactory {
     return MockFactory.createTestHttpBinding();
   }
 
-  public static createTestHttpBinding() {
+  public static createTestHttpBinding(direction: string = "in") {
+    if (direction === "in") {
+      return {
+        authLevel: "anonymous",
+        type: "httpTrigger",
+        direction,
+        name: "req",
+      }
+    } else {
+      return {
+        type: "http",
+        direction,
+        name: "res"
+      }
+    }
+  }
+
+  public static createTestBindingsObject(name: string = "index.js") {
     return {
-      type: "httpTrigger",
-      authLevel: "anonymous",
-      direction: "in",
-      methods: [
-        "get",
-        "post"
+      scriptFile: name,
+      entryPoint: "handler",
+      disabled: false,
+      bindings: [
+        MockFactory.createTestHttpBinding("in"),
+        MockFactory.createTestHttpBinding("out")
       ]
     }
   }