cli-engine
Version:
Generic CLI Framework
378 lines (310 loc) • 10.4 kB
JavaScript
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
require('cli-engine-config');
var _http = require('cli-engine-command/lib/http');
var _http2 = _interopRequireDefault(_http);
var _path = require('path');
var _path2 = _interopRequireDefault(_path);
var _lock = require('./lock');
var _lock2 = _interopRequireDefault(_lock);
var _fsExtra = require('fs-extra');
var _fsExtra2 = _interopRequireDefault(_fsExtra);
var _moment = require('moment');
var _moment2 = _interopRequireDefault(_moment);
var _util = require('./util');
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
const debug = require('debug')('cli-engine:updater');
function mtime(f) {
return (0, _moment2.default)(_fsExtra2.default.statSync(f).mtime);
}
function timestamp(msg) {
return `[${(0, _moment2.default)().format()}] ${msg}`;
}
class Updater {
constructor(output) {
this.out = output;
this.config = output.config;
this.http = new _http2.default(output);
this.lock = new _lock2.default(output);
}
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;
}
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');
return `https://${this.config.s3.host}/${this.config.name}/channels/${channel}/${p}`;
}
async fetchManifest(channel) {
try {
return await this.http.get(this.s3url(channel, `${this.config.platform}-${this.config.arch}`));
} catch (err) {
if (err.statusCode === 403) throw new Error(`HTTP 403: Invalid channel ${channel}`);
throw err;
}
}
async fetchVersion(channel, download) {
let v;
try {
if (!download) v = await _fsExtra2.default.readJSON(this.versionFile);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
if (!v) {
v = await this.http.get(this.s3url(channel, 'version'));
await this._catch(() => _fsExtra2.default.writeJSON(this.versionFile, v));
}
return v;
}
_catch(fn) {
try {
return Promise.resolve(fn());
} catch (err) {
this.out.debug(err);
}
}
async update(manifest) {
let base = this.base(manifest);
const filesize = require('filesize');
if (!this.config.s3.host) throw new Error('S3 host not defined');
let url = `https://${this.config.s3.host}/${this.config.name}/channels/${manifest.channel}/${base}.tar.gz`;
let stream = await this.http.stream(url);
if (this.out.action.frames) {
// if spinner action
let total = stream.headers['content-length'];
let current = 0;
stream.on('data', data => {
current += data.length;
this.out.action.status = `${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();
let downgrade = await this.lock.upgrade();
// wait 1000ms for any commands that were partially loaded to finish loading
await (0, _util.wait)(1000);
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((0, _moment2.default)().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) {
this.out.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)) {
this.out.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((0, _moment2.default)().subtract(5, 'hours'));
} catch (err) {
if (err.code !== 'ENOENT') console.error(err.stack);
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.out.warn(e, 'autoupdate:')).unref();
} catch (e) {
this.out.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 () => {
let v = await this.fetchVersion(this.config.channel, 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.out.warn(`${this.config.name}: update available from ${this.config.version} to ${v.version}`);
}
if (v.message) {
this.out.warn(`${this.config.name}: ${v.message}`);
}
});
}
async checkIfUpdating() {
debug('check if updating');
if (!(await this.lock.canRead())) {
debug('update in process');
await this.restartCLI();
} else await this.lock.read();
debug('done checking if updating');
}
async restartCLI() {
let unread = await this.lock.read();
await unread();
const { spawnSync } = require('child_process');
const binPath = this.binPath;
if (!binPath) {
debug('cannot restart CLI, no binpath');
return;
}
debug('update complete, restarting CLI');
const { status } = this.spawnBinPath(spawnSync, binPath, process.argv.slice(2), { stdio: 'inherit' });
this.out.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.default = Updater;