@zowe/imperative
Version:
framework for building configurable CLIs
667 lines • 29 kB
JavaScript
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*
*/
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CliUtils = void 0;
const error_1 = require("../../error");
const constants_1 = require("../../constants");
const TextUtils_1 = require("./TextUtils");
const ProfileUtils_1 = require("../../profiles/src/utils/ProfileUtils");
const read_1 = require("read");
/**
* Cli Utils contains a set of static methods/helpers that are CLI related (forming options, censoring args, etc.)
* @export
* @class CliUtils
*/
class CliUtils {
/**
* Get the 'dash form' of an option as it would appear in a user's command,
* appending the proper number of dashes depending on the length of the option name
* @param {string} optionName - e.g. my-option
* @returns {string} - e.g. --my-option
*/
static getDashFormOfOption(optionName) {
if (optionName !== undefined && optionName !== null && optionName.length >= 1) {
const dashes = optionName.length > 1 ? constants_1.Constants.OPT_LONG_DASH : constants_1.Constants.OPT_SHORT_DASH;
return dashes + optionName;
}
else {
throw new error_1.ImperativeError({
msg: "A null or blank option was supplied. Please correct the option definition."
});
}
}
/**
* Copy and censor any sensitive CLI arguments before logging/printing
* @param {string[]} args - The args list to censor
* @returns {string[]}
* @deprecated Use Censor.censorCLIArgs
*/
static censorCLIArgs(args) {
const newArgs = JSON.parse(JSON.stringify(args));
// eslint-disable-next-line deprecation/deprecation
const censoredValues = CliUtils.CENSORED_OPTIONS.map(CliUtils.getDashFormOfOption);
for (const value of censoredValues) {
if (args.indexOf(value) >= 0) {
const valueIndex = args.indexOf(value);
if (valueIndex < args.length - 1) {
// eslint-disable-next-line deprecation/deprecation
newArgs[valueIndex + 1] = CliUtils.CENSOR_RESPONSE; // censor the argument after the option name
}
}
}
return newArgs;
}
/**
* Copy and censor a yargs argument object before logging
* @param {yargs.Arguments} args the args to censor
* @returns {yargs.Arguments} a censored copy of the arguments
* @deprecated Use Censor.censorYargsArguments
*/
static censorYargsArguments(args) {
const newArgs = JSON.parse(JSON.stringify(args));
for (const optionName of Object.keys(newArgs)) {
// eslint-disable-next-line deprecation/deprecation
if (CliUtils.CENSORED_OPTIONS.indexOf(optionName) >= 0) {
const valueToCensor = newArgs[optionName];
// eslint-disable-next-line deprecation/deprecation
newArgs[optionName] = CliUtils.CENSOR_RESPONSE;
for (const checkAliasKey of Object.keys(newArgs)) {
if (newArgs[checkAliasKey] === valueToCensor) {
// eslint-disable-next-line deprecation/deprecation
newArgs[checkAliasKey] = CliUtils.CENSOR_RESPONSE;
}
}
}
}
return newArgs;
}
/**
* Searches properties in team configuration and attempts to match the option names supplied with profile keys.
* @param {Config} config - Team config API
* @param {ICommandProfile} profileDef - Profile definition of invoked command
* @param {ICommandArguments} args - Arguments from command line and environment
* @param {(Array<ICommandOptionDefinition | ICommandPositionalDefinition>)} options - the full set of command options
* for the command being processed
*
* @returns {*}
*
* @memberof CliUtils
*/
static getOptValuesFromConfig(config, profileDef, args, options) {
// Build a list of all profile types - this will help us search the CLI
// options for profiles specified by the user
let allTypes = [];
if (profileDef != null) {
if (profileDef.required != null)
allTypes = allTypes.concat(profileDef.required);
if (profileDef.optional != null)
allTypes = allTypes.concat(profileDef.optional);
}
// Build an object that contains all the options loaded from config
let fromCnfg = {};
for (const profileType of allTypes) {
const opt = ProfileUtils_1.ProfileUtils.getProfileOptionAndAlias(profileType)[0];
// If the config contains the requested profiles, then "remember"
// that this type has been fulfilled - so that we do NOT load from
// the traditional profile location
const profileTypePrefix = profileType + "_";
let p = null;
if (args[opt] != null && config.api.profiles.exists(args[opt])) {
p = config.api.profiles.get(args[opt]);
}
else if (args[opt] != null && !args[opt].startsWith(profileTypePrefix) &&
config.api.profiles.exists(profileTypePrefix + args[opt])) {
p = config.api.profiles.get(profileTypePrefix + args[opt]);
}
else if (args[opt] == null &&
config.properties.defaults[profileType] != null &&
config.api.profiles.exists(config.properties.defaults[profileType])) {
p = config.api.profiles.defaultGet(profileType);
}
if (p == null && (profileDef === null || profileDef === void 0 ? void 0 : profileDef.required) != null && (profileDef === null || profileDef === void 0 ? void 0 : profileDef.required.indexOf(profileType)) >= 0) {
throw new error_1.ImperativeError({
msg: `Profile of type "${profileType}" does not exist within the loaded profiles for the command and it is marked as required.`,
additionalDetails: `This is an internal imperative error. ` +
`Command preparation was attempting to extract option values from this profile.`
});
}
fromCnfg = Object.assign(Object.assign({}, p !== null && p !== void 0 ? p : {}), fromCnfg);
}
// Convert each property extracted from the config to the correct yargs
// style cases for the command handler (kebab and camel)
options.forEach((opt) => {
let cases = CliUtils.getOptionFormat(opt.name);
if (fromCnfg[opt.name] == null && "aliases" in opt) {
// Use aliases for backwards compatibility
// Search for first alias available in the profile
const oldOption = opt.aliases.find(o => fromCnfg[o] != null);
// Get the camel and kebab case
if (oldOption)
cases = CliUtils.getOptionFormat(oldOption);
}
const profileKebab = fromCnfg[cases.kebabCase];
const profileCamel = fromCnfg[cases.camelCase];
if ((profileCamel !== undefined || profileKebab !== undefined) &&
(!Object.hasOwn(args, cases.kebabCase) && !Object.hasOwn(args, cases.camelCase))) {
// If both case properties are present in the profile, use the one that matches
// the option name explicitly
const shouldUseKebab = profileKebab !== undefined && profileCamel !== undefined ?
opt.name === cases.kebabCase : profileKebab !== undefined;
const value = shouldUseKebab ? profileKebab : profileCamel;
const keys = CliUtils.setOptionValue(opt.name, "aliases" in opt ? opt.aliases : [], value);
fromCnfg = Object.assign(Object.assign({}, fromCnfg), keys);
}
});
return fromCnfg;
}
/**
* Using Object.assign(), merges objects in the order they appear in call. Object.assign() copies and overwrites
* existing properties in the target object, meaning property precedence is least to most (left to right).
*
* See details on Object.assign() for nuance.
*
* @param {...any[]} args - variadic set of objects to be merged
*
* @returns {*} - the merged object
*
*/
static mergeArguments(...args) {
let merged = {};
args.forEach((obj) => {
merged = Object.assign(Object.assign({}, merged), obj);
});
return merged;
}
/**
* Accepts the full set of command options and extracts their values from environment variables that are set.
*
* @param {(Array<ICommandOptionDefinition | ICommandPositionalDefinition>)} options - the full set of options
* specified on the command definition. Includes both the option definitions and the positional definitions.
*
* @returns {ICommandArguments["args"]} - the argument style object with both camel and kebab case keys for each
* option specified in environment variables.
*
*/
static extractEnvForOptions(envPrefix, options) {
let args = {};
options.forEach((opt) => {
let envValue = CliUtils.getEnvValForOption(envPrefix, opt.name);
if (envValue != null) {
// Perform the proper conversion if necessary for the type
// ENV vars are extracted as strings
switch (opt.type) {
// convert strings to booleans if the option is boolean type
case "boolean":
if (envValue.toUpperCase() === "TRUE") {
envValue = true;
}
else if (envValue.toUpperCase() === "FALSE") {
envValue = false;
}
break;
// convert strings to numbers if the option is number type
case "number": {
const BASE_TEN = 10;
const oldEnvValue = envValue;
envValue = parseInt(envValue, BASE_TEN);
// if parsing fails, we'll re-insert the original value so that the
// syntax failure message is clearer
if (isNaN(envValue)) {
envValue = oldEnvValue;
}
break;
}
// convert to an array of strings if the type is array
case "array": {
envValue = this.extractArrayFromEnvValue(envValue);
break;
}
// Do nothing for other option types
default:
break;
}
const keys = CliUtils.setOptionValue(opt.name, "aliases" in opt ? opt.aliases : [], envValue);
args = Object.assign(Object.assign({}, args), keys);
}
});
return args;
}
/**
* Convert an array of strings provided as an environment variable
*
* @param envValue String form of the array
* @returns String[] based on environment variable
*/
static extractArrayFromEnvValue(envValue) {
const regex = /(["'])(?:(?=(\\?))\2.)*?\1/g;
let arr = [];
let match = regex.exec(envValue);
let removed = envValue;
while (match != null) {
removed = removed.replace(match[0], "");
const replace = match[0].replace("\\'", "'");
const trimmed = replace.replace(/(^')|('$)/g, "");
arr.push(trimmed);
match = regex.exec(envValue);
}
removed = removed.trim();
arr = arr.concat(removed.split(/[\s\n]+/g));
return arr;
}
/**
* Get the value of an environment variable associated with the specified option name.
* The environment variable name will be formed by concatenating an environment name prefix,
* and the cmdOption using underscore as the delimiter.
*
* The cmdOption name can be specified in camelCase or in kabab-style.
* Regardless of the style, it will be converted to upper case.
* We replace dashes in Kabab-style values with underscores. We replace each uppercase
* character in a camelCase value with underscore and that character.
*
* The envPrefix will be used exactly as specified.
*
* Example: The values myEnv-Prefix and someOptionName would retrieve
* the value of an environment variable named
* myEnv-Prefix_SOME_OPTION_NAME
*
* @param {string} envPrefix - The prefix for environment variables for this CLI.
* Our caller can use the value obtained by ImperativeConfig.instance.envVariablePrefix,
* which will use the envVariablePrefix from the Imperative config object,
* and will use the rootCommandName as a fallback value.
*
* @param {string} cmdOption - The name of the option in either camelCase or kabab-style.
*
* @returns {string | null} - The value of the environment variable which corresponds
* to the supplied option for the supplied command. If no such environment variable
* exists we return null.
*
* @memberof CliUtils
*/
static getEnvValForOption(envPrefix, cmdOption) {
// Form envPrefix and cmdOption into an environment variable
const envDelim = "_";
let envVarName = CliUtils.getOptionFormat(cmdOption).kebabCase;
envVarName = envPrefix + envDelim + "OPT" + envDelim +
envVarName.toUpperCase().replace(/-/g, envDelim);
// Get the value of the environment variable
if (Object.prototype.hasOwnProperty.call(process.env, envVarName)) {
return process.env[envVarName];
}
// no corresponding environment variable exists
return null;
}
/**
* Constructs the yargs style positional argument string.
* @static
* @param {boolean} positionalRequired - Indicates that this positional is required
* @param {string} positionalName - The name of the positional
* @returns {string} - The yargs style positional argument string (e.g. <name>);
* @memberof CliUtils
*/
static getPositionalSyntaxString(positionalRequired, positionalName) {
const leftChar = positionalRequired ? "<" : "[";
const rightChar = positionalRequired ? ">" : "]";
return leftChar + positionalName + rightChar;
}
/**
* Format the help header - normally used in help generation etc.
* @static
* @param {string} header
* @param {string} [indent=" "]
* @param {string} color
* @returns {string}
* @memberof CliUtils
*/
static formatHelpHeader(header, indent = " ", color) {
if (header === undefined || header === null || header.trim().length === 0) {
throw new error_1.ImperativeError({
msg: "Null or empty header provided; could not be formatted."
});
}
const numDashes = header.length + 1;
const headerText = TextUtils_1.TextUtils.formatMessage("{{indent}}{{headerText}}\n{{indent}}{{dashes}}", { headerText: header.toUpperCase(), dashes: Array(numDashes).join("-"), indent });
return TextUtils_1.TextUtils.chalk[color](headerText);
}
static generateDeprecatedMessage(cmdDefinition, showWarning) {
let message = "";
if (cmdDefinition.deprecatedReplacement != null) {
const noNewlineInText = cmdDefinition.deprecatedReplacement.replace(/\n/g, " ");
if (showWarning)
message += "\n\nWarning: This " + cmdDefinition.type + " has been deprecated.\n";
if (cmdDefinition.deprecatedReplacement === "") {
message += "Obsolete component. No replacement exists";
}
else {
message += "Recommended replacement: " + noNewlineInText;
}
}
return message;
}
/**
* Display a message when the command is deprecated.
* @static
* @param {string} handlerParms - the IHandlerParameters supplied to
* a command handler's process() function.
* @memberof CliUtils
*/
static showMsgWhenDeprecated(handlerParms) {
if (handlerParms.definition.deprecatedReplacement || handlerParms.definition.deprecatedReplacement === "") {
// form the command that is deprecated
const oldCmd = handlerParms.positionals.join(" ");
// display the message
handlerParms.response.console.error("\nWarning: The command '" + oldCmd + "' is deprecated.");
// Use consolidated deprecated message logic
const deprecatedMessage = CliUtils.generateDeprecatedMessage(handlerParms.definition);
handlerParms.response.console.error(deprecatedMessage);
}
}
/**
* Accepts an option name, and array of option aliases, and their value
* and returns the arguments style object.
*
* @param {string} optName - The command option name, usually in kebab case (or a single word)
*
* @param {string[]} optAliases - An array of alias names for this option
*
* @param {*} value - The value to assign to the argument
*
* @returns {ICommandArguments["args"]} - The argument style object
*
* @example <caption>Create Argument Object</caption>
*
* CliUtils.setOptionValue("my-option", ["mo", "o"], "value");
*
* // returns
* {
* "myOption": "value",
* "my-option": "value",
* "mo": "value",
* "o": "value"
* }
*
*/
static setOptionValue(optName, optAliases, value) {
let names = CliUtils.getOptionFormat(optName);
const args = {};
args[names.camelCase] = value;
args[names.kebabCase] = value;
for (const optAlias of optAliases) {
if (optAlias.length === 1) {
// for single character aliases, set the value using the alias verbatim
args[optAlias] = value;
}
else {
names = CliUtils.getOptionFormat(optAlias);
args[names.camelCase] = value;
args[names.kebabCase] = value;
}
}
return args;
}
/**
* Sleep for the specified number of miliseconds.
* @param timeInMs Number of miliseconds to sleep
*
* @example
* // create a synchronous delay as follows:
* await CliUtils.sleep(3000);
*/
static sleep(timeInMs) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((resolve) => {
setTimeout(resolve, timeInMs);
});
});
}
/**
* Prompt the user with a question and wait for an answer,
* but only up to the specified timeout.
*
* @param message The text with which we will prompt the user.
*
* @param opts.hideText Should we hide the text. True = display stars.
* False = display text. Default = false.
*
* @param opts.secToWait The number of seconds that we will wait for an answer.
* If not supplied, the default is 10 minutes.
* If 0 is specified, we will never timeout.
* Numbers larger than 3600 (1 hour) are not allowed.
*
* @param opts.maskChar The character that should be used to mask hidden text.
* If null is specified, then no characters will be echoed back.
*
* @return A string containing the user's answer, or null if we timeout.
*
* @example
* const answer = await CliUtils.readPrompt("Type your answer here: ");
* if (answer === null) {
* // abort the operation that you wanted to perform
* } else {
* // use answer in some operation
* }
*/
static readPrompt(message, opts) {
return __awaiter(this, void 0, void 0, function* () {
// Ensure that we use a reasonable timeout
let secToWait = (opts === null || opts === void 0 ? void 0 : opts.secToWait) || 600; // eslint-disable-line @typescript-eslint/no-magic-numbers
const maxSecToWait = 3600; // 1 hour max
if (secToWait > maxSecToWait || secToWait < 0) {
secToWait = maxSecToWait;
}
let response;
try {
response = yield (0, read_1.read)({
input: process.stdin,
output: process.stdout,
terminal: true,
prompt: message,
silent: opts === null || opts === void 0 ? void 0 : opts.hideText,
replace: opts === null || opts === void 0 ? void 0 : opts.maskChar,
timeout: secToWait * 1000 // eslint-disable-line @typescript-eslint/no-magic-numbers
});
if (opts === null || opts === void 0 ? void 0 : opts.hideText) {
process.stdout.write("\r\n");
}
}
catch (err) {
if (err.message === "canceled") {
process.exit(2);
}
else if (err.message === "timed out") {
return null;
}
else {
throw err;
}
}
return response;
});
}
/**
* Accepts the yargs argument object and constructs the base imperative
* argument object. The objects are identical to maintain compatibility with
* existing CLIs and plugins, but the intent is to eventually phase out
* having CLIs import anything from Yargs (types, etc).
*
* @param {Arguments} args - Yargs argument object
*
* @returns {ICommandArguments} - Imperative argument object
*
*/
static buildBaseArgs(args) {
const impArgs = Object.assign({}, args);
Object.keys(impArgs).forEach((key) => {
if (key !== "_" && key !== "$0" && impArgs[key] === undefined) {
delete impArgs[key];
}
});
return impArgs;
}
/**
* Takes a key and converts it to both camelCase and kebab-case.
*
* @param key The key to transform
*
* @returns An object that contains the new format.
*
* @example <caption>Conversion of keys</caption>
*
* CliUtils.getOptionFormat("helloWorld");
*
* // returns
* const return1 = {
* key: "helloWorld",
* camelCase: "helloWorld",
* kebabCase: "hello-world"
* }
*
* /////////////////////////////////////////////////////
*
* CliUtils.getOptionFormat("hello-world");
*
* // returns
* const return2 = {
* key: "hello-world",
* camelCase: "helloWorld",
* kebabCase: "hello-world"
* }
*
* /////////////////////////////////////////////////////
*
* CliUtils.getOptionFormat("hello--------world");
*
* // returns
* const return3 = {
* key: "hello--------world",
* camelCase: "helloWorld",
* kebabCase: "hello-world"
* }
*
* /////////////////////////////////////////////////////
*
* CliUtils.getOptionFormat("hello-World-");
*
* // returns
* const return4 = {
* key: "hello-World-",
* camelCase: "helloWorld",
* kebabCase: "hello-world"
* }
*/
static getOptionFormat(key) {
return {
camelCase: key.replace(/(-+\w?)/g, (match, p1) => {
/*
* Regular expression checks for 1 or more "-" characters followed by 0 or 1 word character
* The last character in each match is converted to upper case and returned only if it
* isn't equal to "-"
*
* Examples: (input -> output)
*
* - helloWorld -> helloWorld
* - hello-world -> helloWorld
* - hello--------world -> helloWorld
* - hello-World- -> helloWorld
*/
const returnChar = p1.slice(-1).toUpperCase();
return returnChar !== "-" ? returnChar : "";
}),
kebabCase: key.replace(/(-*[A-Z]|-{2,}|-$)/g, (match, p1, offset, inputString) => {
/*
* Regular expression matches the following:
*
* 1. Any string segment containing 0 or more "-" characters followed by any uppercase letter.
* 2. Any string segment containing 2 or more consecutive "-" characters
* 3. Any string segment where the last character is "-"
*
* Matches for 1.
*
* - "A" -> If condition 1.2
* - "-B" -> If condition 2.2
* - "------C" -> If condition 2.2
*
* Matches for 2.
*
* - "--" -> If condition 2.1.1
* - "-------" -> If condition 2.1.1 or 2.1.2
*
* 2.1.1 will be entered if the match is the last sequence of the string
* 2.1.2 will be entered if the match is not the last sequence of the string
*
* Matches for 3.
* - "-<end_of_string>" -> If condition 1.1
*
* Examples: (input -> output)
*
* - helloWorld -> hello-world
* - hello-world -> hello-world
* - hello--------world -> hello-world
* - hello-World- -> hello-world
*/
if (p1.length === 1) { // 1
if (p1 === "-") { // 1.1
// Strip trailing -
return "";
}
else { // 1.2
// Change "letter" to "-letter"
return "-" + p1.toLowerCase();
}
}
else { // 2
const returnChar = p1.slice(-1); // Get the last character of the sequence
if (returnChar === "-") { // 2.1
if (offset + p1.length === inputString.length) { // 2.1.1
// Strip a trailing -------- sequence
return "";
}
else { // 2.1.2
// Change a sequence of -------- to a -
return "-";
}
}
else { // 2.2
// Change a sequence of "-------letter" to "-letter"
return "-" + returnChar.toLowerCase();
}
}
}),
key
};
}
}
exports.CliUtils = CliUtils;
/**
* Used as the place holder when censoring arguments in messages/command output
* @static
* @memberof CliUtils
* @deprecated Use Censor.CENSOR_RESPONSE
*/
CliUtils.CENSOR_RESPONSE = "****";
/**
* A list of cli options/keywords that should normally be censored
* @static
* @memberof CliUtils
* @deprecated Use Censor.CENSORED_OPTIONS
*/
CliUtils.CENSORED_OPTIONS = ["auth", "p", "pass", "password", "passphrase", "credentials",
"authentication", "basic-auth", "basicAuth"];
//# sourceMappingURL=CliUtils.js.map
;