Repository: electron-userland/electron-forge
Pull request #126 (2017-02-06 08:27:32)

feat(publisher): add S3 publish target
ISSUES CLOSED: #116

**Have you read the [section in CONTRIBUTING.md about pull requests](https://github.com/electron-userland/electron-forge/blob/master/CONTRIBUTING.md#filing-pull-requests)?**

Yes

**Are your changes appropriately documented?**

Yes

**Do your changes have sufficient test coverage?**

Maybe

**Does the testsuite pass successfully on your local machine?**

Yes

**Summarize your changes:**

Adds S3 as a publish target



Patch:
diff --git a/README.md b/README.md
index 98b4740d9..3cafd702f 100644
--- a/README.md
+++ b/README.md
@@ -177,10 +177,12 @@ the JS file method mentioned above then you can use functions normally.
 | Target Name | Description | Required Config |
 |-------------|-------------|-----------------|
 | github      | Makes a new release for the current version (if required) and uploads the make artifacts as release assets | `process.env.GITHUB_TOKEN` - A personal access token with access to your releases <br />`forge.github_repository.owner` - The owner of the GitHub repository<br />`forge.github_repository.name` - The name of the GitHub repository <br />`forge.github_repository.draft` - Create the release as a draft, defaults to `true` <br />`forge.github_repository.prerelease` - Identify the release as a prerelease, defaults to `false` |
+| Amazon S3   | Uploads your artifacts to the given S3 bucket | `process.env.ELECTRON_FORGE_S3_SECRET_ACCESS_KEY` - Your secret access token for your AWS account _(falls back to the standard `AWS_SECRET_ACCESS_KEY` environment variable)_<br />`forge.s3.accessKey` - Your access key for your AWS account _(falls back to the standard `AWS_ACCESS_KEY_ID` environment variable)_<br />`forge.s3.bucket` - The name of the S3 bucket to upload to<br />`forge.s3.folder` - The folder path to upload to inside your bucket, defaults to your application version<br />`forge.s3.public` - Whether to make the S3 upload public, defaults to `false`
 
 For example:
 
 ```javascript
+// github
 {
   // Assume the GitHub repository is at https://github.com/username/repo
   "github_repository": {
@@ -188,6 +190,15 @@ For example:
     "name": "repo"
   }
 }
+
+// s3
+{
+  "s3": {
+    "accessKey": "<AWS_ACCESS_KEY>",
+    "bucket": "my_bucket_name",
+    "public": true
+  }
+}
 ```
 
 ## Custom `make` and `publish` targets
diff --git a/package.json b/package.json
index 485bc9bfc..b7653de87 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,7 @@
     "cache": true
   },
   "dependencies": {
+    "aws-sdk": "^2.9.0",
     "babel-register": "^6.16.3",
     "bluebird": "^3.4.6",
     "colors": "^1.1.2",
@@ -108,21 +109,22 @@
     "electron-packager": "^8.5.0",
     "electron-rebuild": "^1.5.7",
     "electron-sudo": "malept/electron-sudo#fix-linux-sudo-detection",
-    "electron-windows-store": "^0.7.2",
+    "electron-windows-store": "^0.9.3",
     "electron-winstaller": "^2.5.0",
     "fs-promise": "^1.0.0",
-    "github": "^7.2.0",
+    "github": "^8.1.1",
     "glob": "^7.1.1",
-    "inquirer": "^2.0.0",
+    "inquirer": "^3.0.1 ",
     "lodash.template": "^4.4.0",
     "log-symbols": "^1.0.2",
     "node-fetch": "^1.6.3",
     "node-gyp": "^3.4.0",
     "nugget": "^2.0.1",
     "opn": "^4.0.2",
-    "ora": "^0.4.0",
+    "ora": "^1.1.0",
     "pify": "^2.3.0",
     "resolve-package": "^1.0.1",
+    "s3": "^4.4.0",
     "semver": "^5.3.0",
     "spawn-rx": "^2.0.7",
     "sudo-prompt": "^6.2.1",
diff --git a/src/electron-forge-publish.js b/src/electron-forge-publish.js
index 926120c62..315716567 100644
--- a/src/electron-forge-publish.js
+++ b/src/electron-forge-publish.js
@@ -13,7 +13,7 @@ import { getMakeOptions } from './electron-forge-make';
     .arguments('[cwd]')
     .option('--auth-token', 'Authorization token for your publisher target (if required)')
     .option('--tag', 'The tag to publish to on GitHub')
-    .option('--target', 'The deployment target, defaults to "github"')
+    .option('--target [target]', 'The deployment target, defaults to "github"')
     .allowUnknownOption(true)
     .action((cwd) => {
       if (!cwd) return;
diff --git a/src/publishers/s3.js b/src/publishers/s3.js
new file mode 100644
index 000000000..f49f71203
--- /dev/null
+++ b/src/publishers/s3.js
@@ -0,0 +1,68 @@
+import AWS from 'aws-sdk';
+import debug from 'debug';
+import path from 'path';
+import s3 from 's3';
+
+import asyncOra from '../util/ora-handler';
+
+const d = debug('electron-forge:publish:s3');
+
+AWS.util.update(AWS.S3.prototype, {
+  addExpect100Continue: function addExpect100Continue() {
+    // Hack around large upload issue: https://github.com/andrewrk/node-s3-client/issues/74
+  },
+});
+
+export default async (artifacts, packageJSON, forgeConfig, authToken, tag) => {
+  const s3Config = forgeConfig.s3;
+  s3Config.secretAccessKey = s3Config.secretAccessKey || authToken;
+  if (!(s3Config.accessKeyId && s3Config.secret && s3Config.bucket)) {
+    throw 'In order to publish to s3 you must set the "s3.accessKeyId", "process.env.ELECTRON_FORGE_S3_SECRET_ACCESS_KEY" and "s3.bucket" properties in your forge config. See the docs for more info'; // eslint-disable-line
+  }
+
+  d('creating s3 client with options:', s3Config);
+
+  const client = s3.createClient({
+    s3Client: new AWS.S3({
+      accessKeyId: s3Config.accessKeyId || process.env.AWS_ACCESS_KEY_ID,
+      secretAccessKey: s3Config.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY,
+    }),
+  });
+  client.s3.addExpect100Continue = () => {};
+
+  const folder = s3Config.folder || tag || packageJSON.version;
+
+  let uploaded = 0;
+  await asyncOra(`Uploading Artifacts ${uploaded}/${artifacts.length}`, async (uploadSpinner) => {
+    const updateSpinner = () => {
+      uploadSpinner.text = `Uploading Artifacts ${uploaded}/${artifacts.length}`; // eslint-disable-line
+    };
+
+    await Promise.all(artifacts.map(artifactPath =>
+      new Promise(async (resolve, reject) => {
+        const done = (err) => {
+          if (err) return reject(err);
+          uploaded += 1;
+          updateSpinner();
+          resolve();
+        };
+
+        const uploader = client.uploadFile({
+          localFile: artifactPath,
+          s3Params: {
+            Bucket: s3Config.bucket,
+            Key: `${folder}/${path.basename(artifactPath)}`,
+            ACL: s3Config.public ? 'public-read' : 'private',
+          },
+        });
+        d('uploading:', artifactPath);
+
+        uploader.on('error', err => done(err));
+        uploader.on('progress', () => {
+          d(`Upload Progress (${path.basename(artifactPath)}) ${Math.round((uploader.progressAmount / uploader.progressTotal) * 100)}%`);
+        });
+        uploader.on('end', () => done());
+      })
+    ));
+  });
+};
diff --git a/src/util/forge-config.js b/src/util/forge-config.js
index ee96732f0..a2e67fc84 100644
--- a/src/util/forge-config.js
+++ b/src/util/forge-config.js
@@ -3,6 +3,30 @@ import path from 'path';
 import _template from 'lodash.template';
 import readPackageJSON from './read-package-json';
 
+const underscoreCase = str => str.replace(/(.)([A-Z][a-z]+)/g, '$1_$2').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
+
+const proxify = (object, envPrefix) => {
+  const newObject = {};
+
+  Object.keys(object).forEach((key) => {
+    if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
+      newObject[key] = proxify(object[key], `${envPrefix}_${underscoreCase(key)}`);
+    } else {
+      newObject[key] = object[key];
+    }
+  });
+
+  return new Proxy(newObject, {
+    get(target, name) {
+      if (target.hasOwnProperty(name)) return target[name]; // eslint-disable-line no-prototype-builtins
+      if (typeof name === 'string') {
+        const envValue = process.env[`${envPrefix}_${underscoreCase(name)}`];
+        if (envValue) return envValue;
+      }
+    },
+  });
+};
+
 export default async (dir) => {
   const packageJSON = await readPackageJSON(dir);
   let forgeConfig = packageJSON.config.forge;
@@ -24,6 +48,8 @@ export default async (dir) => {
     electronInstallerDebian: {},
     electronInstallerDMG: {},
     electronInstallerRedhat: {},
+    s3: {},
+    github_repository: {},
   }, forgeConfig);
   forgeConfig.make_targets = Object.assign({
     win32: ['squirrel'],
@@ -54,5 +80,5 @@ export default async (dir) => {
 
   template(forgeConfig);
 
-  return forgeConfig;
+  return proxify(forgeConfig, 'ELECTRON_FORGE');
 };


Test patch:
diff --git a/test/fast/forge-config_spec.js b/test/fast/forge-config_spec.js
index d039d3610..f120b4d0b 100644
--- a/test/fast/forge-config_spec.js
+++ b/test/fast/forge-config_spec.js
@@ -21,6 +21,8 @@ const defaults = {
     linux: ['github'],
     mas: ['github'],
   },
+  github_repository: {},
+  s3: {},
 };
 
 describe('forge-config', () => {
@@ -46,4 +48,13 @@ describe('forge-config', () => {
     expect(conf.magicFn).to.be.a('function');
     expect(conf.magicFn()).to.be.equal('magic result');
   });
+
+  it('should magically map properties to environment variables', async () => {
+    const conf = await findConfig(path.resolve(__dirname, '../fixture/dummy_js_conf'));
+    expect(conf.s3.secretAccessKey).to.equal(undefined);
+
+    process.env.ELECTRON_FORGE_S3_SECRET_ACCESS_KEY = 'SecretyThing';
+    expect(conf.s3.secretAccessKey).to.equal('SecretyThing');
+    delete process.env.ELECTRON_FORGE_S3_SECRET_ACCESS_KEY;
+  });
 });