Просмотр исходного кода

Merge pull request #1194 from weseek/feat/zip-import

Feat/zip import
Yuki Takei 6 лет назад
Родитель
Сommit
9f2e359898

+ 2 - 0
package.json

@@ -65,6 +65,7 @@
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0",
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
+    "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
@@ -130,6 +131,7 @@
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
+    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "xss": "^1.0.6"
   },

+ 14 - 0
src/client/js/app.jsx

@@ -39,6 +39,7 @@ import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 import ExportPage from './components/Admin/Export/ExportPage';
+import GrowiImportForm from './components/Admin/Import/GrowiImportForm';
 import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 
 import AppContainer from './services/AppContainer';
@@ -202,6 +203,19 @@ if (adminExportPageElem != null) {
   );
 }
 
+// TODO: move to /imponents/Admin/Importer.jsx
+const growiImportElem = document.getElementById('growi-import');
+if (growiImportElem != null) {
+  ReactDOM.render(
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
+        <GrowiImportForm />
+      </I18nextProvider>
+    </Provider>,
+    growiImportElem,
+  );
+}
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(

+ 97 - 0
src/client/js/components/Admin/Import/GrowiImportForm.jsx

@@ -0,0 +1,97 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.inputRef = React.createRef();
+
+    this.changeFileName = this.changeFileName.bind(this);
+    this.import = this.import.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeFileName(e) {
+    // to rerender onChange
+    // eslint-disable-next-line react/no-unused-state
+    this.setState({ name: e.target.files[0].name });
+  }
+
+  async import(e) {
+    e.preventDefault();
+
+    const formData = new FormData();
+    formData.append('_csrf', this.props.appContainer.csrfToken);
+    formData.append('file', this.inputRef.current.files[0]);
+
+    // TODO use appContainer.apiv3.post
+    await this.props.appContainer.apiPost('/v3/import/pages', formData);
+    // TODO toastSuccess, toastError
+  }
+
+  validateForm() {
+    return (
+      this.inputRef.current // null check
+      && this.inputRef.current.files[0] // null check
+      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.import}>
+        <fieldset>
+          <legend>Import</legend>
+          <div className="well well-sm small">
+            <ul>
+              <li>Imported pages will overwrite existing pages</li>
+            </ul>
+          </div>
+          <div className="form-group d-flex align-items-center">
+            <label htmlFor="file" className="col-xs-3 control-label">Zip File</label>
+            <div className="col-xs-6">
+              <input
+                type="file"
+                name="file"
+                className="form-control-file"
+                ref={this.inputRef}
+                onChange={this.changeFileName}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-xs-offset-3 col-xs-6">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+                { t('importer_management.import') }
+              </button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+GrowiImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiImportFormWrapper = (props) => {
+  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiImportFormWrapper);

+ 12 - 0
src/server/crowi/index.js

@@ -47,6 +47,7 @@ function Crowi(rootdir) {
   this.fileUploadService = null;
   this.restQiitaAPIService = null;
   this.exportService = null;
+  this.importService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.xss = new Xss();
@@ -105,6 +106,7 @@ Crowi.prototype.init = async function() {
     this.setUpRestQiitaAPI(),
     this.setupUserGroup(),
     this.setupExport(),
+    this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -137,6 +139,9 @@ Crowi.prototype.initForTest = async function() {
     this.setUpAcl(),
   //   this.setUpCustomize(),
   //   this.setUpRestQiitaAPI(),
+  //   this.setupUserGroup(),
+  //   this.setupExport(),
+  //   this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -541,4 +546,11 @@ Crowi.prototype.setupExport = async function() {
   }
 };
 
+Crowi.prototype.setupImport = async function() {
+  const ImportService = require('../service/import');
+  if (this.importService == null) {
+    this.importService = new ImportService(this);
+  }
+};
+
 module.exports = Crowi;

+ 115 - 0
src/server/routes/apiv3/import.js

@@ -0,0 +1,115 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
+const path = require('path');
+const multer = require('multer');
+const autoReap = require('multer-autoreap');
+const { ObjectId } = require('mongoose').Types;
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Import
+ */
+
+module.exports = (crowi) => {
+  const { importService } = crowi;
+  const uploads = multer({
+    dest: importService.baseDir,
+    fileFilter: (req, file, cb) => {
+      if (path.extname(file.originalname) === '.zip') {
+        return cb(null, true);
+      }
+      cb(new Error('Only ".zip" is allowed'));
+    },
+  });
+
+  /**
+   * defined overwrite params for each collection
+   * all imported documents are overwriten by this value
+   * each value can be any value or a function (_value, { _document, key, schema }) { return newValue }
+   *
+   * @param {object} Model instance of mongoose model
+   * @param {object} req request object
+   * @return {object} document to be persisted
+   */
+  const overwriteParamsFn = async(Model, req) => {
+    const { collectionName } = Model.collection;
+
+    /* eslint-disable no-case-declarations */
+    switch (Model.collection.collectionName) {
+      case 'pages':
+        // TODO: use req.body to generate overwriteParams
+        return {
+          status: 'published', // FIXME when importing users and user groups
+          grant: 1, // FIXME when importing users and user groups
+          grantedUsers: [], // FIXME when importing users and user groups
+          grantedGroup: null, // FIXME when importing users and user groups
+          creator: ObjectId(req.user._id), // FIXME when importing users
+          lastUpdateUser: ObjectId(req.user._id), // FIXME when importing users
+          liker: [], // FIXME when importing users
+          seenUsers: [], // FIXME when importing users
+          commentCount: 0, // FIXME when importing comments
+          extended: {}, // FIXME when ?
+          pageIdOnHackmd: undefined, // FIXME when importing hackmd?
+          revisionHackmdSynced: undefined, // FIXME when importing hackmd?
+          hasDraftOnHackmd: undefined, // FIXME when importing hackmd?
+        };
+      // case 'revisoins':
+      //   return {};
+      // case 'users':
+      //   return {};
+      // ... add more cases
+      default:
+        throw new Error(`cannot find a model for collection name "${collectionName}"`);
+    }
+    /* eslint-enable no-case-declarations */
+  };
+
+  /**
+   * @swagger
+   *
+   *  /import/:collection:
+   *    post:
+   *      tags: [Import]
+   *      description: import a collection from a zipped json
+   *      produces:
+   *        - application/json
+   *      responses:
+   *        200:
+   *          description: data is successfully imported
+   *          content:
+   *            application/json:
+   */
+  router.post('/:collection', uploads.single('file'), autoReap, async(req, res) => {
+    // TODO: add express validator
+    const { file } = req;
+    const { collection } = req.params;
+    const Model = importService.getModelFromCollectionName(collection);
+    const zipFilePath = path.join(file.destination, file.filename);
+
+    try {
+      let overwriteParams;
+      if (overwriteParamsFn[collection] != null) {
+        // await in case overwriteParamsFn[collection] is a Promise
+        overwriteParams = await overwriteParamsFn(Model, req);
+      }
+
+      await importService.importFromZip(Model, zipFilePath, overwriteParams);
+
+      // TODO: use res.apiv3
+      return res.send({ status: 'OK' });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  return router;
+};

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -11,5 +11,7 @@ module.exports = (crowi) => {
 
   router.use('/export', require('./export')(crowi));
 
+  router.use('/import', require('./import')(crowi));
+
   return router;
 };

+ 261 - 0
src/server/service/import.js

@@ -0,0 +1,261 @@
+const logger = require('@alias/logger')('growi:services:ImportService'); // eslint-disable-line no-unused-vars
+const fs = require('fs');
+const path = require('path');
+const JSONStream = require('JSONStream');
+const streamToPromise = require('stream-to-promise');
+const unzip = require('unzipper');
+const { ObjectId } = require('mongoose').Types;
+
+class ImportService {
+
+  constructor(crowi) {
+    this.baseDir = path.join(crowi.tmpDir, 'imports');
+    this.encoding = 'utf-8';
+    this.per = 100;
+    this.keepOriginal = this.keepOriginal.bind(this);
+
+    // { pages: Page, users: User, ... }
+    this.collectionMap = {};
+    this.initCollectionMap(crowi.models);
+
+    // { pages: { _id: ..., path: ..., ...}, users: { _id: ..., username: ..., }, ... }
+    this.convertMap = {};
+    this.initConvertMap(crowi.models);
+  }
+
+  /**
+   * initialize collection map
+   *
+   * @memberOf ImportService
+   * @param {object} models from models/index.js
+   */
+  initCollectionMap(models) {
+    for (const model of Object.values(models)) {
+      this.collectionMap[model.collection.collectionName] = model;
+    }
+  }
+
+  /**
+   * initialize convert map. set keepOriginal as default
+   *
+   * @memberOf ImportService
+   * @param {object} models from models/index.js
+   */
+  initConvertMap(models) {
+    // by default, original value is used for imported documents
+    for (const model of Object.values(models)) {
+      const { collectionName } = model.collection;
+      this.convertMap[collectionName] = {};
+
+      for (const key of Object.keys(model.schema.paths)) {
+        this.convertMap[collectionName][key] = this.keepOriginal;
+      }
+    }
+  }
+
+  /**
+   * keep original value
+   * automatically convert ObjectId
+   *
+   * @memberOf ImportService
+   * @param {array<object>} _value value from imported document
+   * @param {{ _document: object, schema: object, key: string }}
+   * @return {any} new value for the document
+   */
+  keepOriginal(_value, { _document, schema, key }) {
+    let value;
+    if (schema[key].instance === 'ObjectID' && ObjectId.isValid(_value)) {
+      value = ObjectId(_value);
+    }
+    else {
+      value = _value;
+    }
+
+    return value;
+  }
+
+  /**
+   * import a collection from json
+   *
+   * @memberOf ImportService
+   * @param {object} Model instance of mongoose model
+   * @param {string} filePath path to zipped json
+   * @param {object} overwriteParams overwrite each document with unrelated value. e.g. { creator: req.user }
+   */
+  async importFromZip(Model, filePath, overwriteParams = {}) {
+    const { collectionName } = Model.collection;
+
+    // extract zip file
+    await this.unzip(filePath);
+
+    let counter = 0;
+    let nInsertedTotal = 0;
+
+    let failedIds = [];
+    let unorderedBulkOp = Model.collection.initializeUnorderedBulkOp();
+
+    const tmpJson = path.join(this.baseDir, `${collectionName}.json`);
+    const readStream = fs.createReadStream(tmpJson, { encoding: this.encoding });
+    const jsonStream = readStream.pipe(JSONStream.parse('*'));
+
+    jsonStream.on('data', async(document) => {
+      // documents are not persisted until unorderedBulkOp.execute()
+      unorderedBulkOp.insert(this.convertDocuments(Model, document, overwriteParams));
+
+      counter++;
+
+      if (counter % this.per === 0) {
+        // puase jsonStream to prevent more items to be added to unorderedBulkOp
+        jsonStream.pause();
+
+        const { nInserted, failed } = await this.execUnorderedBulkOpSafely(unorderedBulkOp);
+        nInsertedTotal += nInserted;
+        failedIds = [...failedIds, ...failed];
+
+        // reset initializeUnorderedBulkOp
+        unorderedBulkOp = Model.collection.initializeUnorderedBulkOp();
+
+        // resume jsonStream
+        jsonStream.resume();
+      }
+    });
+
+    jsonStream.on('end', async(data) => {
+      // insert the rest. avoid errors when unorderedBulkOp has no items
+      if (unorderedBulkOp.s.currentBatch !== null) {
+        const { nInserted, failed } = await this.execUnorderedBulkOpSafely(unorderedBulkOp);
+        nInsertedTotal += nInserted;
+        failedIds = [...failedIds, ...failed];
+      }
+
+      logger.info(`Done. Inserted ${nInsertedTotal} ${collectionName}.`);
+
+      if (failedIds.length > 0) {
+        logger.error(`Failed to insert ${failedIds.length} ${collectionName}: ${failedIds.join(', ')}.`);
+      }
+    });
+
+    // streamToPromise(jsonStream) throws error, so await readStream instead
+    await streamToPromise(readStream);
+
+    // clean up tmp directory
+    fs.unlinkSync(tmpJson);
+  }
+
+  /**
+   * extract a zip file
+   *
+   * @memberOf ImportService
+   * @param {string} zipFilePath path to zip file
+   */
+  unzip(zipFilePath) {
+    return new Promise((resolve, reject) => {
+      const unzipStream = fs.createReadStream(zipFilePath).pipe(unzip.Extract({ path: this.baseDir }));
+      unzipStream.on('error', (err) => {
+        reject(err);
+      });
+      unzipStream.on('close', () => {
+        resolve();
+      });
+    });
+  }
+
+  /**
+   * execute unorderedBulkOp and ignore errors
+   *
+   * @memberOf ImportService
+   * @param {object} unorderedBulkOp result of Model.collection.initializeUnorderedBulkOp()
+   * @return {{nInserted: number, failed: string[]}} number of docuemnts inserted and failed
+   */
+  async execUnorderedBulkOpSafely(unorderedBulkOp) {
+    // keep the number of documents inserted and failed for logger
+    let nInserted = 0;
+    const failed = [];
+
+    // try catch to skip errors
+    try {
+      const log = await unorderedBulkOp.execute();
+      nInserted = log.result.nInserted;
+    }
+    catch (err) {
+      for (const error of err.result.result.writeErrors) {
+        logger.error(error.errmsg);
+        failed.push(error.err.op._id);
+      }
+
+      nInserted = err.result.result.nInserted;
+    }
+
+    logger.debug(`Importing ${unorderedBulkOp.s.collection.s.name}. Inserted: ${nInserted}. Failed: ${failed.length}.`);
+
+    return {
+      nInserted,
+      failed,
+    };
+  }
+
+  /**
+   * execute unorderedBulkOp and ignore errors
+   *
+   * @memberOf ImportService
+   * @param {object} Model instance of mongoose model
+   * @param {object} _document document being imported
+   * @param {object} overwriteParams overwrite each document with unrelated value. e.g. { creator: req.user }
+   * @return {object} document to be persisted
+   */
+  convertDocuments(Model, _document, overwriteParams) {
+    const collectionName = Model.collection.collectionName;
+    const schema = Model.schema.paths;
+    const convertMap = this.convertMap[collectionName];
+
+    if (convertMap == null) {
+      throw new Error(`attribute map is not defined for ${collectionName}`);
+    }
+
+    const document = {};
+
+    // assign value from documents being imported
+    for (const entry of Object.entries(convertMap)) {
+      const [key, value] = entry;
+
+      // distinguish between null and undefined
+      if (_document[key] === undefined) {
+        continue; // next entry
+      }
+
+      document[key] = (typeof value === 'function') ? value(_document[key], { _document, key, schema }) : value;
+    }
+
+    // overwrite documents with custom values
+    for (const entry of Object.entries(overwriteParams)) {
+      const [key, value] = entry;
+
+      // distinguish between null and undefined
+      if (_document[key] !== undefined) {
+        document[key] = (typeof value === 'function') ? value(_document[key], { _document, key, schema }) : value;
+      }
+    }
+
+    return document;
+  }
+
+  /**
+   * get a model from collection name
+   *
+   * @memberOf ImportService
+   * @param {object} collectionName collection name
+   * @return {object} instance of mongoose model
+   */
+  getModelFromCollectionName(collectionName) {
+    const Model = this.collectionMap[collectionName];
+
+    if (Model == null) {
+      throw new Error(`cannot find a model for collection name "${collectionName}"`);
+    }
+
+    return Model;
+  }
+
+}
+
+module.exports = ImportService;

+ 3 - 0
src/server/views/admin/importer.html

@@ -39,6 +39,9 @@
       </div>
       {% endif %}
 
+      <!-- TODO: move to /imponents/Admin/Importer.jsx -->
+      <div id="growi-import"></div>
+
       <!-- esa Importer management forms -->
       <form action="/_api/admin/settings/importerEsa" method="post" class="form-horizontal" id="importerSettingFormEsa" role="form"
           data-success-messaage="{{ ('Updated') }}">

+ 1 - 0
tmp/downloads/.keep

@@ -0,0 +1 @@
+

+ 1 - 0
tmp/imports/.keep

@@ -0,0 +1 @@
+

+ 95 - 4
yarn.lock

@@ -1256,6 +1256,14 @@
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
+JSONStream@^1.3.5:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
+  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
+  dependencies:
+    jsonparse "^1.2.0"
+    through ">=2.2.7 <3"
+
 abab@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f"
@@ -1932,6 +1940,11 @@ bfj@^6.1.1:
     hoopy "^0.1.2"
     tryer "^1.0.0"
 
+big-integer@^1.6.17:
+  version "1.6.44"
+  resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.44.tgz#4ee9ae5f5839fc11ade338fea216b4513454a539"
+  integrity sha512-7MzElZPTyJ2fNvBkPxtFQ2fWIkVmuzw41+BZHSzpEq3ymB2MfeKp1+yXl/tS75xCx+WnyV+yb0kp+K1C3UNwmQ==
+
 big.js@^3.1.3:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e"
@@ -1950,6 +1963,14 @@ binary-extensions@^1.0.0:
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
 
+binary@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
+  integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
+  dependencies:
+    buffers "~0.1.1"
+    chainsaw "~0.1.0"
+
 bl@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
@@ -1980,6 +2001,11 @@ bluebird@^3.5.5:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
   integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
 
+bluebird@~3.4.1:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
+  integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=
+
 bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
@@ -2255,6 +2281,11 @@ buffer-from@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
 
+buffer-indexof-polyfill@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.1.tgz#a9fb806ce8145d5428510ce72f278bb363a638bf"
+  integrity sha1-qfuAbOgUXVQoUQznLyeLs2OmOL8=
+
 buffer-xor@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
@@ -2275,6 +2306,11 @@ buffer@^5.1.0:
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
 
+buffers@~0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
+  integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s=
+
 builtin-modules@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -2475,6 +2511,13 @@ chain-function@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
 
+chainsaw@~0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
+  integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=
+  dependencies:
+    traverse ">=0.3.0 <0.4"
+
 chalk@2.4.2, chalk@^2.0.1, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -3771,6 +3814,13 @@ dtrace-provider@~0.8:
   dependencies:
     nan "^2.3.3"
 
+duplexer2@~0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
+  dependencies:
+    readable-stream "^2.0.2"
+
 duplexer3@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
@@ -5025,6 +5075,16 @@ fstream@^1.0.0, fstream@^1.0.2:
     mkdirp ">=0.5 0"
     rimraf "2"
 
+fstream@^1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
+  integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
+  dependencies:
+    graceful-fs "^4.1.2"
+    inherits "~2.0.0"
+    mkdirp ">=0.5 0"
+    rimraf "2"
+
 function-bind@^1.0.2, function-bind@^1.1.0, function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -5329,7 +5389,7 @@ graceful-fs@^4.1.15:
   version "4.1.15"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
 
-graceful-fs@^4.2.0:
+graceful-fs@^4.2.0, graceful-fs@^4.2.2:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02"
   integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==
@@ -6954,6 +7014,11 @@ jsonify@~0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
 
+jsonparse@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
+
 jsonschema-draft4@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/jsonschema-draft4/-/jsonschema-draft4-1.0.0.tgz#f0af2005054f0f0ade7ea2118614b69dc512d865"
@@ -7163,6 +7228,11 @@ linkify-it@^2.0.0:
   dependencies:
     uc.micro "^1.0.1"
 
+listenercount@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
+  integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=
+
 load-css-file@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/load-css-file/-/load-css-file-1.0.0.tgz#dac097ead6470f4c3f23d4bc5b9ff2c3decb212f"
@@ -10187,7 +10257,7 @@ read-pkg@^3.0.0:
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.6:
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
   dependencies:
@@ -11058,7 +11128,7 @@ set-value@^2.0.0:
     is-plain-object "^2.0.3"
     split-string "^3.0.1"
 
-setimmediate@^1.0.4, setimmediate@^1.0.5:
+setimmediate@^1.0.4, setimmediate@^1.0.5, setimmediate@~1.0.4:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
 
@@ -12081,7 +12151,7 @@ through2@^2.0.0:
     readable-stream "^2.1.5"
     xtend "~4.0.1"
 
-through@2, through@^2.3.6, through@~2.3, through@~2.3.1:
+through@2, "through@>=2.2.7 <3", through@^2.3.6, through@~2.3, through@~2.3.1:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
 
@@ -12184,6 +12254,11 @@ tr46@^1.0.1:
   dependencies:
     punycode "^2.1.0"
 
+"traverse@>=0.3.0 <0.4":
+  version "0.3.9"
+  resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
+  integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=
+
 trim-newlines@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
@@ -12480,6 +12555,22 @@ unzip-response@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
 
+unzipper@^0.10.5:
+  version "0.10.5"
+  resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.5.tgz#4d189ae6f8af634b26efe1a1817c399e0dd4a1a0"
+  integrity sha512-i5ufkXNjWZYxU/0nKKf6LkvW8kn9YzRvfwuPWjXP+JTFce/8bqeR0gEfbiN2IDdJa6ZU6/2IzFRLK0z1v0uptw==
+  dependencies:
+    big-integer "^1.6.17"
+    binary "~0.3.0"
+    bluebird "~3.4.1"
+    buffer-indexof-polyfill "~1.0.0"
+    duplexer2 "~0.1.4"
+    fstream "^1.0.12"
+    graceful-fs "^4.2.2"
+    listenercount "~1.0.1"
+    readable-stream "~2.3.6"
+    setimmediate "~1.0.4"
+
 upath@^1.0.5, upath@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd"