UNPKG

happyuc-client-binaries

Version:
620 lines (504 loc) 16.6 kB
'use strict'; const got = require('got'), fs = require('fs'), crypto = require('crypto'), path = require('path'), tmp = require('tmp'), mkdirp = require('mkdirp'), unzip = require('node-unzip-2'), spawn = require('buffered-spawn'); const _ = { isEmpty: require('lodash.isempty'), get: require('lodash.get'), values: require('lodash.values'), }; function copyFile(src, dst) { return new Promise((resolve, reject) => { var rd = fs.createReadStream(src); rd.on('error', (err) => { reject(err); }); var wr = fs.createWriteStream(dst); wr.on('error', (err) => { reject(err); }); wr.on('close', (ex) => { resolve(); }); rd.pipe(wr); }); } function checksum(filePath, algorithm) { return new Promise((resolve, reject) => { const checksum = crypto.createHash(algorithm); const stream = fs.ReadStream(filePath); stream.on('data', (d) => checksum.update(d)); stream.on('end', () => { resolve(checksum.digest('hex')); }); stream.on('error', reject); }); } const DUMMY_LOGGER = { debug: function() { }, info: function() { }, warn: function() { }, error: function() { }, }; const DefaultConfig = exports.DefaultConfig = require('./config.json'); class Manager { /** * Construct a new instance. * * @param {Object} [config] The configuraton to use. If ommitted then the * default configuration (`DefaultConfig`) will be used. */ constructor(config) { this._config = config || DefaultConfig; this._logger = DUMMY_LOGGER; } /** * Get configuration. * @return {Object} */ get config() { return this._config; } /** * Set the logger. * @param {Object} val Should have same methods as global `console` object. */ set logger(val) { this._logger = {}; for (let key in DUMMY_LOGGER) { this._logger[key] = (val && typeof val[key] === 'function') ? val[key].bind(val) : DUMMY_LOGGER[key] ; } } /** * Get info on available clients. * * This will return an object, each item having the structure: * * "client name": { * id: "client name" * homepage: "client homepage url" * version: "client version" * versionNotes: "client version notes url" * cli: {... info on all available platforms...}, * activeCli: { * ...info for this platform... * } * status: { "available": true OR false (depending on status) "failReason": why it is not available (`sanityCheckFail`, `notFound`, etc) * } * } * * @return {Object} */ get clients() { return this._clients; } /** * Initialize the manager. * * This will scan for clients. * Upon completion `this.clients` will have all the info you need. * * @param {Object} [options] Additional options. * @param {Array} [options.folders] Additional folders to search in for client binaries. * * @return {Promise} */ init(options) { this._logger.info('Initializing...'); this._resolvePlatform(); return this._scan(options); } /** * Download a particular client. * * If client supports this platform then * it will be downloaded from the download URL, whether it is already available * on the system or not. * * If client doesn't support this platform then the promise will be rejected. * * Upon completion the `clients` property will have been updated with the new * availability status of this client. In addition the following info will * be returned from the promise: * * ``` * { * downloadFolder: ...where archive got downloaded... * downloadFile: ...location of downloaded file... * unpackFolder: ...where archive was unpacked to... * client: ...updated client object (contains availability info and full binary path)... * } * ``` * * @param {Object} [options] Options. * @param {Object} [options.downloadFolder] Folder to download client to, and to unzip it in. * @param {Function} [options.unpackHandler] Custom download archive unpack handling function. * @param {RegExp} [options.urlRegex] Regex to check the download URL against (this is a security measure). * * @return {Promise} */ download(clientId, options) { options = Object.assign({ downloadFolder: null, unpackHandler: null, urlRegex: null, }, options); this._logger.info(`Download binary for ${clientId} ...`); const client = _.get(this._clients, clientId); const activeCli = _.get(client, `activeCli`), downloadCfg = _.get(activeCli, `download`); return Promise.resolve().then(() => { // not for this machine? if (!client) { throw new Error(`${clientId} missing configuration for this platform.`); } if (!_.get(downloadCfg, 'url') || !_.get(downloadCfg, 'type')) { throw new Error(`Download info not available for ${clientId}`); } if (options.urlRegex) { this._logger.debug('Checking download URL against regex ...'); if (!options.urlRegex.test(downloadCfg.url)) { throw new Error(`Download URL failed regex check`); } } let resolve, reject; const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); this._logger.debug('Generating download folder path ...'); const downloadFolder = path.join( options.downloadFolder || tmp.dirSync().name, client.id, ); this._logger.debug(`Ensure download folder ${downloadFolder} exists ...`); mkdirp.sync(downloadFolder); const downloadFile = path.join(downloadFolder, `archive.${downloadCfg.type}`); this._logger.info( `Downloading package from ${downloadCfg.url} to ${downloadFile} ...`); const writeStream = fs.createWriteStream(downloadFile); const stream = got.stream(downloadCfg.url); // stream.pipe(progress({ // time: 100 // })); stream.pipe(writeStream); // stream.on('progress', (info) => ); stream.on('error', (err) => { this._logger.error(err); reject(new Error( `Error downloading package for ${clientId}: ${err.message}`)); }); stream.on('end', () => { this._logger.debug(`Downloaded ${downloadCfg.url} to ${downloadFile}`); // quick sanity check try { fs.accessSync(downloadFile, fs.F_OK | fs.R_OK); resolve({ downloadFolder: downloadFolder, downloadFile: downloadFile, }); } catch (err) { reject(new Error( `Error downloading package for ${clientId}: ${err.message}`)); } }); return promise; }).then((dInfo) => { const downloadFolder = dInfo.downloadFolder, downloadFile = dInfo.downloadFile; // test checksum let value, algorithm, expectedHash; if (value = _.get(downloadCfg, 'md5')) { expectedHash = value; algorithm = 'md5'; } else if (value = _.get(downloadCfg, 'sha256')) { expectedHash = value; algorithm = 'sha256'; } // hash algorithm if (algorithm) { return checksum(dInfo.downloadFile, algorithm).then((hash) => { if (expectedHash !== hash) { throw new Error( `Hash mismatch (using ${algorithm}): expected ${expectedHash}; got ${hash}`); } return dInfo; }); } else { return dInfo; } }).then((dInfo) => { const downloadFolder = dInfo.downloadFolder, downloadFile = dInfo.downloadFile; const unpackFolder = path.join(downloadFolder, 'unpacked'); this._logger.debug(`Ensure unpack folder ${unpackFolder} exists ...`); mkdirp.sync(unpackFolder); this._logger.debug(`Unzipping ${downloadFile} to ${unpackFolder} ...`); let promise; if (options.unpackHandler) { this._logger.debug(`Invoking custom unpack handler ...`); promise = options.unpackHandler(downloadFile, unpackFolder); } else { switch (downloadCfg.type) { case 'zip': this._logger.debug(`Using unzip ...`); promise = new Promise((resolve, reject) => { try { fs.createReadStream(downloadFile).pipe( unzip.Extract({path: unpackFolder}). on('close', resolve). on('error', reject), ).on('error', reject); } catch (err) { reject(err); } }); break; case 'tar': this._logger.debug(`Using tar ...`); promise = this._spawn('tar', ['-xf', downloadFile, '-C', unpackFolder]); break; default: throw new Error(`Unsupported archive type: ${downloadCfg.type}`); } } return promise.then(() => { this._logger.debug(`Unzipped ${downloadFile} to ${unpackFolder}`); const linkPath = path.join(unpackFolder, activeCli.bin); return Promise.resolve(linkPath); }).then((binPath) => { // make binary executable try { fs.chmodSync(binPath, '755'); } catch (e) { this._logger.warn(e); } return { downloadFolder: downloadFolder, downloadFile: downloadFile, unpackFolder: unpackFolder, }; }); }).then((info) => { return this._verifyClientStatus(client, { folders: [info.unpackFolder], }).then(() => { info.client = client; return info; }); }); } _resolvePlatform() { this._logger.info('Resolving platform...'); // platform switch (process.platform) { case 'win32': this._os = 'win'; break; case 'darwin': this._os = 'mac'; break; default: this._os = process.platform; } // architecture this._arch = process.arch; return Promise.resolve(); } /** * Scan the local machine for client software, as defined in the configuration. * * Upon completion `this._clients` will be set. * * @param {Object} [options] Additional options. * @param {Array} [options.folders] Additional folders to search in for client binaries. * * @return {Promise} */ _scan(options) { this._clients = {}; return this._calculatePossibleClients().then((clients) => { this._clients = clients; const count = Object.keys(this._clients).length; this._logger.info(`${count} possible clients.`); if (_.isEmpty(this._clients)) { return; } this._logger.info(`Verifying status of all ${count} possible clients...`); return Promise.all(_.values(this._clients).map( (client) => this._verifyClientStatus(client, options), )); }); } /** * Calculate possible clients for this machine by searching for binaries. * @return {Promise} */ _calculatePossibleClients() { return Promise.resolve().then(() => { // get possible clients this._logger.info('Calculating possible clients...'); const possibleClients = {}; for (let clientName in _.get(this._config, 'clients', {})) { let client = this._config.clients[clientName]; if (_.get(client, `platforms.${this._os}.${this._arch}`)) { possibleClients[clientName] = Object.assign({}, client, { id: clientName, activeCli: client.platforms[this._os][this._arch], }); } } return possibleClients; }); } /** * This will modify the passed-in `client` item according to check results. * * @param {Object} [options] Additional options. * @param {Array} [options.folders] Additional folders to search in for client binaries. * * @return {Promise} */ _verifyClientStatus(client, options) { options = Object.assign({ folders: [], }, options); this._logger.info(`Verify ${client.id} status ...`); return Promise.resolve().then(() => { const binName = client.activeCli.bin; // reset state client.state = {}; delete client.activeCli.binPath; this._logger.debug(`${client.id} binary name: ${binName}`); const binPaths = []; let command; let args = []; if (process.platform === 'win32') { command = 'where'; } else { command = 'command'; args.push('-v'); } args.push(binName); return this._spawn(command, args).then((output) => { const systemPath = _.get(output, 'stdout', '').trim(); if (_.get(systemPath, 'length')) { this._logger.debug(`Got PATH binary for ${client.id}: ${systemPath}`); binPaths.push(systemPath); } }, (err) => { this._logger.debug(`Command ${binName} not found in path.`); }).then(() => { // now let's search additional folders if (_.get(options, 'folders.length')) { options.folders.forEach((folder) => { this._logger.debug( `Checking for ${client.id} binary in ${folder} ...`); const fullPath = path.join(folder, binName); try { fs.accessSync(fullPath, fs.F_OK | fs.X_OK); this._logger.debug( `Got optional folder binary for ${client.id}: ${fullPath}`); binPaths.push(fullPath); } catch (err) { /* do nothing */ } }); } }).then(() => { if (!binPaths.length) { throw new Error(`No binaries found for ${client.id}`); } }).catch((err) => { this._logger.error( `Unable to resolve ${client.id} executable: ${binName}`); client.state.available = false; client.state.failReason = 'notFound'; throw err; }).then(() => { // sanity check each available binary until a good one is found return Promise.all(binPaths.map((binPath) => { this._logger.debug( `Running ${client.id} sanity check for binary: ${binPath} ...`); return this._runSanityCheck(client, binPath).catch((err) => { this._logger.debug(`Sanity check failed for: ${binPath}`); }); })).then(() => { // if one succeeded then we're good if (client.activeCli.fullPath) { return; } client.state.available = false; client.state.failReason = 'sanityCheckFail'; throw new Error('All sanity checks failed'); }); }).then(() => { client.state.available = true; }).catch((err) => { this._logger.debug(`${client.id} deemed unavailable`); client.state.available = false; }); }); } /** * Run sanity check for client. * @param {Object} client Client config info. * @param {String} binPath Path to binary (to sanity-check). * * @return {Promise} */ _runSanityCheck(client, binPath) { this._logger.debug(`${client.id} binary path: ${binPath}`); this._logger.info(`Checking for ${client.id} sanity check ...`); const sanityCheck = _.get(client, 'activeCli.commands.sanity'); return Promise.resolve().then(() => { if (!sanityCheck) { throw new Error(`No ${client.id} sanity check found.`); } }).then(() => { this._logger.info(`Checking sanity for ${client.id} ...`); return this._spawn(binPath, sanityCheck.args); }).then((output) => { const haystack = output.stdout + output.stderr; this._logger.debug(`Sanity check output: ${haystack}`); const needles = sanityCheck.output || []; for (let needle of needles) { if (0 > haystack.indexOf(needle)) { throw new Error(`Unable to find "${needle}" in ${client.id} output`); } } this._logger.debug(`Sanity check passed for ${binPath}`); // set it! client.activeCli.fullPath = binPath; }).catch((err) => { this._logger.error(`Sanity check failed for ${client.id}`, err); throw err; }); } /** * @return {Promise} Resolves to { stdout, stderr } object */ _spawn(cmd, args) { args = args || []; this._logger.debug(`Exec: "${cmd} ${args.join(' ')}"`); return spawn(cmd, args); } } exports.Manager = Manager;