@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
JavaScript
#!/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