Repository: paularmstrong/build-tracker
Pull request #158 (2020-03-08 16:37:16)

[cli] split out methods to @build-tracker/api-client
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

The `bt-cli` is really handy, but would be great to also expose the functionality for use within node scripts, not just as a CLI.

# Solution

Refactors out `statArtifacts`, `createBuild`, `uploadBuild`, and `getConfig` into available methods from `@build-tracker/api-client`, keeps `bt-cli` the same and available from `@build-tracker/cli`

# TODO

- [x] 🤓 Add & update tests (always try to _increase_ test coverage)
- [x] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [x] 📖 Update relevant documentation


paularmstrong: bike-shed alternative package names:
* `@build-tracker/uploader`
* `@build-tracker/builder`

As `@build-tracker/utility`, I was also thinking about moving `types` into this package as well
OliverJAsh: Another potential name: `api-client`?

You could keep the `cli` package, but just extract the API client from that, and then make the `cli` package depend on it?
paularmstrong: Thanks @OliverJAsh that's a great suggestion and now the front-runner. I'll try to close this out with that name today

Patch:
diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
index 5d396b4..aa1533c 100644
--- a/.github/workflows/push.yml
+++ b/.github/workflows/push.yml
@@ -56,7 +56,7 @@ jobs:
       - name: Build all
         run: yarn build
       - name: Upload build
-        run: yarn ts-node src/cli/src/bin.ts upload-build -c demo/build-tracker-cli.config.js -b next
+        run: yarn ts-node src/utility/src/bin.ts upload-build -c demo/build-tracker-cli.config.js -b next
         env:
           BT_API_AUTH_TOKEN: ${{ secrets.BT_API_AUTH_TOKEN }}
 
diff --git a/docs/docs/.eslintrc.js b/docs/docs/.eslintrc.js
index 4691d7c..ee01d3b 100644
--- a/docs/docs/.eslintrc.js
+++ b/docs/docs/.eslintrc.js
@@ -6,7 +6,7 @@ module.exports = {
       files: ['*.md'],
       rules: {
         '@typescript-eslint/explicit-function-return-type': 'off',
-        '@typescript-eslint/no-unused-vars': 'warn'
+        '@typescript-eslint/no-unused-vars': 'off'
       }
     }
   ]
diff --git a/docs/docs/guides/ci.md b/docs/docs/guides/ci.md
index ef3c9e7..02ae48b 100644
--- a/docs/docs/guides/ci.md
+++ b/docs/docs/guides/ci.md
@@ -3,19 +3,17 @@ id: ci
 title: Continuous Integration
 ---
 
-The [Build Tracker CLI](../ci) is a handy tool to make uploading new builds from your CI environment simple.
+## Uploading a new build
 
-# Uploading a new build
-
-Using the CLI from `@build-tracker/cli`, once you've set up your [configuration file](../cli#configuration) you can upload a new build with a single command:
+Using the API integration from `@build-tracker/cli`, once you've set up your [configuration file](/docs/packages/api-client#configuration) you can upload a new build with a single command:
 
 ```shell
 yarn bt-cli upload-build
 ```
 
-## Reporting budget results
+### Reporting budget results
 
-The response from the Build Tracker API is sent back to the [`onCompare`](../cli#oncompare-data-apiresponse-promise-void) callback function in your configuration and has many useful pieces of information.
+The response from the Build Tracker API is sent back to the [`onCompare`](/docs/packages/api-client#oncompare-data-apiresponse-promise-void) callback function in your configuration and has many useful pieces of information.
 
 There are two items that are particularly useful here: `groupDeltas` and `artifactDeltas`. We can use these and filter on those with failing budgets to format a nice message.
 
@@ -60,7 +58,7 @@ const artifactFilter = row => {
 };
 ```
 
-## Linking directly to a build comparison
+### Linking directly to a build comparison
 
 The Build Tracker web application accept links directly to a comparison of one or more builds in the format: `/builds/:revisions+`.
 
diff --git a/docs/docs/installation.md b/docs/docs/installation.md
index 02fa172..055f8a6 100644
--- a/docs/docs/installation.md
+++ b/docs/docs/installation.md
@@ -48,7 +48,7 @@ Your application should now be accessible at the provided `url`.
 Install the Build Tracker CLI in the same repository as your application code you want to track (**not** the Server that we set up above):
 
 ```sh
-yarn add @build-tracker/cli@latest
+yarn add @build-tracker/api-client@latest
 ```
 
 Create a configuration file, `build-tracker.config.js`
diff --git a/docs/docs/packages/api-client.md b/docs/docs/packages/api-client.md
new file mode 100644
index 0000000..290af09
--- /dev/null
+++ b/docs/docs/packages/api-client.md
@@ -0,0 +1,212 @@
+---
+id: api-client
+title: '@build-tracker/api-client'
+sidebar_label: '@build-tracker/api-client'
+---
+
+The `@build-tracker/api-client` package includes node.js functions for creating and uploading builds to your Build Tracker instance’s API.
+
+> **Important note:** Set up the Build Tracker API configuration close to your application's code (in the same repository and workspace). Remember that the configuration for the API is **not** the same as the configuration for the [server](./server.md).
+
+## Install
+
+```sh
+yarn add @build-tracker/api-client@latest
+# or
+npm install --save @build-tracker/api-client@latest
+```
+
+## Configuration
+
+The Build Tracker API is easily configured using a [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) compatible file.
+
+Starting from the current working directory, it will look for the following possible sources, in this order:
+
+1. a `build-tracker` property in your `package.json`
+2. a `.build-tracerrc` file
+3. a `build-tracker.config.js` file exporting a JS object
+
+The .`.build-trackerrc` file (without extension) can be in JSON or YAML format.
+
+Alternately, you can add a filename extension to designate JSON, YAML, or JS format: `.build-trackerrc.json`, `.build-trackerrc.yaml`, `.build-trackerrc.yml`, `.build-trackerrc.js`. You may want to use an extension so that your text editor can better interpret the file, and help with syntax checking and highlighting.
+
+Once one of these is found and parsed, the search will stop and that object will be used.
+
+The configuration search can also be short-circuited by passing a `--config` argument with a path to your configuration file.
+
+### `applicationUrl: string`
+
+The full URL (with scheme) to your application. New builds will be posted to the API available at this URL
+
+```js
+module.exports = {
+  applicationUrl: 'https://build-tracker-demo.herokuapp.com',
+  // OR, if you're running from a subdirectory
+  applicationUrl: 'https://mysite.dev/build-tracker'
+};
+```
+
+### `artifacts: Array<string>`
+
+An array of [glob](https://github.com/isaacs/node-glob#readme) paths to search for your artifacts.
+
+```js
+module.exports = {
+  artifacts: ['./dist/**/*.js']
+};
+```
+
+### `baseDir?: string = process.cwd()`
+
+A base directory option to calculate relative paths for your artifacts. This is most often useful for stripping off unwanted paths, like `dist`, though it must be an absolute path on your system. This can be most easily accomplished by using the `path` node API:
+
+```js
+module.exports = {
+  baseDir: path.join(__dirname, 'dist')
+};
+```
+
+### `cwd?: string = process.cwd()`
+
+The command working directory. Set this to change the script working path to something else other than `process.cwd()`
+
+```js
+module.exports = {
+  cwd: path.join(__dirname, '..')
+};
+```
+
+### `getFilenameHash?: (filename: string) => string | void`
+
+This optional method allows you to extract a filename hash from the built artifact filename. Many applications built with webpack will include a chunk hash in the filename for cache-busting reasons.
+
+It's important to register filename hashes in Build Tracker builds foe each artifact to know if your service is requiring users to redownload files, even though their functional content has not changed.
+
+For example, if your dist folder looks like this:
+
+```
+vendor.a64785b.js
+main.56acd2e.js
+```
+
+You can extract the hash from the filename with a function:
+
+```js
+module.exports = {
+  getFilenameHash: fileName => {
+    const parts = path.basename(fileName, '.js').split('.');
+    return parts.length > 1 ? parts[parts.length - 1] : null;
+  }
+};
+```
+
+### `nameMapper?: (filename: string) => string`
+
+Similar to the `getFilenameHash` method above, you may want to strip out parts of the artifact filenames to make them more legible in reports and the application.
+
+```js
+const filenameHash = fileName => {
+  const parts = path.basename(fileName, '.js').split('.');
+  return parts.length > 1 ? parts[parts.length - 1] : null;
+};
+
+module.exports = {
+  nameMapper: fileName => {
+    const hash = filenameHash(fileName);
+    const out = fileName.replace(/\.js$/, '');
+    return hash ? out.replace(`.${hash}`, '') : out;
+  }
+};
+```
+
+### `buildUrlFormat?: string`
+
+You may want to link each build to a commit. Provide `buildUrlFormat` in the following format to map revision value to an url.
+
+```js
+module.exports = {
+  buildUrlFormat: 'https://github.com/paularmstrong/build-tracker/commit/:revision'
+};
+```
+
+### `onCompare?: (data: APIResponse) => Promise<void>`
+
+Take any action on the response from the API.
+
+```js
+module.exports = {
+  onCompare: data => {
+    // send markdown response somewhere…
+    GithubApi.postComment(data.summary.join('\n'));
+    return Promise.resolve();
+  }
+};
+```
+
+The data response consists of a lot of useful information. Depending on how you want to report information, you may only need part of it.
+
+```ts
+interface APIResponse {
+  // JSON serialized string that can be read back into a `Comparator` instance
+  comparatorData: string;
+  // Summary response from the original Comparator object
+  summary: Array<string>;
+}
+```
+
+More can be done with the data. Check out the [Continuous Integration guide](guides/ci.md) for more ideas.
+
+## API
+
+### `getConfig(path?: string): Promise<Config>`
+
+Reads your Build Tracker Utility configuration. If a path is not provided, this will use [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) to find the appropriate file.
+
+```js
+import { getConfig } from '@build-tracker/api-client';
+
+const config = await getConfig();
+```
+
+### `createBuild(config: Config, opts: Options): Promise<Build>`
+
+Creates a JSON-friendly build configuration, suitable for POSTing to the Build Tracker API.
+
+```js
+import { createBuild, getConfig } from '@build-tracker/api-client';
+
+const config = await getConfig();
+createBuild(config, {
+  branch: 'master', // optional, in case your git state is not on a branch
+  meta: {}, // optional additional metadata to provide in the build
+  parentRevision: '123456', // optional, in case your git state cannot find the merge-base
+  skipDirtyCheck: false // set to true to bypass enforcing no local changes in your git work tree
+}).then(build => {
+  // do something with the build
+});
+```
+
+### `statArtifacts(config): Map<string, Stat>`
+
+Reads the artifacts from disk and builds a map of filenames to size stats. This is done automatically by `createBuild`; it is only included in case you want to create builds with a custom script.
+
+```js
+import { getConfig, statArtifacts } from '@build-tracker/api-client';
+
+const config = await getConfig();
+const artifactMap = statArtifacts(config);
+const mainSizes = artifactMap.get('main.js');
+```
+
+### `uploadBuild(config: Config, build: Build, apiToken?: string, logger: { log, error }): Promise<ApiReturn>`
+
+Uploads a Build to your Build Tracker instance.
+
+```js
+import { createBuild, getConfig, uploadBuild } from '@build-tracker/api-client';
+
+const config = await getConfig();
+createBuild(config, {}).then(build => {
+  return uploadBuild(config, build, process.env.BT_API_AUTH_TOKEN);
+});
+```
diff --git a/docs/docs/packages/cli.md b/docs/docs/packages/cli.md
index 9f1fa6d..ca80d28 100644
--- a/docs/docs/packages/cli.md
+++ b/docs/docs/packages/cli.md
@@ -4,7 +4,7 @@ title: '@build-tracker/cli'
 sidebar_label: '@build-tracker/cli'
 ---
 
-Adding the `@build-tracker/cli` package will install a binary available as `bt-cli`. It can be run with `yarn bt-cli` or `npx bt-cli`
+Adding the `@build-tracker/cli` package includes a command-line utility, `bt-cli` for interacting with the Build Tracker API. It can be run with `yarn bt-cli` or `npx bt-cli`
 
 > **Important note:** Set up the Build Tracker CLI close to your application's code (in the same repository and workspace). Remember that the configuration for the CLI is **not** the same as the configuration for the [server](./server.md).
 
@@ -20,185 +20,49 @@ To list all commands and help, run `yarn bt-cli --help`
 
 ## Configuration
 
-The Build Tracker CLI is easily configured using a [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) compatible file.
+The Build Tracker CLI uses the same configuration as the `@build-tracker/api-client`. For more information, see [api-client configuration](api-client#configuration).
 
-Starting from the current working directory, it will look for the following possible sources, in this order:
-
-1. a `build-tracker` property in your `package.json`
-2. a `.build-tracerrc` file
-3. a `build-tracker.config.js` file exporting a JS object
-
-The .`.build-trackerrc` file (without extension) can be in JSON or YAML format.
-
-Alternately, you can add a filename extension to designate JSON, YAML, or JS format: `.build-trackerrc.json`, `.build-trackerrc.yaml`, `.build-trackerrc.yml`, `.build-trackerrc.js`. You may want to use an extension so that your text editor can better interpret the file, and help with syntax checking and highlighting.
-
-Once one of these is found and parsed, the search will stop and that object will be used.
-
-The configuration search can also be short-circuited by passing a `--config` argument with a path to your configuration file.
-
-### `applicationUrl: string`
-
-The full URL (with scheme) to your application. New builds will be posted to the API available at this URL
-
-```js
-module.exports = {
-  applicationUrl: 'https://build-tracker-demo.herokuapp.com',
-  // OR, if you're running from a subdirectory
-  applicationUrl: 'https://mysite.dev/build-tracker'
-};
-```
-
-### `artifacts: Array<string>`
-
-An array of [glob](https://github.com/isaacs/node-glob#readme) paths to search for your artifacts.
-
-```js
-module.exports = {
-  artifacts: ['./dist/**/*.js']
-};
-```
-
-### `baseDir?: string = process.cwd()`
-
-A base directory option to calculate relative paths for your artifacts. This is most often useful for stripping off unwanted paths, like `dist`, though it must be an absolute path on your system. This can be most easily accomplished by using the `path` node API:
-
-```js
-module.exports = {
-  baseDir: path.join(__dirname, 'dist')
-};
-```
-
-### `cwd?: string = process.cwd()`
-
-The command working directory. Set this to change the script working path to something else other than `process.cwd()`
-
-```js
-module.exports = {
-  cwd: path.join(__dirname, '..')
-};
-```
-
-### `getFilenameHash?: (filename: string) => string | void`
-
-This optional method allows you to extract a filename hash from the built artifact filename. Many applications built with webpack will include a chunk hash in the filename for cache-busting reasons.
-
-It's important to register filename hashes in Build Tracker builds foe each artifact to know if your service is requiring users to redownload files, even though their functional content has not changed.
-
-For example, if your dist folder looks like this:
-
-```
-vendor.a64785b.js
-main.56acd2e.js
-```
-
-You can extract the hash from the filename with a function:
-
-```js
-module.exports = {
-  getFilenameHash: fileName => {
-    const parts = path.basename(fileName, '.js').split('.');
-    return parts.length > 1 ? parts[parts.length - 1] : null;
-  }
-};
-```
-
-### `nameMapper?: (filename: string) => string`
-
-Similar to the `getFilenameHash` method above, you may want to strip out parts of the artifact filenames to make them more legible in reports and the application.
-
-```js
-const filenameHash = fileName => {
-  const parts = path.basename(fileName, '.js').split('.');
-  return parts.length > 1 ? parts[parts.length - 1] : null;
-};
-
-module.exports = {
-  nameMapper: fileName => {
-    const hash = filenameHash(fileName);
-    const out = fileName.replace(/\.js$/, '');
-    return hash ? out.replace(`.${hash}`, '') : out;
-  }
-};
-```
-
-### `buildUrlFormat?: string`
-
-You may want to link each build to a commit. Provide `buildUrlFormat` in the following format to map revision value to an url.
-
-```js
-module.exports = {
-  buildUrlFormat: 'https://github.com/paularmstrong/build-tracker/commit/:revision'
-};
-```
-
-### `onCompare?: (data: APIResponse) => Promise<void>`
-
-Take any action on the response from the API.
-
-```js
-module.exports = {
-  onCompare: data => {
-    // send markdown response somewhere…
-    GithubApi.postComment(data.summary.join('\n'));
-    return Promise.resolve();
-  }
-};
-```
-
-The data response consists of a lot of useful information. Depending on how you want to report information, you may only need part of it.
-
-```ts
-interface APIResponse {
-  // JSON serialized string that can be read back into a `Comparator` instance
-  comparatorData: string;
-  // Summary response from the original Comparator object
-  summary: Array<string>;
-}
-```
-
-More can be done with the data. Check out the [Continuous Integration guide](guides/ci.md) for more ideas.
-
-## Commands
+## CLI
 
 ### `upload-build`
 
 This command will read your configuration file, and upload the current build meta and artifact stats to your server. In most scenarios, this should be all you need.
 
-Beside the arguments below, if you're running your server with a [`BT_API_AUTH_TOKEN` environment variable](./server#securing-your-api), ensure you run this command with that variable available as well.
+Beside the arguments below, if you're running your server with a [`BT_API_AUTH_TOKEN` environment variable](server#securing-your-api), ensure you run this command with that variable available as well.
 
 ```
 BT_API_AUTH_TOKEN=my-secret-token bt-cli upload-build
 ```
 
-| option, alias        | description                                                | default                       |
-| -------------------- | ---------------------------------------------------------- | ----------------------------- |
-| `--branch`, `-b`     | Set the branch name and do not attempt to read from git    | Current git working branch    |
-| `--config`, `-c`     | Set path to the build-tracker CLI config file              | `./build-tracker.config.js`   |
-| `--meta`             | JSON-encoded extra meta information to attach to the build |                               |
-| `--parent-revision`  | Manually set the parent revision for the comparison.       | Determined via git-merge-base |
-| `--skip-dirty-check` | Skip the git work tree state check                         | `false`                       |
+| option, alias        | description                                                | default                                           |
+| -------------------- | ---------------------------------------------------------- | ------------------------------------------------- |
+| `--branch`, `-b`     | Set the branch name and do not attempt to read from git    | Current git working branch                        |
+| `--config`, `-c`     | Set path to the build-tracker API config file              | [Found via cosmiconfig](api-client#configuration) |
+| `--meta`             | JSON-encoded extra meta information to attach to the build |                                                   |
+| `--parent-revision`  | Manually set the parent revision for the comparison.       | Determined via git-merge-base                     |
+| `--skip-dirty-check` | Skip the git work tree state check                         | `false`                                           |
 
 ### `create-build`
 
 This command will create a Build object for the current available build. If run independently, it will only output information, but not upload it anywhere. For that, you only need to run `yarn bt-cli upload-build`.
 
-| option, alias        | description                                                | default                       |
-| -------------------- | ---------------------------------------------------------- | ----------------------------- |
-| `--branch`, `-b`     | Set the branch name and do not attempt to read from git    | Current git working branch    |
-| `--config`, `-c`     | Set path to the build-tracker CLI config file              | `./build-tracker.config.js`   |
-| `--meta`             | JSON-encoded extra meta information to attach to the build |                               |
-| `--out`, `-o`        | Write the build to stdout                                  | `true`                        |
-| `--parent-revision`  | Manually set the parent revision for the comparison.       | Determined via git-merge-base |
-| `--skip-dirty-check` | Skip the git work tree state check                         | `false`                       |
+| option, alias        | description                                                | default                                           |
+| -------------------- | ---------------------------------------------------------- | ------------------------------------------------- |
+| `--branch`, `-b`     | Set the branch name and do not attempt to read from git    | Current git working branch                        |
+| `--config`, `-c`     | Set path to the build-tracker API config file              | [Found via cosmiconfig](api-client#configuration) |
+| `--meta`             | JSON-encoded extra meta information to attach to the build |                                                   |
+| `--out`, `-o`        | Write the build to stdout                                  | `true`                                            |
+| `--parent-revision`  | Manually set the parent revision for the comparison.       | Determined via git-merge-base                     |
+| `--skip-dirty-check` | Skip the git work tree state check                         | `false`                                           |
 
 ### `stat-artifacts`
 
 Lower-level than `create-build`, this command will get artifact stats for the current build files and output a JSON representation of them.
 
-| option, alias    | description                                   | default                     |
-| ---------------- | --------------------------------------------- | --------------------------- |
-| `--config`, `-c` | Set path to the build-tracker CLI config file | `./build-tracker.config.js` |
-| `--out`, `-o`    | Write the stats to stdout                     | `true`                      |
+| option, alias    | description                                   | default                                           |
+| ---------------- | --------------------------------------------- | ------------------------------------------------- |
+| `--config`, `-c` | Set path to the build-tracker API config file | [Found via cosmiconfig](api-client#configuration) |
+| `--out`, `-o`    | Write the stats to stdout                     | `true`                                            |
 
 ### `version`
 
diff --git a/docs/sidebars.js b/docs/sidebars.js
index 44dca44..137d7cf 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -3,6 +3,7 @@ module.exports = {
     'Getting Started': ['installation', 'budgets'],
     Guides: ['guides/guides', 'guides/ci', 'guides/contributing'],
     Packages: [
+      'packages/api-client',
       'packages/api-errors',
       'packages/app',
       'packages/build',
diff --git a/src/api-client/index.js b/src/api-client/index.js
new file mode 100644
index 0000000..446e346
--- /dev/null
+++ b/src/api-client/index.js
@@ -0,0 +1,4 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+export * from './dist';
diff --git a/src/cli/src/index.ts b/src/api-client/index.ts
similarity index 66%
rename from src/cli/src/index.ts
rename to src/api-client/index.ts
index 14fbcd0..1a087ba 100644
--- a/src/cli/src/index.ts
+++ b/src/api-client/index.ts
@@ -1,3 +1,4 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+export * from './src';
diff --git a/src/api-client/jest.config.js b/src/api-client/jest.config.js
new file mode 100644
index 0000000..75ac512
--- /dev/null
+++ b/src/api-client/jest.config.js
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+module.exports = {
+  preset: '../../config/jest.js',
+  displayName: 'api-client',
+  testEnvironment: 'node',
+  rootDir: './',
+  roots: ['<rootDir>/src']
+};
diff --git a/src/api-client/package.json b/src/api-client/package.json
new file mode 100644
index 0000000..8cbe593
--- /dev/null
+++ b/src/api-client/package.json
@@ -0,0 +1,40 @@
+{
+  "name": "@build-tracker/api-client",
+  "version": "1.0.0-beta.9",
+  "description": "Build Tracker tool for reading build artifact sizes and uploading to your server",
+  "author": "Paul Armstrong <paul@spaceyak.com>",
+  "repository": "git@github.com:paularmstrong/build-tracker.git",
+  "license": "MIT",
+  "bin": {
+    "bt-cli": "./dist/bin.js"
+  },
+  "main": "dist",
+  "module": "./",
+  "scripts": {
+    "build": "tsc",
+    "clean": "rimraf dist",
+    "tsc": "tsc --noEmit"
+  },
+  "dependencies": {
+    "brotli-size": "^4.0.0",
+    "cosmiconfig": "^5.1.0",
+    "glob": "^7.1.3",
+    "gzip-size": "^5.0.0",
+    "path-to-regexp": "3.0.0",
+    "yargs": "^15.0.0"
+  },
+  "devDependencies": {
+    "@types/cosmiconfig": "^5.0.3",
+    "@types/glob": "^7.1.1",
+    "@types/node": "^11.10.4",
+    "@types/request": "^2.48.1",
+    "@types/request-promise-native": "^1.0.15",
+    "@types/yargs": "^15.0.0",
+    "mock-spawn": "^0.2.6",
+    "nock": "^10.0.6"
+  },
+  "gitHead": "156788a6a199fc25e21adf354c106defd002afbf",
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/src/api-client/src/index.ts b/src/api-client/src/index.ts
new file mode 100644
index 0000000..021e06e
--- /dev/null
+++ b/src/api-client/src/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+
+export { default as createBuild } from './modules/create-build';
+export { Config, default as getConfig } from './modules/config';
+export { default as statArtifacts } from './modules/stat-artifacts';
+export { default as uploadBuild } from './modules/upload-build';
diff --git a/src/cli/src/modules/config.ts b/src/api-client/src/modules/config.ts
similarity index 97%
rename from src/cli/src/modules/config.ts
rename to src/api-client/src/modules/config.ts
index 465207c..6feb82c 100644
--- a/src/cli/src/modules/config.ts
+++ b/src/api-client/src/modules/config.ts
@@ -18,7 +18,7 @@ export interface ApiReturn {
   csv: string;
 }
 
-interface Config {
+export interface Config {
   applicationUrl: string;
   artifacts: Array<string>;
   baseDir: string;
diff --git a/src/api-client/src/modules/create-build.ts b/src/api-client/src/modules/create-build.ts
new file mode 100644
index 0000000..90ff6e5
--- /dev/null
+++ b/src/api-client/src/modules/create-build.ts
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Git from './git';
+import { Config } from './config';
+import pathToRegexp from 'path-to-regexp';
+import statArtifacts from './stat-artifacts';
+import { Artifact, BuildMeta, BuildMetaItem } from '@build-tracker/build';
+
+export interface Options {
+  branch?: string;
+  meta?: {};
+  parentRevision?: string;
+  skipDirtyCheck: boolean;
+}
+
+export interface Build {
+  meta: BuildMeta;
+  artifacts: Array<Artifact>;
+}
+
+export default async function createBuild(config: Config, opts: Options = { skipDirtyCheck: false }): Promise<Build> {
+  if (!opts.skipDirtyCheck) {
+    const isDirty = await Git.isDirty(config.cwd);
+    if (isDirty) {
+      throw new Error('Current work tree is dirty. Please commit all changes before proceeding');
+    }
+  }
+
+  const artifactStats = statArtifacts(config);
+  const artifacts = Array.from(artifactStats).reduce((memo, [artifactName, stat]) => {
+    memo.push({
+      name: artifactName,
+      hash: stat.hash,
+      sizes: {
+        stat: stat.stat,
+        gzip: stat.gzip,
+        brotli: stat.brotli
+      }
+    });
+    return memo;
+  }, []);
+
+  const defaultBranch = await Git.getDefaultBranch(config.cwd);
+  const branch = opts.branch ? opts.branch : await Git.getBranch(config.cwd);
+  let revision: BuildMetaItem = await Git.getCurrentRevision(config.cwd);
+  let parentRevision: BuildMetaItem =
+    opts.parentRevision ||
+    (branch !== defaultBranch
+      ? await Git.getMergeBase(defaultBranch, config.cwd)
+      : await Git.getParentRevision(revision));
+  const { timestamp, name, subject } = await Git.getRevisionDetails(revision, config.cwd);
+
+  if (config.buildUrlFormat) {
+    const toPath = pathToRegexp.compile(config.buildUrlFormat);
+    const revisionUrl = toPath({ revision });
+    revision = {
+      value: revision,
+      url: revisionUrl
+    };
+
+    if (parentRevision) {
+      const parentRevisionUrl = toPath({ revision: parentRevision });
+      parentRevision = {
+        value: parentRevision,
+        url: parentRevisionUrl
+      };
+    }
+  }
+
+  const build = {
+    meta: {
+      author: name,
+      branch,
+      parentRevision,
+      revision,
+      subject,
+      timestamp,
+      ...(opts.meta || {})
+    },
+    artifacts
+  };
+
+  return build;
+}
diff --git a/src/cli/src/modules/git.ts b/src/api-client/src/modules/git.ts
similarity index 100%
rename from src/cli/src/modules/git.ts
rename to src/api-client/src/modules/git.ts
diff --git a/src/cli/src/modules/readfile.ts b/src/api-client/src/modules/readfile.ts
similarity index 100%
rename from src/cli/src/modules/readfile.ts
rename to src/api-client/src/modules/readfile.ts
diff --git a/src/cli/src/modules/spawn.ts b/src/api-client/src/modules/spawn.ts
similarity index 100%
rename from src/cli/src/modules/spawn.ts
rename to src/api-client/src/modules/spawn.ts
diff --git a/src/api-client/src/modules/stat-artifacts.ts b/src/api-client/src/modules/stat-artifacts.ts
new file mode 100644
index 0000000..4282fe8
--- /dev/null
+++ b/src/api-client/src/modules/stat-artifacts.ts
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import { Config } from './config';
+import glob from 'glob';
+import path from 'path';
+import readfile from './readfile';
+
+export interface Stat {
+  hash: string;
+  stat: number;
+  gzip: number;
+  brotli: number;
+}
+
+const defaultNameMapper = (fileName: string): string => fileName;
+
+export default function statArtifacts(config: Config): Map<string, Stat> {
+  const { artifacts: artifactGlobs, baseDir, cwd, getFilenameHash, nameMapper = defaultNameMapper } = config;
+
+  const artifacts = new Map();
+
+  artifactGlobs.forEach(fileGlob => {
+    glob.sync(path.resolve(cwd, fileGlob), { nodir: true }).forEach(filePath => {
+      const sizes = readfile(filePath, getFilenameHash);
+      artifacts.set(nameMapper(path.relative(baseDir, filePath).replace(`.${sizes.hash}`, '')), sizes);
+    });
+  }, []);
+
+  return artifacts;
+}
diff --git a/src/api-client/src/modules/upload-build.ts b/src/api-client/src/modules/upload-build.ts
new file mode 100644
index 0000000..fa50c09
--- /dev/null
+++ b/src/api-client/src/modules/upload-build.ts
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import { Build } from './create-build';
+import http from 'http';
+import https from 'https';
+import { ApiReturn, Config } from './config';
+
+export interface Logger {
+  log: (input: string) => void;
+  error: (input: string) => void;
+}
+
+const noop = (): void => {};
+
+export default async function uploadBuild(
+  config: Config,
+  build: Build,
+  apiToken?: string,
+  logger: Logger = { log: noop, error: noop }
+): Promise<ApiReturn> {
+  const { applicationUrl, onCompare } = config;
+
+  const url = new URL(`${applicationUrl}/api/builds`);
+  const httpProtocol = applicationUrl.startsWith('https:') ? https : http;
+  const body = JSON.stringify(build);
+  const requestOptions = {
+    host: url.hostname.replace(`${httpProtocol}//`, ''),
+    port: url.port,
+    path: url.pathname,
+    method: 'POST',
+    headers: {
+      'content-type': 'application/json',
+      'content-length': Buffer.byteLength(body)
+    }
+  };
+
+  if (apiToken) {
+    requestOptions.headers['x-bt-auth'] = apiToken;
+  }
+
+  return new Promise((resolve, reject) => {
+    const req = httpProtocol.request(requestOptions, (res: http.IncomingMessage) => {
+      const output = [];
+      res.setEncoding('utf8');
+
+      res.on('data', data => {
+        output.push(data);
+        logger.log(data);
+      });
+
+      res.on('end', () => {
+        const response = JSON.parse(output.join(''));
+        if (res.statusCode >= 400) {
+          reject(new Error(response.error));
+        } else {
+          const successResponse = response as ApiReturn;
+          if (onCompare) {
+            onCompare(successResponse).then(() => resolve(successResponse));
+          } else {
+            resolve(successResponse);
+          }
+        }
+      });
+    });
+
+    req.on('error', error => {
+      logger.error(error.toString());
+      reject(error);
+    });
+
+    req.write(body);
+    req.end();
+  });
+}
diff --git a/src/api-client/tsconfig.json b/src/api-client/tsconfig.json
new file mode 100644
index 0000000..b1224f9
--- /dev/null
+++ b/src/api-client/tsconfig.json
@@ -0,0 +1,7 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./dist"
+  },
+  "include": ["src"]
+}
diff --git a/src/cli/package.json b/src/cli/package.json
index 0ddd7ca..06967eb 100644
--- a/src/cli/package.json
+++ b/src/cli/package.json
@@ -9,28 +9,20 @@
     "bt-cli": "./dist/bin.js"
   },
   "main": "dist",
+  "module": "./",
   "scripts": {
     "build": "tsc",
     "clean": "rimraf dist",
     "tsc": "tsc --noEmit"
   },
   "dependencies": {
-    "brotli-size": "^4.0.0",
-    "cosmiconfig": "^5.1.0",
-    "glob": "^7.1.3",
-    "gzip-size": "^5.0.0",
-    "path-to-regexp": "3.0.0",
+    "@build-tracker/api-client": "1.0.0-beta.9",
     "yargs": "^15.0.0"
   },
   "devDependencies": {
-    "@types/cosmiconfig": "^5.0.3",
-    "@types/glob": "^7.1.1",
     "@types/node": "^11.10.4",
-    "@types/request": "^2.48.1",
-    "@types/request-promise-native": "^1.0.15",
     "@types/yargs": "^15.0.0",
-    "mock-spawn": "^0.2.6",
-    "nock": "^10.0.6"
+    "brotli-size": "^4.0.0"
   },
   "gitHead": "156788a6a199fc25e21adf354c106defd002afbf",
   "publishConfig": {
diff --git a/src/cli/src/commands/create-build.ts b/src/cli/src/commands/create-build.ts
index 10008a1..7c220fb 100644
--- a/src/cli/src/commands/create-build.ts
+++ b/src/cli/src/commands/create-build.ts
@@ -1,24 +1,20 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-import * as Git from '../modules/git';
 import { Argv } from 'yargs';
-import { BuildMetaItem } from '@build-tracker/build';
-import getConfig from '../modules/config';
-import pathToRegexp from 'path-to-regexp';
-import { handler as statArtifacts } from './stat-artifacts';
+import { createBuild, getConfig } from '@build-tracker/api-client';
 
 export const command = 'create-build';
 
 export const description = 'Construct a build for the current commit';
 
 export interface Args {
+  'parent-revision'?: string;
+  'skip-dirty-check': boolean;
   branch?: string;
   config?: string;
   meta?: string;
   out: boolean;
-  'parent-revision'?: string;
-  'skip-dirty-check': boolean;
 }
 
 const group = 'Create a build';
@@ -65,74 +61,17 @@ export const builder = (yargs): Argv<Args> =>
       type: 'boolean'
     });
 
-export const handler = async (args: Args): Promise<{}> => {
+export const handler = async (args: Args): Promise<void> => {
   const config = await getConfig(args.config);
-  if (!args['skip-dirty-check']) {
-    const isDirty = await Git.isDirty(config.cwd);
-    if (isDirty) {
-      throw new Error('Current work tree is dirty. Please commit all changes before proceeding');
-    }
-  }
-
-  const { artifacts: artifactStats } = await statArtifacts({ config: args.config, out: false });
-  const artifacts = Array.from(artifactStats).reduce((memo, [artifactName, stat]) => {
-    memo.push({
-      name: artifactName,
-      hash: stat.hash,
-      sizes: {
-        stat: stat.stat,
-        gzip: stat.gzip,
-        brotli: stat.brotli
-      }
-    });
-    return memo;
-  }, []);
 
-  const defaultBranch = await Git.getDefaultBranch(config.cwd);
-  const branch = args.branch ? args.branch : await Git.getBranch(config.cwd);
-  let revision: BuildMetaItem = await Git.getCurrentRevision(config.cwd);
-  let parentRevision: BuildMetaItem =
-    args['parent-revision'] ||
-    (branch !== defaultBranch
-      ? await Git.getMergeBase(defaultBranch, config.cwd)
-      : await Git.getParentRevision(revision));
-  const { timestamp, name, subject } = await Git.getRevisionDetails(revision, config.cwd);
-
-  if (config.buildUrlFormat) {
-    const toPath = pathToRegexp.compile(config.buildUrlFormat);
-    const revisionUrl = toPath({ revision });
-    revision = {
-      value: revision,
-      url: revisionUrl
-    };
-
-    if (parentRevision) {
-      const parentRevisionUrl = toPath({ revision: parentRevision });
-      parentRevision = {
-        value: parentRevision,
-        url: parentRevisionUrl
-      };
-    }
-  }
-
-  const meta = args.meta ? JSON.parse(args.meta) : {};
-
-  const build = {
-    meta: {
-      author: name,
-      branch,
-      parentRevision,
-      revision,
-      subject,
-      timestamp,
-      ...meta
-    },
-    artifacts
-  };
+  const build = await createBuild(config, {
+    branch: args.branch,
+    meta: args.meta ? JSON.parse(args.meta) : {},
+    parentRevision: args['parent-revision'],
+    skipDirtyCheck: args['skip-dirty-check']
+  });
 
   if (args.out) {
     process.stdout.write(JSON.stringify(build, null, 2));
   }
-
-  return Promise.resolve(build);
 };
diff --git a/src/cli/src/commands/stat-artifacts.ts b/src/cli/src/commands/stat-artifacts.ts
index 65fb966..a45261b 100644
--- a/src/cli/src/commands/stat-artifacts.ts
+++ b/src/cli/src/commands/stat-artifacts.ts
@@ -2,10 +2,7 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import { Argv } from 'yargs';
-import getConfig from '../modules/config';
-import glob from 'glob';
-import path from 'path';
-import readfile from '../modules/readfile';
+import { getConfig, statArtifacts } from '@build-tracker/api-client';
 
 export const command = 'stat-artifacts';
 
@@ -16,17 +13,8 @@ interface Args {
   out: boolean;
 }
 
-interface Stat {
-  hash: string;
-  stat: number;
-  gzip: number;
-  brotli: number;
-}
-
 const group = 'Stat artifacts';
 
-const defaultNameMapper = (fileName: string): string => fileName;
-
 export const builder = (yargs): Argv<Args> =>
   yargs
     .usage(`Usage: $0 ${command}`)
@@ -44,18 +32,10 @@ export const builder = (yargs): Argv<Args> =>
       type: 'boolean'
     });
 
-export const handler = async (args: Args): Promise<{ artifacts: Map<string, Stat> }> => {
-  const { artifacts: artifactGlobs, baseDir, cwd, getFilenameHash, nameMapper = defaultNameMapper } = await getConfig(
-    args.config
-  );
+export const handler = async (args: Args): Promise<void> => {
+  const config = await getConfig(args.config);
 
-  const artifacts = new Map();
-  artifactGlobs.forEach(fileGlob => {
-    glob.sync(path.resolve(cwd, fileGlob), { nodir: true }).forEach(filePath => {
-      const sizes = readfile(filePath, getFilenameHash);
-      artifacts.set(nameMapper(path.relative(baseDir, filePath).replace(`.${sizes.hash}`, '')), sizes);
-    });
-  }, []);
+  const artifacts = statArtifacts(config);
 
   if (args.out) {
     const fileOut = Array.from(artifacts).reduce((memo, [artifactName, stat]) => {
@@ -65,8 +45,4 @@ export const handler = async (args: Args): Promise<{ artifacts: Map<string, Stat
     // @ts-ignore
     process.stdout.write(JSON.stringify(fileOut, null, 2));
   }
-
-  return Promise.resolve({
-    artifacts
-  });
 };
diff --git a/src/cli/src/commands/upload-build.ts b/src/cli/src/commands/upload-build.ts
index 277e24a..854b261 100644
--- a/src/cli/src/commands/upload-build.ts
+++ b/src/cli/src/commands/upload-build.ts
@@ -2,11 +2,8 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import { Argv } from 'yargs';
-import http from 'http';
-import https from 'https';
-import { URL } from 'url';
-import { Args as BuildArgs, handler as createBuildHandler, getBuildOptions } from './create-build';
-import getConfig, { ApiReturn } from '../modules/config';
+import { Args as BuildArgs, getBuildOptions } from './create-build';
+import { createBuild, getConfig, uploadBuild } from '@build-tracker/api-client';
 
 export const command = 'upload-build';
 
@@ -18,57 +15,19 @@ export const builder = (yargs): Argv<Args> => getBuildOptions(yargs).usage(`Usag
 
 export const handler = async (args: Args): Promise<void> => {
   const config = await getConfig(args.config);
-  const build = await createBuildHandler({ ...args, out: false });
+  const build = await createBuild(config, {
+    branch: args.branch,
+    meta: args.meta,
+    parentRevision: args['parent-revision'],
+    skipDirtyCheck: args['skip-dirty-check']
+  });
 
-  const url = new URL(`${config.applicationUrl}/api/builds`);
-  const httpProtocol = config.applicationUrl.startsWith('https:') ? https : http;
-  const body = JSON.stringify(build);
-  const requestOptions = {
-    host: url.hostname.replace(`${httpProtocol}//`, ''),
-    port: url.port,
-    path: url.pathname,
-    method: 'POST',
-    headers: {
-      'content-type': 'application/json',
-      'content-length': Buffer.byteLength(body)
+  await uploadBuild(config, build, process.env.BT_API_AUTH_TOKEN, {
+    log: input => {
+      process.stdout.write(input);
+    },
+    error: input => {
+      process.stderr.write(input);
     }
-  };
-
-  if (process.env.BT_API_AUTH_TOKEN) {
-    requestOptions.headers['x-bt-auth'] = process.env.BT_API_AUTH_TOKEN;
-  }
-
-  return new Promise((resolve, reject) => {
-    const req = httpProtocol.request(requestOptions, (res: http.IncomingMessage) => {
-      const output = [];
-      res.setEncoding('utf8');
-
-      res.on('data', data => {
-        output.push(data);
-        process.stdout.write(data);
-      });
-
-      res.on('end', () => {
-        const response = JSON.parse(output.join(''));
-        if (res.statusCode >= 400) {
-          reject(new Error(response.error));
-        } else {
-          const successResponse = response as ApiReturn;
-          if (config.onCompare) {
-            config.onCompare(successResponse).then(resolve);
-          } else {
-            resolve();
-          }
-        }
-      });
-    });
-
-    req.on('error', error => {
-      process.stderr.write(error.toString());
-      reject(error);
-    });
-
-    req.write(body);
-    req.end();
   });
 };
diff --git a/src/cli/src/commands/version.ts b/src/cli/src/commands/version.ts
index bbf1ecf..2f708dc 100644
--- a/src/cli/src/commands/version.ts
+++ b/src/cli/src/commands/version.ts
@@ -10,5 +10,5 @@ export const description = 'Print the Build Tracker version';
 export const builder = (yargs): Argv<{}> => yargs.usage(`Usage: $0 ${command}`);
 
 export const handler = (): void => {
-  process.stdout.write(`${require('@build-tracker/cli/package.json').version}\n`);
+  process.stdout.write(`${require('@build-tracker/api-client/package.json').version}\n`);
 };


Test patch:
diff --git a/src/cli/src/modules/__tests__/config.test.ts b/src/api-client/src/modules/__tests__/config.test.ts
similarity index 100%
rename from src/cli/src/modules/__tests__/config.test.ts
rename to src/api-client/src/modules/__tests__/config.test.ts
diff --git a/src/api-client/src/modules/__tests__/create-build.test.ts b/src/api-client/src/modules/__tests__/create-build.test.ts
new file mode 100644
index 0000000..1b44ea3
--- /dev/null
+++ b/src/api-client/src/modules/__tests__/create-build.test.ts
@@ -0,0 +1,131 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as brotliSize from 'brotli-size';
+import * as Git from '../../modules/git';
+import config from '@build-tracker/fixtures/cli-configs/rc/.build-trackerrc.js';
+import configWithFormatUrl from '@build-tracker/fixtures/cli-configs/rc/.build-tracker-build-url-rc.js';
+import createBuild from '../create-build';
+
+describe('createBuild', () => {
+  let isDirtySpy, getBranchSpy;
+  beforeEach(() => {
+    isDirtySpy = jest.spyOn(Git, 'isDirty').mockImplementation(() => Promise.resolve(false));
+    jest.spyOn(Git, 'getDefaultBranch').mockImplementation(() => Promise.resolve('master'));
+    jest.spyOn(Git, 'getMergeBase').mockImplementation(() => Promise.resolve('1234567'));
+    jest.spyOn(Git, 'getParentRevision').mockImplementation(() => Promise.resolve('7654321'));
+    jest.spyOn(Git, 'getCurrentRevision').mockImplementation(() => Promise.resolve('abcdefg'));
+    getBranchSpy = jest.spyOn(Git, 'getBranch').mockImplementation(() => Promise.resolve('master'));
+    jest
+      .spyOn(Git, 'getRevisionDetails')
+      .mockImplementation(() => Promise.resolve({ timestamp: 1234567890, name: 'Jimmy', subject: 'tacos' }));
+    jest.spyOn(brotliSize, 'sync').mockImplementation(() => 49);
+  });
+
+  test('gets parent revision if branch is same as default branch', async () => {
+    await expect(createBuild(config, { skipDirtyCheck: true })).resolves.toMatchObject({
+      meta: { parentRevision: '7654321' }
+    });
+  });
+
+  test('gets the merge base if not on the default branch', async () => {
+    getBranchSpy.mockReturnValue(Promise.resolve('tacos'));
+    await expect(createBuild(config, { skipDirtyCheck: true })).resolves.toMatchObject({
+      meta: { parentRevision: '1234567' }
+    });
+  });
+
+  test('uses the provided parent-revision', async () => {
+    await expect(createBuild(config, { parentRevision: 'abcdefg', skipDirtyCheck: true })).resolves.toMatchObject({
+      meta: { parentRevision: 'abcdefg' }
+    });
+  });
+
+  test('allows overriding the git branch name check', async () => {
+    isDirtySpy.mockReturnValue(Promise.resolve(true));
+
+    await expect(createBuild(config, { branch: 'burritos', skipDirtyCheck: true })).resolves.toMatchObject({
+      meta: expect.objectContaining({ branch: 'burritos' })
+    });
+    expect(getBranchSpy).not.toHaveBeenCalled();
+  });
+
+  test('allows skipping the git worktree check', async () => {
+    jest.spyOn(Git, 'isDirty').mockReturnValue(Promise.resolve(true));
+
+    await expect(createBuild(config, { skipDirtyCheck: true })).resolves.toEqual(expect.any(Object));
+  });
+
+  test('throws if the working tree is dirty', async () => {
+    jest.spyOn(Git, 'isDirty').mockReturnValue(Promise.resolve(true));
+    await expect(createBuild(config, { skipDirtyCheck: false })).rejects.toThrowErrorMatchingInlineSnapshot(
+      `"Current work tree is dirty. Please commit all changes before proceeding"`
+    );
+  });
+
+  test('returns a JSON representation of a build', async () => {
+    getBranchSpy.mockReturnValue(Promise.resolve('tacobranch'));
+
+    await expect(createBuild(config, { skipDirtyCheck: true })).resolves.toMatchObject({
+      meta: {
+        branch: 'tacobranch',
+        parentRevision: '1234567',
+        revision: 'abcdefg',
+        timestamp: 1234567890,
+        author: 'Jimmy',
+        subject: 'tacos'
+      },
+      artifacts: [
+        {
+          hash: '631a500f31d7602a386b4f858338dd6f',
+          name: '../../fakedist/main.1234567.js',
+          sizes: {
+            brotli: 49,
+            gzip: 73,
+            stat: 64
+          }
+        },
+        {
+          hash: '415dec15fc798bb79f499aeff00258fb',
+          name: '../../fakedist/test-folder/test-no-extension',
+          sizes: {
+            brotli: 49,
+            gzip: 54,
+            stat: 34
+          }
+        },
+        {
+          hash: 'fc4bcd175441f89862f9d81e37599416',
+          name: '../../fakedist/vendor.js',
+          sizes: {
+            brotli: 49,
+            gzip: 82,
+            stat: 82
+          }
+        }
+      ]
+    });
+  });
+
+  test('returns a revision and parentRevision url if buildUrlFormat is provided', async () => {
+    getBranchSpy.mockReturnValue(Promise.resolve('tacobranch'));
+
+    await expect(createBuild(configWithFormatUrl, { skipDirtyCheck: true })).resolves.toMatchObject({
+      meta: {
+        revision: { url: 'https://github.com/paularmstrong/build-tracker/commit/abcdefg', value: 'abcdefg' },
+        parentRevision: { url: 'https://github.com/paularmstrong/build-tracker/commit/1234567', value: '1234567' }
+      }
+    });
+  });
+
+  test('can include extra meta', async () => {
+    await expect(
+      createBuild(config, {
+        meta: { foo: 'bar', baz: { url: 'https://buildtracker.dev', value: 'baz' } },
+        skipDirtyCheck: true
+      })
+    ).resolves.toMatchObject({
+      meta: { revision: 'abcdefg', foo: 'bar', baz: { url: 'https://buildtracker.dev', value: 'baz' } }
+    });
+  });
+});
diff --git a/src/cli/src/modules/__tests__/git.test.ts b/src/api-client/src/modules/__tests__/git.test.ts
similarity index 100%
rename from src/cli/src/modules/__tests__/git.test.ts
rename to src/api-client/src/modules/__tests__/git.test.ts
diff --git a/src/cli/src/modules/__tests__/readfile.test.ts b/src/api-client/src/modules/__tests__/readfile.test.ts
similarity index 100%
rename from src/cli/src/modules/__tests__/readfile.test.ts
rename to src/api-client/src/modules/__tests__/readfile.test.ts
diff --git a/src/cli/src/modules/__tests__/spawn.test.ts b/src/api-client/src/modules/__tests__/spawn.test.ts
similarity index 100%
rename from src/cli/src/modules/__tests__/spawn.test.ts
rename to src/api-client/src/modules/__tests__/spawn.test.ts
diff --git a/src/api-client/src/modules/__tests__/stat-artifacts.test.ts b/src/api-client/src/modules/__tests__/stat-artifacts.test.ts
new file mode 100644
index 0000000..aa0a3fd
--- /dev/null
+++ b/src/api-client/src/modules/__tests__/stat-artifacts.test.ts
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as brotliSize from 'brotli-size';
+import config from '@build-tracker/fixtures/cli-configs/rc/.build-trackerrc.js';
+import statArtifacts from '../stat-artifacts';
+
+describe('statArtifacts', () => {
+  test('gets artifact sizes', () => {
+    jest.spyOn(brotliSize, 'sync').mockImplementation(() => {
+      throw new Error('disabled in test');
+    });
+    const artifacts = statArtifacts(config);
+    expect(artifacts).toBeInstanceOf(Map);
+    expect(artifacts.size).toEqual(3);
+    expect(artifacts.get('../../fakedist/main.1234567.js')).toMatchInlineSnapshot(`
+      Object {
+        "gzip": 73,
+        "hash": "631a500f31d7602a386b4f858338dd6f",
+        "stat": 64,
+      }
+    `);
+    expect(artifacts.get('../../fakedist/test-folder/test-no-extension')).toMatchInlineSnapshot(`
+      Object {
+        "gzip": 54,
+        "hash": "415dec15fc798bb79f499aeff00258fb",
+        "stat": 34,
+      }
+    `);
+    expect(artifacts.get('../../fakedist/vendor.js')).toMatchInlineSnapshot(`
+      Object {
+        "gzip": 82,
+        "hash": "fc4bcd175441f89862f9d81e37599416",
+        "stat": 82,
+      }
+    `);
+  });
+});
diff --git a/src/api-client/src/modules/__tests__/upload-build.test.ts b/src/api-client/src/modules/__tests__/upload-build.test.ts
new file mode 100644
index 0000000..d32be1d
--- /dev/null
+++ b/src/api-client/src/modules/__tests__/upload-build.test.ts
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import config from '@build-tracker/fixtures/cli-configs/rc/.build-trackerrc.js';
+import httpConfig from '@build-tracker/fixtures/cli-configs/rc/.build-tracker-http-rc.js';
+import nock from 'nock';
+import uploadBuild from '../upload-build';
+
+const build = {
+  meta: {
+    branch: 'master',
+    revision: '1234567',
+    parentRevision: 'abcdefg',
+    timestamp: 1234567
+  },
+  artifacts: []
+};
+
+const returnValue = {
+  build,
+  parentBuild: build,
+  json: {},
+  markdown: '',
+  csv: ''
+};
+
+describe('uploadBuild', () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  test('uploads the current build', async () => {
+    nock(`${config.applicationUrl}/`)
+      .post('/api/builds')
+      .reply(200, returnValue);
+
+    await expect(uploadBuild(config, build)).resolves.toEqual(returnValue);
+  });
+
+  test('uses the input logger', async () => {
+    const logger = { log: jest.fn(), error: jest.fn() };
+    nock(`${config.applicationUrl}/`)
+      .post('/api/builds')
+      .reply(200, returnValue);
+
+    await expect(uploadBuild(config, build, undefined, logger)).resolves.toEqual(returnValue);
+    expect(logger.log).toHaveBeenLastCalledWith(JSON.stringify(returnValue));
+  });
+
+  test('invokes `onCompare`', async () => {
+    const onCompareSpy = jest.spyOn(config, 'onCompare').mockImplementationOnce(() => Promise.resolve());
+
+    nock(`${config.applicationUrl}/`)
+      .post('/api/builds')
+      .reply(200, returnValue);
+
+    await expect(uploadBuild(config, build)).resolves.toEqual(returnValue);
+    expect(onCompareSpy).toHaveBeenCalledWith(returnValue);
+  });
+
+  test('if request failed, responds with error and does not invoke `onCompare`', async () => {
+    const onCompareSpy = jest.spyOn(config, 'onCompare').mockImplementationOnce(() => Promise.resolve());
+
+    nock(`${config.applicationUrl}/`)
+      .post('/api/builds')
+      .reply(500, { error: 'Something went wrong.' });
+
+    await expect(uploadBuild(config, build)).rejects.toStrictEqual(new Error('Something went wrong.'));
+    expect(onCompareSpy).not.toHaveBeenCalled();
+  });
+
+  test('uploads the current build over http', async () => {
+    nock(`${httpConfig.applicationUrl}/`)
+      .post('/api/builds')
+      .reply(200, returnValue);
+
+    await expect(uploadBuild(httpConfig, build)).resolves.toEqual(returnValue);
+  });
+
+  test('sends the BT_API_AUTH_TOKEN', async () => {
+    nock(`${httpConfig.applicationUrl}/`)
+      .matchHeader('x-bt-auth', 'test-token')
+      .post('/api/builds')
+      .reply(200, returnValue);
+
+    await expect(uploadBuild(httpConfig, build, 'test-token')).resolves.toEqual(returnValue);
+  });
+
+  test('rejects on error response', async () => {
+    const responseError = new Error('some error');
+
+    nock(`${config.applicationUrl}/`)
+      .post('/api/builds')
+      .replyWithError(responseError);
+
+    await expect(uploadBuild(config, build)).rejects.toBe(responseError);
+  });
+
+  test('uses the input error logger', async () => {
+    const responseError = new Error('some error');
+    const logger = { log: jest.fn(), error: jest.fn() };
+    nock(`${config.applicationUrl}/`)
+      .post('/api/builds')
+      .replyWithError(responseError);
+
+    await expect(uploadBuild(config, build, undefined, logger)).rejects.toEqual(responseError);
+    expect(logger.error).toHaveBeenLastCalledWith(responseError.toString());
+  });
+});
diff --git a/src/cli/src/commands/__tests__/create-build.test.ts b/src/cli/src/commands/__tests__/create-build.test.ts
index f8e2d46..322138f 100644
--- a/src/cli/src/commands/__tests__/create-build.test.ts
+++ b/src/cli/src/commands/__tests__/create-build.test.ts
@@ -3,8 +3,8 @@
  */
 import * as brotliSize from 'brotli-size';
 import * as Command from '../create-build';
-import * as Git from '../../modules/git';
-import * as path from 'path';
+import * as Git from '@build-tracker/api-client/src/modules/git';
+import path from 'path';
 import yargs from 'yargs';
 
 const config = path.join(
@@ -12,12 +12,18 @@ const config = path.join(
   'cli-configs/rc/.build-trackerrc.js'
 );
 
-const configWithFormatUrl = path.join(
-  path.dirname(require.resolve('@build-tracker/fixtures')),
-  'cli-configs/rc/.build-tracker-build-url-rc.js'
-);
-
 describe('create-build', () => {
+  beforeEach(() => {
+    jest.spyOn(Git, 'getDefaultBranch').mockImplementation(() => Promise.resolve('master'));
+    jest.spyOn(Git, 'getMergeBase').mockImplementation(() => Promise.resolve('1234567'));
+    jest.spyOn(Git, 'getParentRevision').mockImplementation(() => Promise.resolve('7654321'));
+    jest.spyOn(Git, 'getCurrentRevision').mockImplementation(() => Promise.resolve('abcdefg'));
+    jest.spyOn(Git, 'getBranch').mockImplementation(() => Promise.resolve('master'));
+    jest
+      .spyOn(Git, 'getRevisionDetails')
+      .mockImplementation(() => Promise.resolve({ timestamp: 1234567890, name: 'Jimmy', subject: 'tacos' }));
+  });
+
   describe('builder', () => {
     test('defaults config', () => {
       const args = Command.builder(yargs([]));
@@ -33,143 +39,22 @@ describe('create-build', () => {
   });
 
   describe('handler', () => {
-    let isDirtySpy, getBranchSpy, writeSpy;
+    let writeSpy;
     beforeEach(() => {
       writeSpy = jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
-      isDirtySpy = jest.spyOn(Git, 'isDirty').mockImplementation(() => Promise.resolve(false));
-      jest.spyOn(Git, 'getDefaultBranch').mockImplementation(() => Promise.resolve('master'));
-      jest.spyOn(Git, 'getMergeBase').mockImplementation(() => Promise.resolve('1234567'));
-      jest.spyOn(Git, 'getParentRevision').mockImplementation(() => Promise.resolve('7654321'));
-      jest.spyOn(Git, 'getCurrentRevision').mockImplementation(() => Promise.resolve('abcdefg'));
-      getBranchSpy = jest.spyOn(Git, 'getBranch').mockImplementation(() => Promise.resolve('master'));
-      jest
-        .spyOn(Git, 'getRevisionDetails')
-        .mockImplementation(() => Promise.resolve({ timestamp: 1234567890, name: 'Jimmy', subject: 'tacos' }));
       jest.spyOn(brotliSize, 'sync').mockImplementation(() => 49);
     });
 
     test('writes the artifact stats to stdout', async () => {
-      await expect(Command.handler({ config, out: true, 'skip-dirty-check': true })).resolves.toEqual(
-        expect.any(Object)
-      );
-      expect(writeSpy).toHaveBeenCalledWith(expect.stringMatching('\\"parentRevision\\": \\"7654321\\"'));
-    });
-
-    test('gets parent revision if branch is same as default branch', async () => {
-      await expect(Command.handler({ config, out: false, 'skip-dirty-check': true })).resolves.toMatchObject({
-        meta: { parentRevision: '7654321' }
-      });
-    });
-
-    test('gets the merge base if not on the default branch', async () => {
-      getBranchSpy.mockReturnValue(Promise.resolve('tacos'));
-      await expect(Command.handler({ config, out: false, 'skip-dirty-check': true })).resolves.toMatchObject({
-        meta: { parentRevision: '1234567' }
-      });
+      await expect(Command.handler({ config, out: true, 'skip-dirty-check': true })).resolves.toBeUndefined();
+      expect(writeSpy).toHaveBeenCalledWith(expect.stringMatching('"parentRevision": "7654321",'));
     });
 
-    test('uses the provided parent-revision', async () => {
+    test('converts JSON string-encoded metadata', async () => {
       await expect(
-        Command.handler({ config, out: false, 'parent-revision': 'abcdefg', 'skip-dirty-check': true })
-      ).resolves.toMatchObject({
-        meta: { parentRevision: 'abcdefg' }
-      });
-    });
-
-    test('allows overriding the git branch name check', async () => {
-      isDirtySpy.mockReturnValue(Promise.resolve(true));
-
-      await expect(
-        Command.handler({ branch: 'burritos', config, out: false, 'skip-dirty-check': true })
-      ).resolves.toMatchObject({ meta: expect.objectContaining({ branch: 'burritos' }) });
-      expect(getBranchSpy).not.toHaveBeenCalled();
-    });
-
-    test('allows skipping the git worktree check', async () => {
-      jest.spyOn(Git, 'isDirty').mockReturnValue(Promise.resolve(true));
-
-      await expect(Command.handler({ config, out: false, 'skip-dirty-check': true })).resolves.toEqual(
-        expect.any(Object)
-      );
-    });
-
-    test('throws if the working tree is dirty', async () => {
-      jest.spyOn(Git, 'isDirty').mockReturnValue(Promise.resolve(true));
-      await expect(
-        Command.handler({ config, out: false, 'skip-dirty-check': false })
-      ).rejects.toThrowErrorMatchingInlineSnapshot(
-        `"Current work tree is dirty. Please commit all changes before proceeding"`
-      );
-    });
-
-    test('returns a JSON representation of a build', async () => {
-      getBranchSpy.mockReturnValue(Promise.resolve('tacobranch'));
-
-      await expect(Command.handler({ config, out: false, 'skip-dirty-check': true })).resolves.toMatchObject({
-        meta: {
-          branch: 'tacobranch',
-          parentRevision: '1234567',
-          revision: 'abcdefg',
-          timestamp: 1234567890,
-          author: 'Jimmy',
-          subject: 'tacos'
-        },
-        artifacts: [
-          {
-            hash: '631a500f31d7602a386b4f858338dd6f',
-            name: '../../fakedist/main.1234567.js',
-            sizes: {
-              brotli: 49,
-              gzip: 73,
-              stat: 64
-            }
-          },
-          {
-            hash: '415dec15fc798bb79f499aeff00258fb',
-            name: '../../fakedist/test-folder/test-no-extension',
-            sizes: {
-              brotli: 49,
-              gzip: 54,
-              stat: 34
-            }
-          },
-          {
-            hash: 'fc4bcd175441f89862f9d81e37599416',
-            name: '../../fakedist/vendor.js',
-            sizes: {
-              brotli: 49,
-              gzip: 82,
-              stat: 82
-            }
-          }
-        ]
-      });
-    });
-
-    test('returns a revision and parentRevision url if buildUrlFormat is provided', async () => {
-      getBranchSpy.mockReturnValue(Promise.resolve('tacobranch'));
-
-      await expect(
-        Command.handler({ config: configWithFormatUrl, out: false, 'skip-dirty-check': true })
-      ).resolves.toMatchObject({
-        meta: {
-          revision: { url: 'https://github.com/paularmstrong/build-tracker/commit/abcdefg', value: 'abcdefg' },
-          parentRevision: { url: 'https://github.com/paularmstrong/build-tracker/commit/1234567', value: '1234567' }
-        }
-      });
-    });
-
-    test('can include JSON encoded extra meta', async () => {
-      await expect(
-        Command.handler({
-          config,
-          out: false,
-          meta: '{"foo":"bar","baz":{"url":"https://buildtracker.dev","value":"baz"}}',
-          'skip-dirty-check': true
-        })
-      ).resolves.toMatchObject({
-        meta: { revision: 'abcdefg', foo: 'bar', baz: { url: 'https://buildtracker.dev', value: 'baz' } }
-      });
+        Command.handler({ config, meta: JSON.stringify({ foo: 'bar' }), out: true, 'skip-dirty-check': true })
+      ).resolves.toBeUndefined();
+      expect(writeSpy).toHaveBeenCalledWith(expect.stringMatching('\\"foo\\": \\"bar\\"'));
     });
   });
 });
diff --git a/src/cli/src/commands/__tests__/upload-build.test.ts b/src/cli/src/commands/__tests__/upload-build.test.ts
index 8894e20..1e2788b 100644
--- a/src/cli/src/commands/__tests__/upload-build.test.ts
+++ b/src/cli/src/commands/__tests__/upload-build.test.ts
@@ -2,28 +2,9 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Command from '../upload-build';
-import * as CreateBuild from '../create-build';
-import nock from 'nock';
-import path from 'path';
 import yargs from 'yargs';
 
-const config = path.join(
-  path.dirname(require.resolve('@build-tracker/fixtures')),
-  'cli-configs/rc/.build-trackerrc.js'
-);
-
-const httpConfig = path.join(
-  path.dirname(require.resolve('@build-tracker/fixtures')),
-  'cli-configs/rc/.build-tracker-http-rc.js'
-);
-
-const postData = { meta: { revision: '12345', parentRevision: 'abcdef', timestamp: Date.now(), artifacts: [] } };
-
 describe('upload-build', () => {
-  afterEach(() => {
-    nock.cleanAll();
-  });
-
   describe('builder', () => {
     test('defaults config', () => {
       const args = Command.builder(yargs([]));
@@ -35,97 +16,4 @@ describe('upload-build', () => {
       });
     });
   });
-
-  describe('handler', () => {
-    test('uploads the current build', async () => {
-      jest.spyOn(CreateBuild, 'handler').mockImplementation(() => Promise.resolve(postData));
-      const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
-      const configValues = require(config);
-
-      nock(`${configValues.applicationUrl}/`)
-        .post('/api/builds')
-        .reply(200, { success: 'yep' });
-
-      await expect(Command.handler({ config, out: true, 'skip-dirty-check': true })).resolves.toBeUndefined();
-      expect(writeSpy).toHaveBeenCalledWith('{"success":"yep"}');
-    });
-
-    test('invokes `onCompare`', async () => {
-      jest.spyOn(CreateBuild, 'handler').mockImplementation(() => Promise.resolve(postData));
-      const configValues = require(config);
-      const onCompareSpy = jest.spyOn(configValues, 'onCompare').mockImplementationOnce(() => Promise.resolve());
-
-      nock(`${configValues.applicationUrl}/`)
-        .post('/api/builds')
-        .reply(200, { success: 'yep' });
-
-      await expect(Command.handler({ config, out: true, 'skip-dirty-check': true })).resolves.toBeUndefined();
-      expect(onCompareSpy).toHaveBeenCalledWith({ success: 'yep' });
-    });
-
-    test('if request failed, responds with error and does not invoke `onCompare`', async () => {
-      jest.spyOn(CreateBuild, 'handler').mockImplementation(() => Promise.resolve(postData));
-      const configValues = require(config);
-      const onCompareSpy = jest.spyOn(configValues, 'onCompare').mockImplementationOnce(() => Promise.resolve());
-
-      nock(`${configValues.applicationUrl}/`)
-        .post('/api/builds')
-        .reply(500, { error: 'Something went wrong.' });
-
-      await expect(Command.handler({ config, out: true, 'skip-dirty-check': true })).rejects.toStrictEqual(
-        new Error('Something went wrong.')
-      );
-      expect(onCompareSpy).not.toHaveBeenCalled();
-    });
-
-    test('uploads the current build over http', async () => {
-      jest.spyOn(CreateBuild, 'handler').mockImplementation(() => Promise.resolve(postData));
-      const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
-      const configValues = require(httpConfig);
-
-      nock(`${configValues.applicationUrl}/`)
-        .post('/api/builds')
-        .reply(200, { success: 'yep' });
-
-      await expect(
-        Command.handler({ config: httpConfig, out: true, 'skip-dirty-check': true })
-      ).resolves.toBeUndefined();
-      expect(writeSpy).toHaveBeenCalledWith('{"success":"yep"}');
-    });
-
-    test('sends the BT_API_AUTH_TOKEN', async () => {
-      const prevToken = process.env.BT_API_AUTH_TOKEN;
-      process.env.BT_API_AUTH_TOKEN = 'test-token';
-
-      jest.spyOn(CreateBuild, 'handler').mockImplementation(() => Promise.resolve(postData));
-      const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
-      const configValues = require(httpConfig);
-
-      nock(`${configValues.applicationUrl}/`)
-        .matchHeader('x-bt-auth', 'test-token')
-        .post('/api/builds')
-        .reply(200, { success: 'yep' });
-
-      await expect(
-        Command.handler({ config: httpConfig, out: true, 'skip-dirty-check': true })
-      ).resolves.toBeUndefined();
-      expect(writeSpy).toHaveBeenCalledWith('{"success":"yep"}');
-      process.env.BT_API_AUTH_TOKEN = prevToken;
-    });
-
-    test('rejects on error response', async () => {
-      jest.spyOn(CreateBuild, 'handler').mockImplementation(() => Promise.resolve(postData));
-      const writeSpy = jest.spyOn(process.stderr, 'write').mockImplementationOnce(() => true);
-      const configValues = require(config);
-
-      const responseError = new Error('some error');
-
-      nock(`${configValues.applicationUrl}/`)
-        .post('/api/builds')
-        .replyWithError(responseError);
-
-      await expect(Command.handler({ config, out: true, 'skip-dirty-check': true })).rejects.toBe(responseError);
-      expect(writeSpy).toHaveBeenCalledWith(responseError.toString());
-    });
-  });
 });