cli-engine
Version:
Generic CLI Framework
404 lines (342 loc) • 11.1 kB
JavaScript
'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;