UNPKG

@report-toolkit/cli

Version:

See docs at [https://ibm.github.io/report-toolkit](https://ibm.github.io/report-toolkit)

707 lines (641 loc) 19.3 kB
#!/usr/bin/env node 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var common = require('@report-toolkit/common'); var core = require('@report-toolkit/core'); var fs = require('@report-toolkit/fs'); var yargs = _interopDefault(require('yargs/yargs.js')); var fs$1 = require('fs'); require('log-symbols'); var stripAnsi = _interopDefault(require('strip-ansi')); var termsize = _interopDefault(require('term-size')); const { bindNodeCallback, iif, map, mergeMap, of, pipeIf, tap } = common.observable; const { DEFAULT_TERMINAL_WIDTH } = common.constants; const writeFile = bindNodeCallback(fs$1.writeFile); /** * Writes CLI output to file or STDOUT * @todo might want to be moved to commands/common.js * @todo probably emits stuff it shouldn't * @param {string} [filepath] - If present, will write to file * @param {Object} [opts] * @param {boolean} [opts.color=true] * @returns {import('rxjs').OperatorFunction<any,string>} */ const toOutput = (filepath, { color = true } = {}) => observable => observable.pipe(map(String), pipeIf(color === false, map(stripAnsi)), mergeMap(output => iif(() => Boolean(filepath), writeFile(filepath, output), of(output).pipe(tap(res => { console.log(res); }))))); const terminalColumns = termsize().columns || DEFAULT_TERMINAL_WIDTH; const debug = common.createDebugPipe('cli', 'commands', 'common'); const { compatibleTransformers, builtinTransformerIds, toReportFromObject } = core.observable; const { fromAny, share } = common.observable; const toUniqueArray = common._.pipe(common._.castArray, common._.uniq); const GROUPS = { FILTER: 'Filter:', FILTER_TRANSFORM: '"filter" Transform Options:', JSON_TRANSFORM: '"json" Transform Options:', OUTPUT: 'Output:', TABLE_TRANSFORM: '"table" Transform Options:' }; const OPTIONS = { FILTER_TRANSFORM: { exclude: { coerce: toUniqueArray, /** @type {string[]} */ default: [], description: 'Exclude properties (keypaths allowed)', group: GROUPS.FILTER_TRANSFORM, nargs: 1, type: 'string' }, include: { coerce: toUniqueArray, /** @type {string[]} */ default: [], description: 'Include properties (keypaths allowed)', group: GROUPS.FILTER_TRANSFORM, nargs: 1, type: 'string' } }, JSON_TRANSFORM: { pretty: { description: 'Pretty-print JSON output', group: GROUPS.JSON_TRANSFORM, type: 'boolean' } }, OUTPUT: { output: { alias: 'o', description: 'Output to file instead of STDOUT', group: GROUPS.OUTPUT, normalize: true, requiresArg: true, type: 'string' }, 'show-secrets-unsafe': { description: 'Live dangerously & do not automatically redact secrets', group: GROUPS.OUTPUT, type: 'boolean' } }, TABLE_TRANSFORM: { 'max-width': { default: terminalColumns, defaultDescription: 'terminal width', description: 'Set maximum output width; ignored if --no-truncate used', group: GROUPS.TABLE_TRANSFORM, type: 'number' }, truncate: { default: true, description: 'Truncate & word-wrap output', group: GROUPS.TABLE_TRANSFORM, type: 'boolean' } }, TRANSFORM: { // @todo list transform aliases alias: 't', choices: builtinTransformerIds, coerce: toUniqueArray, default: common.constants.DEFAULT_TRANSFORMER, description: 'Transform(s) to apply', group: GROUPS.OUTPUT, nargs: 1, type: 'string' } }; /** * Yes, yes, I know. * @typedef {Object} GetTransformerOptionsOptions * @property {'report'|'object'} sourceType - Whether the transformer source is a report or an object * @property {string} defaultTransformer - Name of default transformer * @property {object} extra - Merge these into the result; use this to override specific values (e.g., default behavior of a transformer) * @property {string[]} omit - List of transformers to explicitly omit, if any */ /** * Get all transformer options for a command * @param {Partial<GetTransformerOptionsOptions>} opts - Options */ const getTransformerOptions = ({ sourceType = 'report', defaultTransformer = common.constants.DEFAULT_TRANSFORMER, omit = [], extra = {} } = {}) => { const transformerNames = common._.filter(transformerName => !omit.includes(transformerName), compatibleTransformers(sourceType)); return common._.defaultsDeep(common._.reduce((acc, transformName) => { const transformSpecificOptions = OPTIONS[ /** @type {keyof OPTIONS} */ `${transformName.toUpperCase()}_TRANSFORM`]; if (transformSpecificOptions) { acc = { ...acc, ...transformSpecificOptions }; } return acc; }, { transform: { ...OPTIONS.TRANSFORM, choices: transformerNames, default: defaultTransformer } }, transformerNames), extra); }; /** * * @param {string[]|string} filepaths * @param {Partial<import('@report-toolkit/core/src/observable').ToReportFromObjectOptions>} opts */ function fromFilepathsToReports(filepaths, opts = {}) { return fromAny(filepaths).pipe(debug(() => `attempting to load filepath(s): ${filepaths}`), fs.toObjectFromFilepath(), toReportFromObject(opts), debug(() => `loaded filepath(s): ${filepaths}`), share()); } /** * Compute a configuration for a particular command. Command-specific * config overrides whatever the top-level config is. * @param {string} commandName - Name of command * @param {object} [argv] - Command-line args * @param {object} [defaultConfig] - Default command configuration * @returns {object} Resulting config with command-specific stuff on top */ const mergeCommandConfig = (commandName, argv = {}, defaultConfig = {}) => { const config = common._.omit('commands', common._.mergeAll([// overwrite default config with more-specific default command config, if it exists. common._.defaultsDeep(defaultConfig, common._.getOr({}, `commands.${commandName}`, defaultConfig)), // overwrite transformer default with arg-supplied transformer config { transformers: common._.mapValues(transformerConfig => common._.defaultsDeep(transformerConfig, common._.omit(['$0', 'config', '_'], argv)), common._.getOr({}, 'transformers', defaultConfig)) }, // overwrite config with command-specific config common._.defaultsDeep(common._.getOr({}, 'config', argv), common._.getOr({}, `config.commands.${commandName}`, argv)), common._.omit(['$0', 'config', '_'], argv)])); // @ts-ignore if (common._.isEmpty(config.transformers)) { // @ts-ignore delete config.transformers; } common.createDebugger('cli', 'commands', 'common')(`computed config for command "${commandName}": %O`, config); return config; }; const { diff, transform, fromTransformerChain } = core.observable; const OP_COLORS = common._.toFrozenMap({ add: 'green', remove: 'red', replace: 'yellow' }); const OP_CODE = common._.toFrozenMap({ add: 'A', remove: 'D', replace: 'M' }); /** * Best-effort reformatting RFC6902-style paths to something more familiar. * This will break if the key contains a `/`, which is "unlikely" (possible in the environment flag names) * @param {string} path - RFC6902-style path, e.g., `/header/foo/3/bar` * @returns Lodash-style keypath; e.g., `header.foo[3].bar` */ const formatKeypath = path => path.replace('/', '').replace(/\/(\d+?)(\/)?/g, '[$1]$2').replace(/\//g, '.'); const command = 'diff <file1> <file2>'; const desc = 'Diff two reports'; // @ts-ignore const builder = yargs => yargs.options({ includeProp: { description: 'Include only properties (filter)', group: GROUPS.FILTER, nargs: 1, type: 'array', alias: 'i', coerce: common._.castArray }, excludeProp: { description: 'Exclude properties (reject)', group: GROUPS.FILTER, nargs: 1, type: 'array', alias: 'x', coerce: common._.castArray }, all: { description: 'Include everything in diff', group: GROUPS.FILTER, type: 'boolean', conflicts: ['i', 'x'] }, ...OPTIONS.OUTPUT, ...getTransformerOptions({ sourceType: 'object' }) }); // @ts-ignore const handler = argv => { const { file1, file2, includeProp: includeProperties, excludeProp: excludeProperties, all: includeAll } = argv; const config = mergeCommandConfig('diff', argv, { includeAll, includeProperties, excludeProperties, showSecretsUnsafe: false, sort: false, transformers: { table: { outputHeader: `Diff: ${file1} <=> ${file2}`, maxWidth: terminalColumns, colWidths: [5], fields: [{ color: ({ op }) => OP_COLORS.get(op), label: 'Op', value: ({ op }) => OP_CODE.get(op) }, { color: ({ op }) => OP_COLORS.get(op), label: 'Field', value: ({ field }) => formatKeypath(field) }, { label: file1, value: 'value' }, { label: file2, value: 'oldValue' }] } } }); const source = diff(fromFilepathsToReports(file1), fromFilepathsToReports(file2), config); fromTransformerChain(argv.transform, config).pipe(transform(source, { beginWith: 'object', defaultTransformerConfig: config.transformers.table }), toOutput(argv.output, { color: argv.color })).subscribe(); }; var diff$1 = /*#__PURE__*/Object.freeze({ __proto__: null, command: command, desc: desc, builder: builder, handler: handler }); const { ERROR, WARNING, INFO, DEFAULT_SEVERITY } = common.constants; const { inspect, transform: transform$1, fromTransformerChain: fromTransformerChain$1 } = core.observable; const { filter, take } = common.observable; const DEFAULT_INSPECT_CONFIG = { transformers: { table: { colWidths: [12, 20, 15], fields: [{ color: ({ severity }) => SEVERITY_COLOR_MAP.get(severity), label: 'Severity', value: ({ severity }) => common._.toUpper(severity) }, { label: 'File', value: 'filepath' }, { label: 'Rule', value: 'id' }, { label: 'Message', value: 'message' }], maxWidth: terminalColumns, outputHeader: 'Diagnostic Report Inspection' } } }; const SEVERITY_COLOR_MAP = common._.toFrozenMap({ error: 'red', warning: 'yellow', info: 'blue' }); const command$1 = 'inspect <file..>'; const desc$1 = 'Inspect Diagnostic Report file(s) for problems'; // @ts-ignore const builder$1 = yargs => yargs.positional('file', { coerce: common._.castArray, type: 'array' }).options({ severity: { choices: [ERROR, WARNING, INFO], default: DEFAULT_SEVERITY, description: 'Minimum threshold for message severity', group: GROUPS.FILTER }, ...OPTIONS.OUTPUT, ...getTransformerOptions({ sourceType: 'object' }) }); // @ts-ignore const handler$1 = argv => { const config = mergeCommandConfig('inspect', argv, DEFAULT_INSPECT_CONFIG); const { file, severity, output, transform: transformer, color } = config; const source = inspect(fromFilepathsToReports(file, config), { ruleConfig: config.rules, severity }); // if any of the messages have a severity of `error`, then // exit with code 1. source.pipe(filter(({ severity }) => severity === ERROR), take(1)).subscribe(() => { process.exitCode = 1; }); fromTransformerChain$1(transformer, config).pipe(transform$1(source, { beginWith: 'object', defaultTransformerConfig: config.transformers.table }), toOutput(output, { color })).subscribe(); }; var inspect$1 = /*#__PURE__*/Object.freeze({ __proto__: null, command: command$1, desc: desc$1, builder: builder$1, handler: handler$1 }); const { map: map$1, share: share$1 } = common.observable; const { fromRegisteredRuleDefinitions, transform: transform$2, fromTransformerChain: fromTransformerChain$2 } = core.observable; const DEFAULT_LIST_RULES_CONFIG = { fields: [{ label: 'Rule', value: 'id' }, { label: 'Description', value: common._.getOr('(no description)', 'description') }], transformers: { table: { outputHeader: 'Available Rules', colWidths: [20, 60], truncate: false } } }; const command$2 = 'list-rules'; const desc$2 = 'Lists built-in rules'; // @ts-ignore const builder$2 = yargs => yargs.options({ ...common._.omit(['show-secrets-unsafe'], OPTIONS.OUTPUT), ...getTransformerOptions({ sourceType: 'object' }) }); // @ts-ignore const handler$2 = argv => { const source = fromRegisteredRuleDefinitions().pipe(map$1(({ id, meta }) => ({ id, description: common._.getOr('(no description)', 'docs.description', meta) })), share$1()); const config = mergeCommandConfig('list-rules', argv, DEFAULT_LIST_RULES_CONFIG); fromTransformerChain$2(argv.transform, config).pipe(transform$2(source, { beginWith: 'object', defaultTransformerConfig: config.transformers.table }), toOutput(argv.output, { color: argv.color })).subscribe(); }; /** * @template T * @typedef {import('..').CLIArguments<T>} CLIArguments */ var listRules = /*#__PURE__*/Object.freeze({ __proto__: null, command: command$2, desc: desc$2, builder: builder$2, handler: handler$2 }); const { transform: transform$3, fromTransformerChain: fromTransformerChain$3 } = core.observable; const { of: of$1, iif: iif$1, concatMap } = common.observable; const debug$1 = common.createDebugger('cli', 'commands', 'redact'); const DEFAULT_TRANSFORMER = 'json'; const command$3 = 'redact <file..>'; const desc$3 = 'Redact secrets from report file(s) and output JSON'; // @ts-ignore const builder$3 = yargs => yargs.positional('file', { coerce: common._.castArray }).options({ replace: { description: 'Replace file(s) in-place', type: 'boolean', group: GROUPS.OUTPUT }, ...common._.omit(['output', 'show-secrets-unsafe'], OPTIONS.OUTPUT), ...common._.defaultsDeep(OPTIONS.JSON_TRANSFORM, { pretty: { default: true } }) }); // @ts-ignore const handler$3 = argv => { const config = mergeCommandConfig('transform', argv); debug$1('complete command config: %O', config); // XXX: this is a really wonky way to do it; see `files` usage below const files = [...argv.file]; /** * @type {import('rxjs').Observable<import('@report-toolkit/common').Report>} */ const source = fromFilepathsToReports(argv.file, { showSecretsUnsafe: false }); fromTransformerChain$3(argv.transform, config).pipe(transform$3(source, { defaultTransformer: DEFAULT_TRANSFORMER }), concatMap(result => iif$1(() => !argv.replace, of$1(result).pipe(toOutput(argv.output, { color: argv.color })), of$1(files.shift()).pipe(concatMap(file => of$1(result).pipe(toOutput(file, { color: false }))))))).subscribe(); }; var redact = /*#__PURE__*/Object.freeze({ __proto__: null, command: command$3, desc: desc$3, builder: builder$3, handler: handler$3 }); const { transform: transform$4, fromTransformerChain: fromTransformerChain$4 } = core.observable; const DEFAULT_TRANSFORMER$1 = 'json'; // in the case that `table` is chosen, use this output header. const DEFAULT_TRANSFORM_CONFIG = { transform: { table: { outputHeader: 'Transformation Result' } } }; const debug$2 = common.createDebugger('cli', 'commands', 'transform'); const command$4 = 'transform <file..>'; const desc$4 = 'Transform a report'; // @ts-ignore const builder$4 = yargs => yargs.positional('file', { coerce: common._.castArray, type: 'string', nargs: 1 }).options({ ...OPTIONS.OUTPUT, ...getTransformerOptions({ sourceType: 'report', defaultTransformer: DEFAULT_TRANSFORMER$1 }) }); // @ts-ignore const handler$4 = argv => { /** * @type {Observable<Report>} */ const source = fromFilepathsToReports(argv.file, common._.getOr(common._.get('config.transform.showSecretsUnsafe', argv), 'showSecretsUnsafe', argv)); const config = mergeCommandConfig('transform', argv, DEFAULT_TRANSFORM_CONFIG); debug$2('complete command config: %O', config); fromTransformerChain$4(argv.transform, config).pipe(transform$4(source, { defaultTransformer: DEFAULT_TRANSFORMER$1 }), toOutput(argv.output, { color: argv.color })).subscribe(); }; /** * @template T,U * @typedef {import('@report-toolkit/transformers').Transformer<T,U>} Transformer */ /** * @template T * @typedef {import('rxjs').Observable<T>} Observable */ /** * @typedef {import('@report-toolkit/common').Report} Report */ var transform$5 = /*#__PURE__*/Object.freeze({ __proto__: null, command: command$4, desc: desc$4, builder: builder$4, handler: handler$4 }); var commands = /*#__PURE__*/Object.freeze({ __proto__: null, diff: diff$1, inspect: inspect$1, listRules: listRules, redact: redact, transform: transform$5 }); const { DEFAULT_TRANSFORMER: DEFAULT_TRANSFORMER$2, NAMESPACE, SHORT_NAMESPACE } = common.constants; const debug$3 = common.createDebugger('cli', 'main'); /** * @todo support color JSON output if TTY */ const main = () => { common._.values(commands).reduce((parser, command) => parser.command(command), yargs().scriptName(SHORT_NAMESPACE).demandCommand(1, 1, 'A command is required! See list above.', 'Use only one command, please!').options({ color: { default: true, desc: 'Use color output if possible', type: 'boolean' }, debug: { alias: ['verbose'], desc: 'Enable debug output', global: true, type: 'boolean' }, rc: { desc: `Custom file or directory path to ${fs.constants.RC_FILENAME}`, normalize: true, requiresArg: true, type: 'string' } }).wrap(process.stdout.columns ? Math.min(process.stdout.columns, 100) : 80).env(NAMESPACE).help().version().middleware(async argv => { // "verbose" enables debug statements if (argv.verbose) { common.enableDebugger(); } debug$3('parsed CLI arguments: %O', argv); // any format other than the default "table" will not be in color argv.color = common._.isUndefined(argv.color) ? argv.format !== DEFAULT_TRANSFORMER$2 : argv.color; argv.config = await core.loadConfig(fs.fromFilesystemToConfig({ searchPath: argv.rc })); return argv; })).check(argv => { if (argv.output && common._.castArray(argv.file).length > 1) { throw new Error('--output cannot be combined with multiple files'); } return true; }).parse(process.argv.slice(2)); }; if (require.main === module) { main(); } /** * @template T * @typedef {import('yargs').Arguments<T>} CLIBaseArguments */ /** * @typedef {{debug?: boolean, rc?: string, config: object}} CLIGlobalArguments */ /** * @template T * @typedef {CLIBaseArguments<CLIGlobalArguments & T>} CLIArguments<T> */ exports.main = main; //# sourceMappingURL=report-toolkit-cli.cjs.js.map