UNPKG

salesforce-alm

Version:

This package contains tools, and APIs, for an improved salesforce.com developer experience.

637 lines (635 loc) 24.4 kB
"use strict"; /* * Copyright (c) 2020, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ /* -------------------------------------------------------------------------------------------------------------------- * WARNING: This file has been deprecated and should now be considered locked against further changes. Its contents * have been partially or wholely superceded by functionality included in the @salesforce/core npm package, and exists * now to service prior uses in this repository only until they can be ported to use the new @salesforce/core library. * * If you need or want help deciding where to add new functionality or how to migrate to the new library, please * contact the CLI team at alm-cli@salesforce.com. * ----------------------------------------------------------------------------------------------------------------- */ // node libs const fs = require("fs"); const os = require("os"); const path = require("path"); // 3rd party const BBPromise = require("bluebird"); const fs_readFile = BBPromise.promisify(fs.readFile); const mkdirp = require("mkdirp"); const bunyan = require("bunyan-sfdx-no-dtrace"); const h = require("heroku-cli-util"); const _ = require("lodash"); const ts_types_1 = require("@salesforce/ts-types"); const Messages = require("../messages"); const { env } = require('@salesforce/kit'); const almError = require('./almError'); const _constants = require('./constants'); const messages = Messages(); const stripAnsi = require("strip-ansi"); const chalkStyles = require("ansi-styles"); const chalk = require("chalk"); const heroku = h; const ROOT_LOGGER_NAME = 'sfdx'; const DEFAULT_LOG_FILE = 'sfdx.log'; const LOG_LEVEL_DEFAULT = bunyan.WARN; // Ok to log clientid const FILTERED_KEYS = [ 'sid', // Any json attribute that contains the words "access" and "token" will have the attribute/value hidden { name: 'access_token', regex: 'access[^\'"]*token' }, // Any json attribute that contains the words "refresh" and "token" will have the attribute/value hidden { name: 'refresh_token', regex: 'refresh[^\'"]*token' }, 'clientsecret', // Any json attribute that contains the words "sfdx", "auth", and "url" will have the attribute/value hidden { name: 'sfdxauthurl', regex: 'sfdx[^\'"]*auth[^\'"]*url' }, ]; const loggerRegistry = {}; // store so we reuse and properly close const serializers = bunyan.stdSerializers; serializers.config = (obj) => { const configCopy = {}; Object.keys(obj).forEach((key) => { const val = obj[key]; if (_.isString(val) || _.isNumber(val) || _.isBoolean(val)) { configCopy[key] = val; } }); return configCopy; }; // close streams // FIXME: sadly, this does not work when process.exit is called; for now, disabled process.exit const closeStreams = (fn) => { Object.keys(loggerRegistry).forEach((key) => { loggerRegistry[key].close(fn); }); }; const uncaughtExceptionHandler = (err) => { // log the exception const logger = _getLogger(ROOT_LOGGER_NAME, false); // eslint-disable-line no-use-before-define if (logger) { // FIXME: good chance this won't be logged because // process.exit was called before this is logged // https://github.com/trentm/node-bunyan/issues/95 logger.fatal(err); } }; // Never show tokens or connect app information in the logs const _filter = (...args) => args.map((arg) => { if (_.isArray(arg)) { return _filter(...arg); } if (arg) { let _arg = arg; // Normalize all objects into a string. This include errors. if (_.isObject(arg)) { _arg = JSON.stringify(arg); } const HIDDEN = 'HIDDEN'; FILTERED_KEYS.forEach((key) => { let expElement = key; let expName = key; // Filtered keys can be strings or objects containing regular expression components. if (ts_types_1.isPlainObject(key)) { expElement = key.regex; expName = key.name; } const hiddenAttrMessage = `<${expName} - ${HIDDEN}>`; // Match all json attribute values case insensitive: ex. {" Access*^&(*()^* Token " : " 45143075913458901348905 \n\t" ...} const regexTokens = new RegExp(`['"][^'"]*${expElement}[^'"]*['"]\\s*:\\s*['"][^'"]*['"]`, 'gi'); // Replaced value will be no longer be a valid JSON object which is ok for logs: {<access_token - HIDDEN> ...} _arg = _arg.replace(regexTokens, hiddenAttrMessage); // Match all key value attribute case insensitive: ex. {" key\t" : ' access_token ' , " value " : " dsafgasr431 " ....} const keyRegex = new RegExp(`['"]\\s*key\\s*['"]\\s*:\\s*['"]\\s*${expElement}\\s*['"]\\s*.\\s*['"]\\s*value\\s*['"]\\s*:\\s*['"]\\s*[^'"]*['"]`, 'gi'); // Replaced value will be no longer be a valid JSON object which is ok for logs: {<access_token - HIDDEN> ...} _arg = _arg.replace(keyRegex, hiddenAttrMessage); }); // This is a jsforce message we are masking. This can be removed after the following pull request is committed // and pushed to a jsforce release. // // Looking For: "Refreshed access token = ..." // Related Jsforce pull requests: // https://github.com/jsforce/jsforce/pull/598 // https://github.com/jsforce/jsforce/pull/608 // https://github.com/jsforce/jsforce/pull/609 const jsForceTokenRefreshRegEx = new RegExp('Refreshed(.*)access(.*)token(.*)=\\s*[^\'"\\s*]*'); _arg = _arg.replace(jsForceTokenRefreshRegEx, `<refresh_token - ${HIDDEN}>`); _arg = _arg.replace(/sid=(.*)/, `sid=<${HIDDEN}>`); return _arg; } else { return arg; } }); const _registerLogger = function (logger, name) { if (_.isNil(name)) { throw new Error('Logger name required'); } if (!loggerRegistry[name]) { loggerRegistry[name] = logger; } }; class Mode { constructor(mode) { mode = mode && mode.toLowerCase(); this.mode = Mode.types.includes(mode) ? mode : 'production'; Mode.types.forEach((modeType) => { this[`is${_.capitalize(modeType)}`] = () => modeType === this.mode; }); } static get types() { return ['production', 'development', 'demo']; } } /** * SFDX Logger logs all lines at or above a given level to a file. It also * handles logging to stdout, which delegates to heroku-cli-util. * * Implementation extends Bunyan. * * https://github.com/trentm/node-bunyan * * Things to note: * # Logging API params: * Note that this implies you cannot blindly pass any object as the first argument * to log it because that object might include fields that collide with Bunyan's * core record fields. In other words, log.info(mywidget) may not yield what you * expect. Instead of a string representation of mywidget that other logging * libraries may give you, Bunyan will try to JSON-ify your object. It is a Bunyan * best practice to always give a field name to included objects, e.g.: * * log.info({widget: mywidget}, ...) * * # Issues: * - Ensuring writable stream is flushed on exception * https://github.com/trentm/node-bunyan/issues/37 * */ class Logger extends bunyan { constructor(options, _childOptions, _childSimple) { super(options, _childOptions, _childSimple); this.name = options.name; this.colorEnabled = false; this.humanConsumable = true; this.filters = []; this.levels = bunyan.levelFromName; this._useRingBuffer = false; } init(level, logFile = path.join(path.join(os.homedir(), '.sfdx', DEFAULT_LOG_FILE))) { if (_.isNil(level)) { // Default the log level level = LOG_LEVEL_DEFAULT; } if (this.useRingBuffer) { try { this.ringbuffer = new bunyan.RingBuffer({ limit: 5000 }); this.addStream({ type: 'raw', stream: this.ringbuffer, level }); } catch (error) { const levels = Object.keys(this.levels).join(', '); error['message'] = `${error.message} - ${messages.getMessage('IncorrectLogLevel', levels)}`; throw error; } } // disable log file writing, if applicable else if (process.env.SFDX_DISABLE_LOG_FILE !== 'true') { // create log file, if not exists if (!fs.existsSync(logFile)) { mkdirp.sync(path.dirname(logFile), { mode: _constants.DEFAULT_USER_DIR_MODE, }); fs.writeFileSync(logFile, '', { mode: _constants.DEFAULT_USER_FILE_MODE, }); } // avoid multiple streams to same log file if (!this.streams.find((stream) => stream.type === 'file' && stream.path === logFile)) { // TODO: rotating-file // https://github.com/trentm/node-bunyan#stream-type-rotating-file try { this.path = logFile; this.addStream({ type: 'file', path: logFile, level }); } catch (error) { const levels = Object.keys(this.levels).join(', '); error['message'] = `${error.message} - ${messages.getMessage('IncorrectLogLevel', levels)}`; throw error; } } // This is to prevent the following warning // node:12145) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. // This should be an adequate solution for a force-com-toolbelt logger in the context of running a command. // This however shouldn't be used in say sfdx-core where the logger could be used in a persistent service. process.setMaxListeners(100); // to debug 'Possible EventEmitter memory leak detected', add the following to top of index.js or, for // log tests, top of logApi.js // https://git.soma.salesforce.com/ALMSourceDrivenDev/force-com-toolbelt/compare/cwall/logs-for-EventEmitter-memory-leak // ensure that uncaughtException is logged process.on('uncaughtException', uncaughtExceptionHandler); // FIXME: ensure that streams are flushed on ext // https://github.com/trentm/node-bunyan/issues/37 process.on('exit', closeStreams); } } // Compares the requested log level with the current log level. Returns true if // the requested log level is greater than or equal to the current log level. shouldLog(logLevel) { let shouldLog = false; if (_.isNumber(logLevel)) { shouldLog = logLevel >= this.level(); } else if (_.isString(logLevel)) { shouldLog = this.level[logLevel] >= this.level(); } return shouldLog; } /** * @returns {boolean} returns true or false depending on if in memory logging is enabled. */ get useRingBuffer() { return this._useRingBuffer; } /** * Turns on in memory logging * * @param {boolean} val */ set useRingBuffer(val) { this._useRingBuffer = _.isBoolean(val) ? val : false; } /** * Returns an array of log line objects. Each element is an object the corresponds to a log line. * * @returns {Array} */ getBufferedRecords() { return this.ringbuffer.records; } /** * Returns a text blob of all the log lines contained in memory. * * @returns {*} */ getLogContentsAsText() { if (this.useRingBuffer) { return BBPromise.resolve(this.getBufferedRecords().reduce((accum, value) => { accum += JSON.stringify(value) + this.getEOL(); return accum; }, '')); } else if (!_.isNil(this.path)) { return fs_readFile(this.path, 'utf8'); } else { return BBPromise.reject(new Error('Log type is neither a file stream or ring buffer')); } } /** * Adds a filter to the array * * @param filter - function defined in the command constructor * that manipulates log messages */ addFilter(filter) { this.filters.push(filter); } /** * When logging messages to the DEFAULT_LOG_FILE, this method * calls the filters defined in the executed commands. * * @param args - this can be an array of strings, objects, etc. */ applyFilters(logLevel, ...args) { if (this.shouldLog(logLevel)) { this.filters.forEach((filter) => { args = filter(...args); }); } return args && args.length === 1 ? args[0] : args; } /** * Set the state of the logger to be human consumable or not. Human * consumable enables colors and typical output to stdout. When disabled, * it prevents color and stdout and only allows outputting JSON. */ setHumanConsumable(isConsumable) { this.humanConsumable = isConsumable; this.colorEnabled = isConsumable; } /** * */ close(fn) { if (this.streams) { try { this.streams.forEach((stream) => { if (fn && _.isFunction(fn)) { fn(stream); } // close stream, flush buffer to disk if (stream.type === 'file') { stream.stream.end(); } }); } finally { // remove listeners to avoid 'Possible EventEmitter memory leak detected' process.removeListener('uncaughtException', uncaughtExceptionHandler); process.removeListener('exit', closeStreams); } } } /** * Create a child logger, typically to add a few log record fields. * * @see bunyan.child(options, simple). * * @param {string} name - required, name of child logger that is emitted w/ logline as log:<name> * @param {object} fields - additional fields include in logline * @param {boolean} humanConsumable - true if this logger supports human readable output. * @returns {logger} */ child(name, fields = {}, humanConsumable) { if (!name) { throw almError('LoggerNameRequired'); } fields.log = name; // only support including addt'l fields on logline (no config) const childLogger = super.child(fields, true); childLogger.colorEnabled = this.colorEnabled; childLogger.humanConsumable = _.isNil(humanConsumable) ? this.humanConsumable : humanConsumable; childLogger.filters = this.filters; childLogger.path = this.path; if (this.useRingBuffer) { childLogger._useRingBuffer = this.useRingBuffer; childLogger.ringbuffer = this.ringbuffer; } // store to close on exit _registerLogger(childLogger, name); this.trace(`Setup '${name}' logger instance`); return childLogger; } setConfig(name, value) { if (!this.fields.config) { this.fields.config = {}; } this.fields.config[name] = value; } isDebugEnabled() { return super.debug(); } getEnvironmentMode() { return new Mode(this.envMode || process.env.SFDX_ENV); } isError() { return this.level() === bunyan.ERROR; } /** * Go directly to stdout. Useful when wanting to write to the same line. */ logRaw(...args) { this.info(...args); if (this.humanConsumable) { heroku.console.writeLog(...args); // If we stop using heroku // process.stdout.write(...args); } return this; } /** * Log JSON to stdout and to the log file with log level info. */ logJson(obj) { heroku.log(JSON.stringify(obj)); // log to sfdx.log after the console as filtering will change values this.trace(obj); } /** * Log JSON to stderr and to the log file with log level error. */ logJsonError(obj) { const err = JSON.stringify(obj); console.error(err); // eslint-disable-line no-console return super.error(this.applyFilters(bunyan.ERROR, err)); } /** * Logs INFO level log AND logs to console.log in human-readable form. * * See "Logging API params" in top-level doc. * * @see bunyan.debug() */ log(...args) { if (this.humanConsumable) { heroku.log(...args); } // log to sfdx.log after the console as filtering will change values this.info(...args); return this; } trace(...args) { return super.trace(this.applyFilters(bunyan.TRACE, ...args)); } debug(...args) { return super.debug(this.applyFilters(bunyan.DEBUG, ...args)); } info(...args) { return super.info(this.applyFilters(bunyan.INFO, ...args)); } warn(...args) { return super.warn(this.applyFilters(bunyan.WARN, ...args)); } warnUser(context, message) { const warning = `${this.color.yellow('WARNING:')}`; this.warn(warning, message); if (this.shouldLog(bunyan.WARN)) { if (context && context.flags.json) { if (!context.warnings) { context.warnings = []; } context.warnings.push(message); // Also log the message if valid stderr with json going to stdout. if (env.getBoolean('SFDX_JSON_TO_STDOUT', true)) { console.error(warning, message); // eslint-disable-line no-console } } else { console.error(warning, message); // eslint-disable-line no-console } } } formatDeprecationWarning(name, def, type) { let msg = def.messageOverride || `The ${type} "${name}" has been deprecated and will be removed in v${`${def.version + 1}.0`} or later.`; if (def.to) { msg += ` Use "${def.to}" instead.`; } if (def.message) { msg += ` ${def.message}`; } return msg; } /** * Set the command name for this node process by seeing it statically accross * all logger instances. * * @param {string} cmdName The command name */ setCommandName(cmdName) { // Only one command is ran at a time. Set this statically so all child // loggers have access to it too. Logger.commandName = cmdName; } /** * Format errors for human consumption. Adds 'ERROR running <command name>', * as well as turns all errors the color red/ */ formatError(...args) { const colorizedArgs = []; const runningWith = _.isString(Logger.commandName) ? ` running ${Logger.commandName}` : ''; colorizedArgs.push(this.color.bold(`ERROR${runningWith}: `)); args.forEach((arg) => { colorizedArgs.push(`${this.color.red(arg)}`); }); return colorizedArgs; } // boolean first arg determines if we log to console: false to // only log to logfile, default is true error(...args) { const consoleLog = args.length && args.length > 1 && _.isBoolean(args[0]) ? args[0] : true; if (consoleLog && (this.humanConsumable || env.getBoolean('SFDX_JSON_TO_STDOUT', true))) { console.error(...this.formatError(args)); // eslint-disable-line no-console } return super.error(this.applyFilters(bunyan.ERROR, ...args)); } action(action) { if (this.humanConsumable) { const args = this.formatError(action.message); const colorizedAction = this.color.blue(this.color.bold('Try this:')); args.push(`\n\n${colorizedAction}\n${action.action}`); console.error(...args); // eslint-disable-line no-console } return super.error(this.applyFilters(action.message, action.action)); } fatal(...args) { // Always show fatal to stdout console.error(...args); // eslint-disable-line no-console return super.fatal(this.applyFilters(bunyan.FATAL, ...args)); } table(...args) { if (this.humanConsumable) { const columns = _.get(args, '[1].columns'); if (columns) { args[1].columns = _.map(columns, (col) => { if (_.isString(col)) { return { key: col, label: _.toUpper(col) }; } return { key: col.key, label: _.toUpper(col.label), format: col.format, }; }); } heroku.table(...args); } // before table as filtering will change values this.info(...args); return this; } styledHash(...args) { this.info(...args); if (this.humanConsumable) { heroku.styledHash(...args); } return this; } styledHeader(...args) { this.info(...args); if (this.humanConsumable) { heroku.styledHeader(...args); } return this; } get color() { const colorFns = {}; Object.keys(chalkStyles).forEach((style) => { colorFns[style] = (msg) => { if (this.colorEnabled) { const colorfn = chalk[style]; return colorfn(msg); } return msg; }; }); colorFns['stripColor'] = stripAnsi; return colorFns; } /** * Get/set the level of all streams on this logger. * * @see bunyan.nameFromLevel(value). */ nameFromLevel(value) { return bunyan.nameFromLevel[value === undefined ? this.level() : value]; } setLevel(level) { if (_.isNil(level)) { // Set log level to the default level = this.levels[LOG_LEVEL_DEFAULT]; } // level of all streams on this logger this.level(level); } // reset stream(s) and log file(s) to support testing with test workspaces reset() { this.close(); this.streams.forEach((stream) => { if (stream.path) { try { if (process.platform === 'win32') { // todo: remove this writeFileSync when we fix file deletion on windows fs.writeFileSync(stream.path, ''); } fs.unlinkSync(stream.path); } catch (err) { // ignore } } }); this.streams = []; this.init(); return this; } getEOL() { return os.EOL; } } const _getLogger = function (name, initGlobalLoggerIfNotFound = true) { if (_.isNil(name)) { throw new Error('Logger name required'); } if (Object.keys(loggerRegistry).length === 0 && initGlobalLoggerIfNotFound) { // if no loggers, create and init global logger const globalLogger = new Logger({ name: ROOT_LOGGER_NAME, level: 'error', serializers, // No streams for now, not until it is enabled streams: [], }); globalLogger.addFilter((...args) => _filter(...args)); _registerLogger(globalLogger, ROOT_LOGGER_NAME); globalLogger.trace(`Setup '${name}' logger instance`); } if (!loggerRegistry[name]) { throw new Error(`Logger ${name} not found`); } return loggerRegistry[name]; }; module.exports = _getLogger(ROOT_LOGGER_NAME); //# sourceMappingURL=logApi.js.map