UNPKG

cli-engine

Version:
404 lines (342 loc) 11.1 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.Updater = undefined; var _path = require('path'); var _path2 = _interopRequireDefault(_path); var _fsExtra = require('fs-extra'); var _fsExtra2 = _interopRequireDefault(_fsExtra); require('cli-engine-config'); var _cliUx = require('cli-ux'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } const deps = { get Lock() { return this._lock || (this._lock = require('./lock').default); }, get HTTP() { return this._http || (this._http = require('http-call').default); }, get moment() { return this._moment || (this._moment = require('moment')); }, get util() { return this._util || (this._util = require('./util')); }, get wait() { return this.util.wait; } }; const debug = require('debug')('cli:updater'); function mtime(f) { return deps.moment(_fsExtra2.default.statSync(f).mtime); } function timestamp(msg) { return `[${deps.moment().format()}] ${msg}`; } class Updater { constructor(config, cli) { this.config = config; this.cli = cli || new _cliUx.CLI({ mock: config.mock }); this.lock = new deps.Lock(config); } get autoupdatefile() { return _path2.default.join(this.config.cacheDir, 'autoupdate'); } get autoupdatelogfile() { return _path2.default.join(this.config.cacheDir, 'autoupdate.log'); } get binPath() { return process.env.CLI_BINPATH || this.config.bin; } get updateDir() { return _path2.default.join(this.config.dataDir, 'tmp', 'u'); } get versionFile() { return _path2.default.join(this.config.cacheDir, `${this.config.channel}.version`); } s3url(channel, p) { if (!this.config.s3.host) throw new Error('S3 host not defined'); if (/^https?:\/\/.*/.test(this.config.s3.host)) { return `${this.config.s3.host}/${this.config.name}/channels/${channel}/${p}`; } else { return `https://${this.config.s3.host}/${this.config.name}/channels/${channel}/${p}`; } } async fetchManifest(channel) { try { let { body } = await deps.HTTP.get(this.s3url(channel, `${this.config.platform}-${this.config.arch}`)); return body; } catch (err) { if (err.statusCode === 403) throw new Error(`HTTP 403: Invalid channel ${channel}`); throw err; } } async fetchVersion(download) { let v; try { if (!download) v = await _fsExtra2.default.readJSON(this.versionFile); } catch (err) { if (err.code !== 'ENOENT') throw err; } if (!v) { debug('fetching latest %s version', this.config.channel); let { body } = await deps.HTTP.get(this.s3url(this.config.channel, 'version')); v = body; await this._catch(() => _fsExtra2.default.writeJSON(this.versionFile, v)); } return v; } async _catch(fn) { try { return await Promise.resolve(fn()); } catch (err) { debug(err); } } async update(manifest) { let base = this.base(manifest); const filesize = require('filesize'); let url = this.s3url(manifest.channel, `${base}.tar.gz`); let { response: stream } = await deps.HTTP.stream(url); if (this.cli.action.frames) { // if spinner action let total = stream.headers['content-length']; let current = 0; const throttle = require('lodash.throttle'); const updateStatus = throttle(newStatus => { this.cli.action.status = newStatus; }, 500, { leading: true, trailing: false }); stream.on('data', data => { current += data.length; updateStatus(`${filesize(current)}/${filesize(total)}`); }); } _fsExtra2.default.mkdirpSync(this.updateDir); let dirs = this._dirs(require('tmp').dirSync({ dir: this.updateDir }).name); let dir = _path2.default.join(this.config.dataDir, 'client'); let tmp = dirs.extract; await this.extract(stream, tmp, manifest.sha256gz); let extracted = _path2.default.join(dirs.extract, base); this._cleanup(); this.cli.action.status = 'finishing up'; let downgrade = await this.lock.upgrade(); // wait 2000ms for any commands that were partially loaded to finish loading await deps.wait(2000); if (await _fsExtra2.default.exists(dir)) this._rename(dir, dirs.client); this._rename(extracted, dir); downgrade(); this._cleanupDirs(dirs); } extract(stream, dir, sha) { const zlib = require('zlib'); const tar = require('tar-fs'); const crypto = require('crypto'); return new Promise((resolve, reject) => { let shaValidated = false; let extracted = false; let check = () => { if (shaValidated && extracted) { resolve(); } }; let fail = err => { this._catch(() => { if (_fsExtra2.default.existsSync(dir)) { _fsExtra2.default.removeSync(dir); } }); reject(err); }; let hasher = crypto.createHash('sha256'); stream.on('error', fail); stream.on('data', d => hasher.update(d)); stream.on('end', () => { let shasum = hasher.digest('hex'); if (sha === shasum) { shaValidated = true; check(); } else { reject(new Error(`SHA mismatch: expected ${shasum} to be ${sha}`)); } }); let ignore = function (_, header) { switch (header.type) { case 'directory': case 'file': return false; case 'symlink': return true; default: throw new Error(header.type); } }; let extract = tar.extract(dir, { ignore }); extract.on('error', fail); extract.on('finish', () => { extracted = true; check(); }); let gunzip = zlib.createGunzip(); gunzip.on('error', fail); stream.pipe(gunzip).pipe(extract); }); } _cleanup() { let dir = this.updateDir; this._catch(() => { if (_fsExtra2.default.existsSync(dir)) { _fsExtra2.default.readdirSync(dir).forEach(d => { let dirs = this._dirs(_path2.default.join(dir, d)); this._remove(dirs.node); if (mtime(dirs.dir).isBefore(deps.moment().subtract(24, 'hours'))) { this._cleanupDirs(dirs); } else { this._removeIfEmpty(dirs); } }); } }); } _cleanupDirs(dirs) { this._moveNode(dirs); this._remove(dirs.client); this._remove(dirs.extract); this._removeIfEmpty(dirs); } _removeIfEmpty(dirs) { this._catch(() => { if (_fsExtra2.default.readdirSync(dirs.dir).length === 0) { this._remove(dirs.dir); } }); } _dirs(dir) { let client = _path2.default.join(dir, 'client'); let extract = _path2.default.join(dir, 'extract'); let node = _path2.default.join(dir, 'node.exe'); return { dir, client, extract, node }; } _rename(src, dst) { debug(`rename ${src} to ${dst}`); // moveSync tries to do a rename first then falls back to copy & delete // on windows the delete would error on node.exe so we explicitly rename let rename = this.config.windows ? _fsExtra2.default.renameSync : _fsExtra2.default.moveSync; rename(src, dst); } _remove(dir) { this._catch(() => { if (_fsExtra2.default.existsSync(dir)) { debug(`remove ${dir}`); _fsExtra2.default.removeSync(dir); } }); } _moveNode(dirs) { this._catch(() => { let dirDeleteNode = _path2.default.join(dirs.client, 'bin', 'node.exe'); if (_fsExtra2.default.existsSync(dirDeleteNode)) { this._rename(dirDeleteNode, dirs.node); } }); } base(manifest) { return `${this.config.name}-v${manifest.version}-${this.config.platform}-${this.config.arch}`; } get autoupdateNeeded() { try { return mtime(this.autoupdatefile).isBefore(deps.moment().subtract(5, 'hours')); } catch (err) { if (err.code !== 'ENOENT') console.error(err.stack); debug(err); return true; } } async autoupdate(force = false) { try { await this.checkIfUpdating(); await this.warnIfUpdateAvailable(); if (!force && !this.autoupdateNeeded) return; debug('autoupdate running'); _fsExtra2.default.outputFileSync(this.autoupdatefile, ''); const binPath = this.binPath; if (!binPath) { debug('no binpath set'); return; } debug(`spawning autoupdate on ${binPath}`); let fd = _fsExtra2.default.openSync(this.autoupdatelogfile, 'a'); _fsExtra2.default.write(fd, timestamp(`starting \`${binPath} update --autoupdate\` from ${process.argv.slice(2, 3).join(' ')}\n`)); const { spawn } = require('child_process'); this.spawnBinPath(spawn, binPath, ['update', '--autoupdate'], { detached: !this.config.windows, stdio: ['ignore', fd, fd], env: this.autoupdateEnv }).on('error', e => this.cli.warn(e, 'autoupdate:')).unref(); } catch (e) { this.cli.warn(e, 'autoupdate:'); } } get timestampEnvVar() { // TODO: use function from cli-engine-config let bin = this.config.bin.replace('-', '_').toUpperCase(); return `${bin}_TIMESTAMPS`; } get autoupdateEnv() { return Object.assign({}, process.env, { [this.timestampEnvVar]: '1' }); } async warnIfUpdateAvailable() { await this._catch(async () => { if (!this.config.s3) return; let v = await this.fetchVersion(false); let local = this.config.version.split('.'); let remote = v.version.split('.'); if (parseInt(local[0]) < parseInt(remote[0]) || parseInt(local[1]) < parseInt(remote[1])) { this.cli.warn(`${this.config.name}: update available from ${this.config.version} to ${v.version}`); } if (v.message) { this.cli.warn(`${this.config.name}: ${v.message}`); } }); } async checkIfUpdating() { if (!(await this.lock.canRead())) { debug('update in process'); await this.restartCLI(); } else await this.lock.read(); } async restartCLI() { let unread = await this.lock.read(); await unread(); const { spawnSync } = require('child_process'); let bin = this.binPath; let args = process.argv.slice(2); if (!bin) { if (this.config.initPath) { bin = process.argv[0]; args.unshift(this.config.initPath); } else { debug('cannot restart CLI, no binpath'); return; } } debug('update complete, restarting CLI'); const env = { ...process.env, CLI_ENGINE_HIDE_UPDATED_MESSAGE: '1' }; const { status } = this.spawnBinPath(spawnSync, bin, args, { env, stdio: 'inherit' }); this.cli.exit(status); } spawnBinPath(spawnFunc, binPath, args, options) { if (this.config.windows) { args = ['/c', binPath].concat(args); return spawnFunc(process.env.comspec || 'cmd.exe', args, options); } else { return spawnFunc(binPath, args, options); } } } exports.Updater = Updater;