UNPKG

mlproj

Version:

Project management for MarkLogic

1,109 lines (1,023 loc) 40.2 kB
"use strict"; (function() { const buf = require('buffer'); const fs = require('fs'); const os = require('os'); const path = require('path'); const read = require('readline-sync'); const request = require('sync-request'); const uuid = require('uuid'); const crypto = require('crypto'); const xml = require('xml2js'); const watch = require('node-watch'); const mmatch = require("minimatch"); const core = require('mlproj-core'); const chalk = require('chalk'); // bold is wrong on cygwin monitors (incl. cmder), text is not displayed if ( process.env.TERM === 'cygwin' ) { const bold = chalk.styles.bold; bold.open = ''; bold.close = ''; } /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The watch command, specific to the Node frontend. */ class WatchCommand extends core.LoadCommand { isDeploy() { return true; } populateActions(actions, db, src, srv) { // simple file if ( src.doc ) { actions.add(new WatchAction('Watch source file', db, src.doc, false, (path, root, ctxt) => { this.insert(path, root, db, ctxt); })); } // rest dir else if ( src.type === 'rest-src' ) { const pf = actions.ctxt.platform; const dir = src.prop('dir'); const port = (srv || src.restTarget()).props.port.value; const root = dir + '/root'; const services = dir + '/services'; const transforms = dir + '/transforms'; if ( pf.exists(root) ) { actions.add(new WatchAction('Watch source directory', db, root, true, (path, root, ctxt) => { this.insert(path, root, db, ctxt); })); } if ( pf.exists(services) ) { actions.add(new WatchAction('Watch services directory', db, services, true, (path, root, ctxt) => { this.install(path, root, src, 'resources', port, ctxt); })); } if ( pf.exists(transforms) ) { actions.add(new WatchAction('Watch transforms directory', db, transforms, true, (path, root, ctxt) => { this.install(path, root, src, 'transforms', port, ctxt); })); } } // plain dir else { const dir = src.prop('dir'); actions.add(new WatchAction('Watch source directory', db, dir, true, (path, root, ctxt) => { this.insert(path, root, db, ctxt); })); } } insert(path, root, db, ctxt) { try { var uri = path.replace(/\\/g, '/').slice(root.length); var act = new core.actions.DocInsert(db, uri, path); act.execute(ctxt); } catch ( err ) { ctxt.display.error(err, ctxt.display.verbose); } } install(path, root, src, kind, port, ctxt) { try { var filename = path.replace(/\\/g, '/').slice(root.length + 1); let act = src.installRestThing(port, kind, filename, path); act.execute(ctxt); } catch ( err ) { ctxt.display.error(err, ctxt.display.verbose); } } } class WatchAction extends core.actions.Action { constructor(msg, db, path, recursive, onMatch) { let p = path.replace(/\\/g, '/'); super([ msg, p ]); this.db = db; this.path = p; this.recursive = recursive; this.onMatch = onMatch; } // TODO: Apply the same filtering logic here as in DeployCommand (and // share the mecanism with LoadCommand as well), once implemented... // // -> that is, now it is called the "source sets", and we need to // support their garbage, include and exclude properties // execute(ctxt) { const pf = ctxt.platform; pf.warn(pf.yellow('→') + ' ' + this.msg[0], this.msg[1]); watch(this.path, { recursive: true, filter: f => { if ( fs.existsSync(f) && fs.statSync(f).isDirectory() ) { return false; } const tokens = f.split(/[\/\\]/); const last = tokens.pop(); // TODO: Didn't I remove another use of startsWith because // it was too recent? return ! (last.startsWith('.') || last.endsWith('~')); } }) .on('change', (type, path) => { if ( type === 'update' ) { this.onMatch(path, this.path, ctxt); } else if ( type === 'remove' ) { pf.log(`TODO: File ${path} has been removed, delete it!`); } else { pf.warn(pf.red('✗') + ' Error watching for changes', 'Invalid change type: ' + type); } }) .on('error', (err) => { pf.warn(pf.red('✗') + ' Error watching for changes', new String(err)); }) .on('close', function() { pf.warn(pf.yellow('→') + ' Stopping the watcher'); }); } } /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The context implementation for Node. */ class Context extends core.Context { constructor(dry, verbose, trace, tracedir) { const json = f => { try { return Platform.userJson(f); } catch (err) { // just ignore when the file does not exust if ( err.name !== 'no-such-file' ) { throw err; } } }; // try one config file or the other var conf = json('.mlproj.json') || json('mlproj.json'); // instantiate the base object super(new Display(verbose), new Platform(), conf, dry, verbose); this.trace = trace; this.tracedir = tracedir; } } /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The platform implementation for Node. */ class Platform extends core.Platform { constructor() { super(process.cwd()); } newMinimatch(pattern, options) { return new mmatch.Minimatch(pattern, options); } mkdir(path, force) { try { fs.mkdirSync(path); } catch ( err ) { if ( force && err.code === 'EEXIST' ) { // ignore } else { throw err; } } } debug(msg) { console.log('DEBUG: ' + msg); } // TODO: To remove... log(msg, name) { console.log(Display.pad(msg, name)); } warn(msg, name) { console.warn(Display.pad(msg, name)); } // TODO: To remove... line(indent, name, value) { let spaces = ''; while ( indent-- ) { spaces += ' '; } this.log(spaces + name, value); } resolve(href, base) { return Platform.staticResolve(href, base); } read(path, encoding) { try { return fs.readFileSync(path, encoding); } catch (err) { if ( err.code === 'ENOENT' ) { throw core.error.noSuchFile(path); } else { throw err; } } } projectXml(path) { var parser = new xml.Parser(); var content = this.read(path); var p; parser.parseString(content, (err, result) => { if ( err ) { throw new Error('Error parsing XML: ' + err + ', at ' + path); } if ( ! result || ! result.project ) { throw new Error('Bad project.xml, no document or no project element: ' + path); } if ( ! result.project['$'] || ! result.project['$'].abbrev ) { throw new Error('Bad project.xml, no abbrev: ' + path); } p = result.project; }); if ( ! p ) { // the following page makes it clear it is not async, just using // a callback, synchronously: // https://github.com/Leonidas-from-XIV/node-xml2js/issues/159#issuecomment-248599477 throw new Error('Internal error. Has xml2js become async? Please report this.'); } let project = {}; if ( p['$'].abbrev ) project.abbrev = p['$'].abbrev; if ( p['$'].name ) project.name = p['$'].name; if ( p['$'].version ) project.version = p['$'].version; if ( p.title ) project.title = p.title[0]; return project; } write(path, content, force) { if ( ! force && this.exists(path) ) { throw new Error('File already exists, do not override: ' + path); } fs.writeFileSync(path, content, 'utf8'); } // TODO: To remove... green(s) { return chalk.green(s); } // TODO: To remove... yellow(s) { return chalk.yellow(s); } // TODO: To remove... red(s) { return chalk.red(s); } // TODO: To remove... bold(s) { return chalk.bold(s); } credentials() { // set in Environ ctor, find a nicer way to pass the info if ( ! this.environ ) { throw new Error('No environ set on the platform for credentials'); } var user = this.environ.param('@user'); var pwd = this.environ.param('@password'); if ( ! user ) { throw new Error('No user in environ'); } if ( ! pwd ) { // ask for password interactively first time it is used pwd = read.question('Password: ', { hideEchoBack: true }); this.environ.param('@password', pwd); } return [ user, pwd ]; } requestAuth(method, url, options) { const md5 = (name, str) => { return crypto.createHash('md5').update(str).digest('hex'); }; const parseDigest = header => { if ( ! header || header.slice(0, 7) !== 'Digest ' ) { throw new Error('Expect WWW-Authenticate for digest, got: ' + header); } return header.substring(7).split(/,\s+/).reduce((obj, s) => { var eq = s.indexOf('='); if ( eq === -1 ) { throw new Error('Digest parsing: param with no equal sign: ' + s); } var name = s.slice(0, eq); var value = s.slice(eq + 1); obj[name] = value.replace(/"/g, '') return obj }, {}); }; const renderDigest = params => { const attr = (key, quote) => { if ( params[key] ) { attrs.push(key + '=' + quote + params[key] + quote); } }; var attrs = []; attr('username', '"'); attr('realm', '"'); attr('nonce', '"'); attr('uri', '"'); attr('algorithm', ''); attr('response', '"'); attr('opaque', '"'); attr('qop', ''); attr('nc', ''); attr('cnonce', '"'); return 'Digest ' + attrs.join(', '); }; const auth = header => { var params = parseDigest(header); if ( ! params.qop ) { throw new Error('Not supported: qop is unspecified'); } else if ( params.qop === 'auth-int' ) { throw new Error('Not supported: qop is auth-int'); } else if ( params.qop === 'auth' ) { // keep going... } else { if ( params.qop.split(/,/).includes('auth') ) { // keep going... params.qop = 'auth'; } else { throw new Error('Not supported: qop is ' + params.qop); } } // TODO: Handle NC and CNONCE var nc = '00000002'; var cnonce = '4f1ab28fcd820bc6'; var ha1 = md5('ha1', creds[0] + ':' + params.realm + ':' + creds[1]); var path; if ( url.startsWith('http://') ) { path = url.slice(7); } else if ( url.startsWith('https://') ) { path = url.slice(8); } else { throw new Error('URL is neither HTTP or HTTPS: ' + url); } var slash = path.indexOf('/'); path = slash === -1 ? '/' : path.slice(slash); var ha2 = md5('ha2', method + ':' + path); var resp = md5('response', [ha1, params.nonce, nc, cnonce, params.qop, ha2].join(':')); var auth = { username: creds[0], realm: params.realm, nonce: params.nonce, uri: path, qop: params.qop, response: resp, nc: nc, cnonce: cnonce, opaque: params.opaque, algorithm: params.algorithm }; return renderDigest(auth); }; var resp = request(method, url, options); var i = 0; var creds = this.credentials(); while ( resp.statusCode === 401 ) { if ( ++i > 3 ) { throw new Error('Too many authentications failed: ' + url); } if ( ! options.headers ) { options.headers = {}; } options.headers.authorization = auth(resp.headers['www-authenticate']); resp = request(method, url, options); } return resp; } extractBody(resp) { let body = resp.body; let ctype = resp.headers && body.length && resp.headers['content-type']; if ( ctype ) { // TODO: Parse it properly, e.g. "application/json; charset=UTF-8" if ( ctype.startsWith('application/json') ) { body = JSON.parse(body); } // TODO: Parse it properly, e.g. "application/xml; charset=UTF-8" else if ( ctype.startsWith('application/xml') ) { body = body.toString(); } } return body; } get(params, path) { let tracer = new HttpTracer(this.environ); tracer.params('GET', params, path); let url = this.url(params, path); let options = {}; options.headers = params.headers || {}; if ( options.headers.accept === undefined ) { options.headers.accept = 'application/json'; } tracer.request('GET', url, options); let resp = this.requestAuth('GET', url, options); let result = { status : resp.statusCode, headers : resp.headers, body : this.extractBody(resp) }; tracer.response(result); return result; } delete(params, path) { let tracer = new HttpTracer(this.environ); tracer.params('DELETE', params, path); let url = this.url(params, path); let options = {}; options.headers = params.headers || {}; tracer.request('DELETE', url, options); let resp = this.requestAuth('DELETE', url, options); let result = { status : resp.statusCode, headers : resp.headers, body : this.extractBody(resp) }; tracer.response(result); return result; } post(params, path, data, mime) { let tracer = new HttpTracer(this.environ); tracer.params('POST', params, path, data, mime); let url = this.url(params, path); let options = {}; options.headers = params.headers || {}; let body = data || params.body; let type = mime || params.type || options.headers['content-type']; if ( ! options.headers.accept ) { options.headers.accept = 'application/json'; } if ( body && type ) { options.headers['content-type'] = type; options.body = body; } else if ( body ) { options.json = body; } else { options.headers['content-type'] = 'application/x-www-form-urlencoded'; } tracer.request('POST', url, options); let resp = this.requestAuth('POST', url, options); let result = { status : resp.statusCode, headers : resp.headers, body : this.extractBody(resp) }; tracer.response(result); return result; } put(params, path, data, mime) { let tracer = new HttpTracer(this.environ); tracer.params('PUT', params, path, data, mime); let url = this.url(params, path); let body = data || params.body; let type = mime || params.type; let options = {}; options.headers = params.headers || {}; if ( ! options.headers.accept ) { options.headers.accept = 'application/json'; } if ( body && type ) { options.headers['content-type'] = type; options.body = body; } else if ( body ) { options.json = body; } else { options.headers['content-type'] = 'application/x-www-form-urlencoded'; } // // TODO: Create a proper debug level selection mechanism, with the // ability to say, on the command line: "log the HTTP requests, log // the responses, log the URLs, the headers, the payloads, log the // actions with their data, log the file selections, log everything, // etc." // tracer.request('PUT', url, options); let resp = this.requestAuth('PUT', url, options); let result = { status : resp.statusCode, headers : resp.headers, body : this.extractBody(resp) }; tracer.response(result); return result; } boundary() { return uuid(); } multipart(boundary, parts) { let mp = new Multipart(boundary); parts.forEach(part => { if ( part.path ) { //mp.header('Content-Type', 'foo/bar'); mp.header('Content-Disposition', 'attachment; filename="' + part.uri + '"'); mp.body(this.read(part.path)); } else { if ( part.uri ) { mp.header('Content-Disposition', 'attachment; filename="' + part.uri + '"; category=metadata'); } else { mp.header('Content-Disposition', 'inline; category=metadata'); } mp.header('Content-Type', 'application/json'); mp.body(JSON.stringify(part.body)); } }); return mp.payload(); } restart(last) { const maxNum = 2500; const maxWait = 60000; // in ms = 1 min let ping; let num = 1; let start = new Date(); do { try { ping = this.requestAuth('GET', this.url({ api: 'admin' }, '/timestamp'), {}); } catch ( err ) { ping = err; } } while ( ++num < maxNum && ((new Date() - start) < maxWait) && (ping.statusCode === 503 || ping.code === 'ECONNRESET' || ping.code === 'ECONNREFUSED') ); if ( ping.statusCode !== 200 ) { throw new Error('Error waiting for server restart, not OK: ' + num + ' - ' + ping + ' (tried ' + num + ' times in ' + (new Date() - start) + ' ms)'); } var now = Date.parse(ping.body); if ( last >= now ) { throw new Error('Error waiting for server restart, wrong times: ' + last + ' - ' + now + ' (tried ' + num + ' times in ' + (new Date() - start) + ' ms)'); } } exists(path) { return fs.existsSync(path); } isDirectory(path) { if ( ! this.exists(path) ) { throw core.error.noSuchFile(path); } return fs.statSync(path).isDirectory(); } dirChildren(dir) { var res = []; fs.readdirSync(dir).forEach(child => { const p = this.resolve(child, dir); const s = fs.statSync(p); // TODO: Do something with `s.isSymbolicLink()`? if ( s.isBlockDevice() || s.isCharacterDevice() || s.isFIFO() || s.isSocket() ) { return; } var f = { name : child, path : path.join(dir, child) }; if ( s.isDirectory() ) { f.files = []; f.isdir = true; } res.push(f); }); return res; } static staticResolve(href, base) { return path.resolve(base || '.', href); } static userJson(name) { try { let path = Platform.staticResolve(name, os.homedir()); let text = fs.readFileSync(path, 'utf8'); let json = JSON.parse(text); return json.mlproj; } catch (err) { // ignore ENOENT, file does not exist if ( err.code !== 'ENOENT' ) { throw err; } } } } // Private class for Platform.get(), .post() and .put(). class HttpTracer { constructor(environ) { this.environ = environ; // the stamp to use for this request/response pair this.stamp = HttpTracer.now(); } // common implementation to trace parameters, a request or a response trace(type, obj, body) { const dir = HttpTracer.dir(this.environ); // if trace is enabled... if ( dir ) { // write a file synchronously const write = (path, content) => { let fd = fs.openSync(dir + path, 'wx'); fs.writeSync(fd, content); fs.fsyncSync(fd); }; // log the http entity, and maybe its body let base = this.stamp + '-' + type; write(base + '.json', JSON.stringify(obj)); if ( body instanceof buf.Buffer ) { write(base + '.bin', body); } } } params(verb, params, path, data, mime) { const obj = { verb: verb, params: params, path: path, data: data, mime: mime }; // do not include data if it is a (binary) buffer if ( data instanceof buf.Buffer ) { let msg = '<excluded because it is a binary buffer, of length ' + data.length + '>'; obj.data = { excluded: msg }; } // trace the parameters this.trace('params', obj, data); } request(verb, url, options) { const obj = { verb: verb, url: url, options: options }; // do not include options.body if it is a (binary) buffer if ( options && options.body instanceof buf.Buffer ) { // do not alter the `options` object in place, caller owns it, so make a copy first obj.options = {}; Object.keys(options).forEach(k => obj.options[k] = options[k]); let msg = '<excluded because it is a binary buffer, of length ' + options.body.length + '>'; obj.options.body = { excluded: msg }; } // trace the request this.trace('request', obj, options.body); } response(res) { let obj = res; // do not include res.body if it is a (binary) buffer if ( obj.body instanceof buf.Buffer ) { // do not alter the `res` object in place, caller owns it, so make a copy first obj = {}; Object.keys(res).forEach(k => obj[k] = res[k]); let msg = '<excluded because it is a binary buffer, of length ' + res.body.length + '>'; obj.body = { excluded: msg }; } // trace the response this.trace('response', obj, res.body); } } // cache dir in a static property, to use for all traces in the same mlproj run HttpTracer.dir = (environ) => { if ( ! HttpTracer.tracedir ) { if ( ! environ.ctxt.trace ) { // next time, just return undefined straight away HttpTracer.dir = (environ) => { return; }; return; } let dir = environ.ctxt.tracedir; if ( ! dir ) { throw new Error('HTTP trace enabled but no dir?!?'); } if ( ! dir.endsWith('/') && ! dir.endsWith('\\') ) { dir += '/'; } HttpTracer.tracedir = dir + HttpTracer.now() + '/'; fs.mkdirSync(HttpTracer.tracedir); } return HttpTracer.tracedir; }; HttpTracer.now = () => { return new Date().toISOString().replace(/:|\./g, '-'); }; // Private variable for Multipart. const NL = '\r\n'; // Private class for Platform.multipart(). class Multipart { constructor(boundary) { this.boundary = boundary; // parts is an array of { headers: string, body: string-or-buffer } this.parts = []; this.headers = []; } contentType() { return 'multipart/mixed; boundary=' + this.boundary; } header(name, value) { this.headers.push(name + ': ' + value); } body(content) { let preamble = '--' + this.boundary + NL + this.headers.reduce((res, h) => res + h + NL, '') + NL; this.parts.push({ headers: preamble, body: content }); this.headers = []; } payload() { let end ='--' + this.boundary + '--' + NL; let len = this.parts.reduce((res, p) => { let hlen = Buffer.byteLength(p.headers); let blen = Buffer.byteLength(p.body); return res + hlen + blen + 2; }, 0) + Buffer.byteLength(end); let buf = new Buffer(len); let pos = 0; this.parts.forEach(p => { pos += buf.write(p.headers, pos); pos += Buffer.isBuffer(p.body) ? p.body.copy(buf, pos) : buf.write(p.body, pos); pos += buf.write(NL, pos); }); buf.write(end, pos); return buf; } } /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The display implementation for Node. */ class Display extends core.Display { constructor(verbose) { super(verbose); this.chalk = chalk; } // TODO: FIXME: ... info(msg) { if ( this.verbose ) { console.log(chalk.yellow('Info') + ': ' + msg); } } database(name, id, schema, security, triggers, forests, props) { const log = Display.log; const line = Display.line; log(chalk.bold('Database') + ': ' + chalk.bold(chalk.yellow(name))); id && line(1, 'id', id); schema && line(1, 'schema DB', schema.name); security && line(1, 'security DB', security.name); triggers && line(1, 'triggers DB', triggers.name); if ( forests.length ) { line(1, 'forests:'); forests.forEach(f => line(2, f)); } Object.keys(props).forEach(p => this._property(props[p])); log(); } sysDatabase(name) { const log = Display.log; const line = Display.line; log(chalk.bold('Database') + ': ' + chalk.bold(chalk.yellow(name))); line(1, '(nothing to show, handled outside of the project)'); log(); } server(name, id, type, group, content, modules, props) { const log = Display.log; const line = Display.line; log(chalk.bold('Server') + ': ' + chalk.bold(chalk.yellow(name))); line(1, 'type', type); line(1, 'group', group); id && line(1, 'id', id); content && line(1, 'content DB', content.name); modules && line(1, 'modules DB', modules.name); // explicit list of properties, to guarantee the order they are displayed [ 'type', 'port', 'root', 'rewriter', 'handler' ].forEach(p => { if ( props[p] !== undefined ) { this._property(props[p]); } }); log(); } source(name, props) { const log = Display.log; const line = Display.line; log(chalk.bold('Source') + ': ' + chalk.bold(chalk.yellow(name))); Object.keys(props).forEach(p => this._property(props[p])); log(); } mimetype(name, props) { const log = Display.log; const line = Display.line; log(chalk.bold('MIME type') + ': ' + chalk.bold(chalk.yellow(name))); Object.keys(props).forEach(p => this._property(props[p])); log(); } privilege(props, kind) { const log = Display.log; const line = Display.line; log(chalk.bold('Privilege ' + kind) + ': ' + chalk.bold(chalk.yellow(props['privilege-name'].value))); Object.keys(props).forEach(p => this._property(props[p])); log(); } role(props) { const log = Display.log; const line = Display.line; log(chalk.bold('Role') + ': ' + chalk.bold(chalk.yellow(props['role-name'].value))); Object.keys(props).forEach(p => this._property(props[p])); log(); } user(props) { const log = Display.log; const line = Display.line; log(chalk.bold('User') + ': ' + chalk.bold(chalk.yellow(props['user-name'].value))); Object.keys(props).forEach(p => this._property(props[p])); log(); } _property(prop, level) { const line = Display.line; if ( ! level ) { level = 1; } if ( prop.prop.multiline ) { prop.value.forEach(v => { line(level, prop.prop.label); Object.keys(v).forEach(n => this._property(v[n], level + 1)); }); } else if ( Array.isArray(prop.value) ) { const isRole = prop.prop.name === 'role'; const isPerm = prop.prop.name === 'permission'; const isPriv = prop.prop.name === 'privilege'; const vals = prop.value.map(v => { return isPerm ? v['role-name'].value + '/' + v.capability.value : isPriv ? v['privilege-name'].value + '/' + v.kind.value : v; }); if ( vals.length || (!isRole && !isPerm && !isPriv) ) { line(level, prop.prop.label, vals.join(', ')); } } else { line(level, prop.prop.label, prop.value); } } project(abbrev, configs, title, name, version) { const log = Display.log; const line = Display.line; log(chalk.bold('Project') + ': ' + chalk.bold(chalk.yellow(abbrev))); title && line(1, 'title', title); name && line(1, 'name', name); version && line(1, 'version', version); // display the config parameters applicable configs.forEach(cfg => { if ( 'object' === typeof cfg.value ) { line(1, 'cfg.' + cfg.name); Object.keys(cfg.value).forEach(n => { line(2, n, cfg.value[n]); }); } else { line(1, 'cfg.' + cfg.name, cfg.value); } }); log(); } environ(envipath, title, desc, host, user, password, params, apis, commands, imports) { const log = Display.log; const line = Display.line; log(chalk.bold('Environment') + ': ' + chalk.bold(chalk.yellow(envipath))); title && line(1, 'title', title); desc && line(1, 'desc', desc); host && line(1, 'host', host); user && line(1, 'user', user); password && line(1, 'password', '*****'); if ( params.length ) { line(1, 'parameters:'); params.forEach(p => line(2, p.name, p.value)); } if ( Object.keys(apis).length ) { line(1, 'apis:'); Object.keys(apis).forEach(name => { line(2, name + ':'); let api = apis[name]; Object.keys(api).forEach(p => line(3, p, api[p])); }); } if ( commands.length ) { line(1, 'commands:'); commands.forEach(c => line(2, c)); } if ( imports.length ) { line(1, 'import graph:'); imports.forEach(i => { const p = path.relative(process.cwd(), i.href); line(i.level + 1, '-> ' + p); }); } log(); } check(indent, msg, arg) { Display.action(indent, '• ' + chalk.yellow('checking') + ' ' + msg, arg); } add(indent, verb, msg, arg) { Display.action(indent, ' need to ' + chalk.green(verb) + ' ' + msg, arg); } remove(indent, verb, msg, arg) { Display.action(indent, ' need to ' + chalk.red(verb) + ' ' + msg, arg); } error(err) { Display.error(err, this.verbose); } } Display.error = (err, verbose) => { switch ( err.name ) { case 'server-no-content': Display.log(chalk.red('Error') + ': The server ' + err.server + ' has no content DB.'); Display.log('Are you sure you want to load documents on it? Check your environ file.'); break; case 'server-no-modules': Display.log(chalk.red('Error') + ': The server ' + err.server + ' has no modules DB.'); Display.log('There is no need to deploy when server modules are on the filesystem.'); break; default: Display.log(chalk.red('Error') + ': ' + err.message); } if ( verbose ) { Display.log(); Display.log(chalk.bold('Stacktrace') + ':'); Display.log(err.stack); } }; Display.log = (msg, name) => { if ( msg === undefined ) { console.log(); } else { console.log(Display.pad(msg, name)); } }; Display.indent = level => { let s = ''; while ( level-- ) { s += ' '; } return s; }; Display.line = (indent, name, value) => { Display.log(Display.indent(indent) + name, value); }; Display.action = (indent, msg, arg) => { Display.log(Display.indent(indent) + msg, arg); }; Display.COLWIDTH = 36; Display.pad = (msg, name) => { if ( name ) { // adjust "colwidth" as escape sequences do not "consume" any column const singles = msg.match(/\u001b\[[0-9]m/g); const doubles = msg.match(/\u001b\[[0-9][0-9]m/g); const snum = (singles || []).length; const dnum = (doubles || []).length; const width = Display.COLWIDTH + (snum * 4) + (dnum * 5); return Display.padEnd(msg + ': ', width) + name; } else { return msg; } }; Display.padEnd = (str, width) => { if ( str.length >= width ) { return str; } else { return str + ' '.repeat(width - str.length); } }; module.exports = { Context : Context, Display : Display, Platform : Platform, WatchCommand : WatchCommand } } )();