hypery2k/galenframework-cli

View on GitHub
core/postinstall.js

Summary

Maintainability
C
1 day
Test Coverage
// Copyright 2016 Martin Reinhardt

/*
 * This simply downloads Galen
 */

'use strict';

const requestProgress = require('request-progress');
const replace = require('replace-in-file');
const Progress = require('progress');
const AdmZip = require('adm-zip');
const cp = require('child_process');
const fs = require('fs-extra');
const helper = require('./lib/helper');
const kew = require('kew');
const npmconf = require('npmconf');
const path = require('path');
const request = require('request');
const url = require('url');
const which = require('which');
const log = require('npmlog');

const cdnUrl = process.env.npm_config_galen_url || process.env.GALEN_CDNURL || 'https://github.com/galenframework/galen/releases/download/';
const downloadUrl = cdnUrl + '/galen-' + helper.version + '/galen-bin-' + helper.version + '.zip';

const originalPath = process.env.PATH;

// If the process exits without going through exit(), then we did not complete.
var validExit = false;

process.on('exit', function () {
  if (!validExit) {
    log.error('Install exited unexpectedly');
    exit(1);
  }
});

// NPM adds bin directories to the path, which will cause `which` to find the
// bin for this package not the actual galenframework bin.  Also help out people who
// put ./bin on their path
process.env.PATH = helper.cleanPath(originalPath);

const libPath = path.join(__dirname, 'lib');
const pkgPath = path.join(libPath, 'galen');
let galenPath = null;
let tmpPath = null;

// If the user manually installed galen, we want
// to use the existing version.
//
// Do not re-use a manually-installed galen with
// a different version.
//
// Do not re-use an npm-installed galen, because
// that can lead to weird circular dependencies between
// local versions and global versions.
const whichDeferred = kew.defer();
which('galen', whichDeferred.makeNodeResolver());
whichDeferred.promise
  .then((result) => {
    galenPath = result;
    // Horrible hack to avoid problems during global install. We check to see if
    // the file `which` found is our own bin script.
    if (galenPath.indexOf(path.join('npm', 'galenframework')) !== -1) {
      log.info('Looks like an `npm install -g` on windows; unable to check for already installed version.');
      throw new Error('Global install');
    }

    const contents = fs.readFileSync(galenPath, 'utf8');
    if (/NPM_INSTALL_MARKER/.test(contents)) {
      log.info('Looks like an `npm install -g`; unable to check for already installed version.');
      throw new Error('Global install');
    } else {
      const checkVersionDeferred = kew.defer();
      cp.execFile(galenPath, ['--version'], checkVersionDeferred.makeNodeResolver());
      return checkVersionDeferred.promise;
    }
  })
  .then((stdout) => {
    const regex = /^Version: ([0-9\.]+)$/;
    const result = stdout.trim().match(regex);
    const version = result[1];
    if (helper.version === version) {
      writeLocationFile(galenPath);
      log.info('galenframework is already installed at', galenPath + '.');
      exit(0);
    } else {
      log.info('galenframework detected, but wrong version', stdout.trim(), '@', galenPath + '.');
      downloadAndInstallGalen();
    }
  })
  .fail(downloadAndInstallGalen);


function downloadAndInstallGalen() {
  // Trying to use a local file failed, so initiate download and install
  // steps instead.
  const npmconfDeferred = kew.defer();
  npmconf.load(npmconfDeferred.makeNodeResolver());
  return npmconfDeferred.promise.then((conf) => {
    tmpPath = findSuitableTempDirectory(conf);

    const fileName = downloadUrl.split('/').pop();
    const downloadedFile = path.join(tmpPath, fileName);

    log.info('Running at platform: ' + process.platform);

    // Start the install.
    if (!fs.existsSync(downloadedFile)) {
      log.info('Downloading', downloadUrl);
      log.info('Saving to', downloadedFile);
      return requestBinary(getRequestOptions(conf), downloadedFile);
    } else {
      log.info('Download already available at', downloadedFile);
      return {
        requestOptions: getRequestOptions(conf),
        downloadedFile: downloadedFile
      };
    }
  })
    .then((response) => extractDownload(response.downloadedFile, response.requestOptions, false))
    .then((extractedPath) => copyIntoPlace(extractedPath, pkgPath))
    .then(() => {
      const location = libPath;
      writeLocationFile(location);

      log.info('Done. galen binary available at ', location);
      // Ensure executable is executable by all users
      fs.chmodSync(location, '755');
      fs.chmodSync(location + '/galen/galen', '755');
      fs.chmodSync(location + '/galen/galen.bat', '755');
      replace({
        files: location + '/galen/galen.bat',
        replace: 'com.galenframework.GalenMain %*',
        with: 'com.galenframework.GalenMain %* -Djna.nosys=true'
      }, (error, changedFiles) => {
        //Catch errors
        if (error) {
          log.error('Error occurred:', error);
        }
        //List changed files
        log.info('Modified files:', changedFiles.join(', '));
        exit(0);
      });
    })
    .fail(function (err) {
      log.error('Galen installation failed', err, err.stack);
      exit(1);
    });
}

/**
 * Save the destination directory back
 * @param {string} location - path of the directory
 */
function writeLocationFile(location) {
  log.info('Writing location.js file');
  if (process.platform === 'win32') {
    location = location.replace(/\\/g, '\\\\');
  }
  fs.writeFileSync(path.join(libPath, 'location.js'),
    'module.exports.location = \'' + location + '\';');
}

/**
 * Exit helper function
 * @param {int} code - exit code
 * @function
 */
function exit(code) {
  validExit = true;
  process.env.PATH = originalPath;
  process.exit(code || 0);
}

/**
 * Function to find an writable temp directory
 * @param {object} npmConf - current NPM configuration
 * @returns {string} representing suitable temp directory
 * @function
 */
function findSuitableTempDirectory(npmConf) {
  const now = Date.now();
  const candidateTmpDirs = [
    process.env.TMPDIR || process.env.TEMP || npmConf.get('tmp'),
    path.join(process.cwd(), 'tmp',
      '/tmp')
  ];

  for (let i = 0; i < candidateTmpDirs.length; i++) {
    const candidatePath = path.join(candidateTmpDirs[i], 'galenframework');

    try {
      fs.mkdirsSync(candidatePath, '0777');
      // Make double sure we have 0777 permissions; some operating systems
      // default umask does not allow write by default.
      fs.chmodSync(candidatePath, '0777');
      const testFile = path.join(candidatePath, now + '.tmp');
      fs.writeFileSync(testFile, 'test');
      fs.unlinkSync(testFile);
      return candidatePath;
    } catch (e) {
      log.info(candidatePath, 'is not writable:', e.message);
    }
  }

  log.error('Can not find a writable tmp directory.');
  exit(1);
}

/**
 * Create request opions object
 * @param {object} conf - current NPM config
 * @returns {{uri: string, encoding: null, followRedirect: boolean, headers: {}, strictSSL: *}}
 * @function
 */
function getRequestOptions(conf) {
  const strictSSL = conf.get('strict-ssl');

  let options = {
    uri: downloadUrl,
    encoding: null, // Get response as a buffer
    followRedirect: true, // The default download path redirects to a CDN URL.
    headers: {},
    strictSSL: strictSSL
  };

  let proxyUrl = conf.get('https-proxy') || conf.get('http-proxy') || conf.get('proxy');
  if (proxyUrl) {

    // Print using proxy
    let proxy = url.parse(proxyUrl);
    if (proxy.auth) {
      // Mask password
      proxy.auth = proxy.auth.replace(/:.*$/, ':******');
    }
    log.info('Using proxy ' + url.format(proxy));

    // Enable proxy
    options.proxy = proxyUrl;

    // If going through proxy, use the user-agent string from the npm config
    options.headers['User-Agent'] = conf.get('user-agent');
  }

  // Use certificate authority settings from npm
  const ca = conf.get('ca');
  if (ca) {
    log.info('Using npmconf ca');
    options.ca = ca;
  }

  return options;
}

/**
 * Downloads binary file
 * @param {object} requestOptions - to use for HTTP call
 * @param {string} filePath - download URL
 * @returns {*}
 * @function
 */
function requestBinary(requestOptions, filePath) {
  const deferred = kew.defer();
  const writePath = filePath + '-download-' + Date.now();

  log.info('Receiving...');
  let bar = null;
  requestProgress(request(requestOptions, (error, response, body) => {
    log.info('');
    if (!error && response.statusCode === 200) {
      fs.writeFileSync(writePath, body);
      log.info('Received ' + Math.floor(body.length / 1024) + 'K total.');
      fs.renameSync(writePath, filePath);
      deferred.resolve({
        requestOptions: requestOptions,
        downloadedFile: filePath
      });

    } else if (response) {
      log.error('Error requesting archive.\n' +
        'Status: ' + response.statusCode + '\n' +
        'Request options: ' + JSON.stringify(requestOptions, null, 2) + '\n' +
        'Response headers: ' + JSON.stringify(response.headers, null, 2) + '\n' +
        'Make sure your network and proxy settings are correct.\n\n');
      exit(1);
    } else if (error && error.stack && error.stack.indexOf('SELF_SIGNED_CERT_IN_CHAIN') != -1) {
      log.error('Error making request.');
      exit(1);
    } else if (error) {
      log.error('Error making request.\n' + error.stack + '\n\n' +
        'Please report this full log at https://github.com/hypery2k/galenframework-cli/issues');
      exit(1);
    } else {
      log.error('Something unexpected happened, please report this full ' +
        'log at https://github.com/hypery2k/galenframework-cli/issues');
      exit(1);
    }
  })).on('progress', (state) => {
    if (!bar) {
      bar = new Progress('  [:bar] :percent :etas', { total: state.total, width: 40 });
    }
    bar.curr = state.received;
    bar.tick(0);
  });

  return deferred.promise;
}

/**
 * Extracts the given Archive
 * @param {string} filePath - path of the ZIP archive to extract
 * @param {object} requestOptions - request options for retry attempt
 * @param {boolean} retry - set to true if it's already an retry attempt
 * @returns {*} - path of the extracted archive content
 * @function
 */
function extractDownload(filePath, requestOptions, retry) {
  const deferred = kew.defer();
  // extract to a unique directory in case multiple processes are
  // installing and extracting at once
  const extractedPath = filePath + '-extract-' + Date.now();
  let options = { cwd: extractedPath };

  fs.mkdirsSync(extractedPath, '0777');
  // Make double sure we have 0777 permissions; some operating systems
  // default umask does not allow write by default.
  fs.chmodSync(extractedPath, '0777');

  if (filePath.substr(-4) === '.zip') {
    log.info('Extracting zip contents');

    try {
      let zip = new AdmZip(filePath);
      zip.extractAllTo(extractedPath, true);
      deferred.resolve(extractedPath);
    } catch (err) {
      log.error('Error extracting zip');
      deferred.reject(err);
    }

  } else {
    log.info('Extracting tar contents (via spawned process)');
    cp.execFile('tar', ['jxf', filePath], options, function (err) {
      if (err) {
        if (!retry) {
          log.info('Error during extracting. Trying to download again.');
          fs.unlinkSync(filePath);
          return requestBinary(requestOptions, filePath).then(function (downloadedFile) {
            return extractDownload(downloadedFile, requestOptions, true);
          });
        } else {
          deferred.reject(err);
          log.error('Error extracting archive');
        }
      } else {
        deferred.resolve(extractedPath);
      }
    });
  }
  return deferred.promise;
}

/**
 * Helper function to move folder contents to target directory
 * @param {string} extractedPath - source directory path
 * @param {string} targetPath - target directory path
 * @returns {string} {!Promise.<RESULT>} promise for chaing
 * @function
 */
function copyIntoPlace(extractedPath, targetPath) {
  log.info('Removing', targetPath);
  return kew.nfcall(fs.remove, targetPath).then(function () {
    // Look for the extracted directory, so we can rename it.
    const files = fs.readdirSync(extractedPath);
    for (let i = 0; i < files.length; i++) {
      const file = path.join(extractedPath, files[i]);
      if (fs.statSync(file).isDirectory() && file.indexOf(helper.version) !== -1) {
        log.info('Copying extracted folder', file, '->', targetPath);
        return kew.nfcall(fs.move, file, targetPath);
      }
    }
    log.info('Could not find extracted file', files);
    throw new Error('Could not find extracted file');
  });
}