UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

607 lines (560 loc) 16.3 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2017 Zenesis Ltd License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * John Spackman (john.spackman@zenesis.com, @johnspackman) ************************************************************************ */ const path = require("upath"); const fs = require("fs"); const async = require("async"); const { promisify } = require("util"); const child_process = require("child_process"); const psTree = require("ps-tree"); /** * @ignore(process) */ /* global process */ /** * Utility methods */ qx.Class.define("qx.tool.utils.Utils", { extend: qx.core.Object, statics: { /** * Creates a Promise which can be resolved/rejected externally - it has * the resolve/reject methods as properties * * @returns {Promise} a promise */ newExternalPromise() { var resolve; var reject; var promise = new Promise((resolve_, reject_) => { resolve = resolve_; reject = reject_; }); promise.resolve = resolve; promise.reject = reject; return promise; }, promisifyThis(fn, self, ...args) { return new Promise((resolve, reject) => { args = args.slice(); args.push(function (err, result) { if (err) { reject(err); } else { resolve(result); } }); try { fn.apply(self, args); } catch (ex) { reject(ex); } }); }, /** * Error that can be thrown to indicate wrong user input and which doesn't * need a stack trace * * @type {new (message: string) => Error} */ UserError: class extends Error { constructor(message) { super(message); this.name = "UserError"; this.stack = null; } }, /** * Formats the time in a human readable format, eg "1h 23m 45.678s" * * @param {number} millisec * @returns {string} formatted string */ formatTime(millisec) { var seconds = Math.floor(millisec / 1000); millisec %= 1000; var minutes = Math.floor(seconds / 60); seconds %= 60; var hours = Math.floor(minutes / 60); minutes %= 60; var result = ""; if (hours) { result += (hours > 9 ? hours : "0" + hours) + "h "; } if (hours || minutes) { result += (minutes > 9 ? minutes : "0" + minutes) + "m "; } if (seconds > 9 || (!hours && !minutes)) { result += seconds; } else if (hours || minutes) { result += "0" + seconds; } result += "." + (millisec > 99 ? "" : millisec > 9 ? "0" : "00") + millisec + "s"; return result; }, /** * Creates a dir * @param dir * @param cb */ mkpath(dir, cb) { dir = path.normalize(dir); var segs = dir.split(path.sep); var made = ""; async.eachSeries( segs, function (seg, cb) { if (made.length || !seg.length) { made += "/"; } made += seg; fs.exists(made, function (exists) { if (!exists) { fs.mkdir(made, function (err) { if (err && err.code === "EEXIST") { err = null; } cb(err); }); return; } fs.stat(made, function (err, stat) { if (err) { cb(err); } else if (stat.isDirectory()) { cb(null); } else { cb( new Error( "Cannot create " + made + " (in " + dir + ") because it exists and is not a directory", "ENOENT" ) ); } }); }); }, function (err) { cb(err); } ); }, /** * Creates the parent directory of a filename, if it does not already exist */ mkParentPath(dir, cb) { var segs = dir.split(/[\\\/]/); segs.pop(); if (!segs.length) { return cb && cb(); } dir = segs.join(path.sep); return this.mkpath(dir, cb); }, /** * Creates the parent directory of a filename, if it does not already exist * * @param {string} filename the filename to create the parent directory of * * @return {Promise?} the value */ makeParentDir(filename) { const mkParentPath = promisify(this.mkParentPath).bind(this); return mkParentPath(filename); }, /** * Creates a directory, if it does not exist, including all intermediate paths * * @param {string} filename the directory to create * * @return {Promise?} the value */ makeDirs(filename) { const mkpath = promisify(this.mkpath); return mkpath(filename); }, /** * Writable stream that keeps track of what the current line number is */ LineCountingTransform: null, /** * Writable stream that strips out sourceMappingURL comments */ StripSourceMapTransform: null, /** * Writable stream that keeps track of what's been written and can return * a copy as a string */ ToStringWriteStream: null, /* Function to test if an object is a plain object, i.e. is constructed ** by the built-in Object constructor and inherits directly from Object.prototype ** or null. Some built-in objects pass the test, e.g. Math which is a plain object ** and some host or exotic objects may pass also. ** ** @param {} obj - value to test ** @returns {Boolean} true if passes tests, false otherwise * * @see https://stackoverflow.com/a/5878101/2979698 */ isPlainObject(obj) { // Basic check for Type object that's not null if (typeof obj == "object" && obj !== null) { // If Object.getPrototypeOf supported, use it if (typeof Object.getPrototypeOf == "function") { var proto = Object.getPrototypeOf(obj); return proto === Object.prototype || proto === null; } // Otherwise, use internal class // This should be reliable as if getPrototypeOf not supported, is pre-ES5 return Object.prototype.toString.call(obj) == "[object Object]"; } // Not an object return false; }, /** * Runs the given command and returns an object containing information on the * `exitCode`, the `output`, potential `error`s, and additional `messages`. * @param {String} cwd The current working directory * @param {String} args One or more command line arguments, including the * command itself * @return {{exitCode: Number, output: String, error: *, messages: *}} */ async runCommand(cwd, ...args) { let options = {}; if (typeof cwd == "object") { options = cwd; } else { args = args.filter(value => { if (typeof value == "string") { return true; } if (!options) { options = value; } return false; }); if (!options.cwd) { options.cwd = cwd; } if (!options.cmd) { options.cmd = args.shift(); } if (!options.args) { options.args = args; } } if (!options.error) { options.error = console.error; } if (!options.log) { options.log = console.log; } return await new Promise((resolve, reject) => { let env = process.env; if (options.env) { env = Object.assign({}, env); Object.assign(env, options.env); } let proc = child_process.spawn(options.cmd, options.args, { cwd: options.cwd, shell: true, env: env }); let result = { exitCode: null, output: "", error: "", messages: null }; proc.stdout.on("data", data => { data = data.toString().trim(); options.log(data); result.output += data; }); proc.stderr.on("data", data => { data = data.toString().trim(); options.error(data); result.error += data; }); proc.on("close", code => { result.exitCode = code; resolve(result); }); proc.on("error", err => { reject(err); }); }); }, /** * Awaitable wrapper around child_process.spawn. * Runs a command in a separate process. The output of the command * is ignored. Throws when the exit code is not 0. * @param {String} cmd Name of the command * @param {Array} args Array of arguments to the command * @return {Promise<Number>} A promise that resolves with the exit code */ run(cmd, args) { let opts = { env: process.env }; return new Promise((resolve, reject) => { let exe = child_process.spawn(cmd, args, opts); // suppress all output unless in verbose mode exe.stdout.on("data", data => { qx.log.Logger.debug(data.toString()); }); exe.stderr.on("data", data => { qx.log.Logger.error(data.toString()); }); exe.on("close", code => { if (code !== 0) { let message = `Error executing '${cmd} ${args.join( " " )}'. Use --verbose to see what went wrong.`; reject(new qx.tool.utils.Utils.UserError(message)); } else { resolve(0); } }); exe.on("error", reject); }); }, /** * Awaitable wrapper around child_process.exec * Executes a command and return its result wrapped in a Promise. * @param cmd {String} Command with all parameters * @return {Promise<String>} Promise that resolves with the result */ exec(cmd) { return new Promise((resolve, reject) => { child_process.exec(cmd, (err, stdout, stderr) => { if (err) { reject(err); } if (stderr) { reject(new Error(stderr)); } resolve(stdout); }); }); }, /** * Parses a command line and separates them out into an array that can be given to `child_process.spawn` etc * * @param {String} cmd * @returns {String[]} */ parseCommand(str) { let inQuote = null; let inArg = false; let lastC = null; let start = 0; let args = []; for (let i = 0; i < str.length; i++) { let c = str[i]; if (inQuote) { if (c == inQuote) { inQuote = null; } continue; } if (c == '"' || c == "'") { inQuote = c; if (!inArg) { inArg = true; start = i; } continue; } if (c == " " || c == "\t") { if (inArg) { let arg = str.substring(start, i); args.push(arg); inArg = false; } } else { if (!inArg) { inArg = true; start = i; } } } if (inArg) { let arg = str.substring(start); args.push(arg); } return args; }, /** * Quotes special characters in the argument array, ensuring that they are safe to pass to the command line * * @param {String[]} cmd * @returns {String[]} */ quoteCommand(cmd) { const SPECIALS = '&*?;# "'; cmd = cmd.map(arg => { let c = arg[0]; if ((c == "'" || c == '"') && c == arg[arg.length - 1]) { return arg; } if (arg.indexOf("'") > -1) { if (arg.indexOf('"') > -1) { return "$'" + arg.replace(/'/g, "\\'") + "'"; } return '"' + arg + '"'; } for (let i = 0; i < SPECIALS.length; i++) { if (arg.indexOf(SPECIALS[i]) > -1) { return "'" + arg + "'"; } } return arg; }); return cmd; }, /** * Reformats a command line * * @param {String} cmd * @returns {String} */ formatCommand(cmd) { return qx.tool.utils.Utils.quoteCommand(cmd).join(" "); }, /** * Kills a process tree * * @param {Number} parentId parent process ID to kill */ async killTree(parentId) { await new qx.Promise((resolve, reject) => { psTree(parentId, function (err, children) { if (err) { reject(err); return; } children.forEach(item => { try { process.kill(item.PID); } catch (ex) { // Nothing } }); try { process.kill(parentId); } catch (ex) { // Nothing } resolve(); }); }); }, /** * Returns the absolute path to the template directory * @return {String} */ getTemplateDir() { let dir = qx.util.ResourceManager.getInstance().toUri( "qx/tool/cli/templates/template_vars.js" ); dir = path.dirname(dir); return dir; }, /** * Detects whether the command line explicit set an option (as opposed to yargs * providing a default value). Note that this does not handle aliases, use the * actual, full option name. * * @param option {String} the name of the option, eg "listen-port" * @return {Boolean} */ isExplicitArg(option) { function searchForOption(option) { return process.argv.indexOf(option) > -1; } return searchForOption(`-${option}`) || searchForOption(`--${option}`); } }, defer(statics) { const { Writable, Transform } = require("stream"); class LineCountingTransform extends Transform { constructor(options) { super(options); this.__lineNumber = 1; } _write(chunk, encoding, callback) { let str = chunk.toString(); for (let i = 0; i < str.length; i++) { if (str[i] == "\n") { this.__lineNumber++; } } this.push(str); callback(); } getLineNumber() { return this.__lineNumber; } } statics.LineCountingTransform = LineCountingTransform; class StripSourceMapTransform extends Transform { constructor(options) { super(options); this.__lastLine = ""; } _transform(chunk, encoding, callback) { let str = this.__lastLine + chunk.toString(); let pos = str.lastIndexOf("\n"); if (pos > -1) { this.__lastLine = str.substring(pos); str = str.substring(0, pos); } else { this.__lastLine = str; str = ""; } str = str.replace(/\n\/\/\#\s*sourceMappingURL=.*$/m, ""); this.push(str); callback(); } _flush(callback) { let str = this.__lastLine; this.__lastLine = null; str = str.replace(/\n\/\/\#\s*sourceMappingURL=.*$/m, ""); this.push(str); callback(); } } statics.StripSourceMapTransform = StripSourceMapTransform; class ToStringWriteStream extends Writable { constructor(dest, options) { super(options); this.__dest = dest; this.__value = ""; } _write(chunk, encoding, callback) { this.__value += chunk.toString(); if (this.__dest) { this.__dest.write(chunk, encoding, callback); } else if (callback) { callback(); } } toString() { return this.__value; } } statics.ToStringWriteStream = ToStringWriteStream; } });