mcdev
Version:
Accenture Salesforce Marketing Cloud DevTools
1,286 lines (1,216 loc) • 52 kB
JavaScript
'use strict';
import MetadataDefinitions from './../MetadataTypeDefinitions.js';
import process from 'node:process';
import toposort from 'toposort';
import winston from 'winston';
import child_process from 'node:child_process';
import path from 'node:path';
// import just to resolve cyclical - TO DO consider if could move to file or context
import { readJsonSync } from 'fs-extra/esm';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* @typedef {import('../../types/mcdev.d.js').AuthObject} AuthObject
* @typedef {import('../../types/mcdev.d.js').BuObject} BuObject
* @typedef {import('../../types/mcdev.d.js').Cache} Cache
* @typedef {import('../../types/mcdev.d.js').CodeExtract} CodeExtract
* @typedef {import('../../types/mcdev.d.js').CodeExtractItem} CodeExtractItem
* @typedef {import('../../types/mcdev.d.js').DeltaPkgItem} DeltaPkgItem
* @typedef {import('../../types/mcdev.d.js').McdevLogger} McdevLogger
* @typedef {import('../../types/mcdev.d.js').Logger} Logger
* @typedef {import('../../types/mcdev.d.js').Mcdevrc} Mcdevrc
* @typedef {import('../../types/mcdev.d.js').MetadataTypeItem} MetadataTypeItem
* @typedef {import('../../types/mcdev.d.js').MetadataTypeItemDiff} MetadataTypeItemDiff
* @typedef {import('../../types/mcdev.d.js').MetadataTypeItemObj} MetadataTypeItemObj
* @typedef {import('../../types/mcdev.d.js').MetadataTypeMap} MetadataTypeMap
* @typedef {import('../../types/mcdev.d.js').MetadataTypeMapObj} MetadataTypeMapObj
* @typedef {import('../../types/mcdev.d.js').MultiMetadataTypeList} MultiMetadataTypeList
* @typedef {import('../../types/mcdev.d.js').MultiMetadataTypeMap} MultiMetadataTypeMap
* @typedef {import('../../types/mcdev.d.js').SkipInteraction} SkipInteraction
* @typedef {import('../../types/mcdev.d.js').SoapRequestParams} SoapRequestParams
* @typedef {import('../../types/mcdev.d.js').TemplateMap} TemplateMap
* @typedef {import('../../types/mcdev.d.js').TypeKeyCombo} TypeKeyCombo
* @typedef {import('../../types/mcdev.d.js').SDKError} SDKError
*/
/**
* Util that contains logger and simple util methods
*/
export const Util = {
authFileName: '.mcdev-auth.json',
boilerplateDirectory: '../../boilerplate',
configFileName: '.mcdevrc.json',
defaultGitBranch: 'main',
parentBuName: '_ParentBU_',
standardizedSplitChar: '/',
/** @type {SkipInteraction} */
skipInteraction: null,
packageJsonMcdev: readJsonSync(path.join(__dirname, '../../package.json')),
OPTIONS: {},
changedKeysMap: {},
matchedByName: {},
/**
* helper that allows filtering an object by its keys
*
* @param {Object.<string,*>} originalObj object that you want to filter
* @param {string[]} [whitelistArr] positive filter. if not provided, returns originalObj without filter
* @returns {Object.<string,*>} filtered object that only contains keys you provided
*/
filterObjByKeys(originalObj, whitelistArr) {
if (!whitelistArr || !Array.isArray(whitelistArr)) {
return originalObj;
}
return Object.keys(originalObj)
.filter((key) => whitelistArr.includes(key))
.reduce((obj, key) => {
obj[key] = originalObj[key];
return obj;
}, {});
},
/**
* extended Array.includes method that allows check if an array-element starts with a certain string
*
* @param {string[]} arr your array of strigns
* @param {string} search the string you are looking for
* @returns {boolean} found / not found
*/
includesStartsWith(arr, search) {
return this.includesStartsWithIndex(arr, search) >= 0;
},
/**
* extended Array.includes method that allows check if an array-element starts with a certain string
*
* @param {string[]} arr your array of strigns
* @param {string} search the string you are looking for
* @returns {number} array index 0..n or -1 of not found
*/
includesStartsWithIndex(arr, search) {
return Array.isArray(arr) ? arr.findIndex((el) => el.startsWith(search)) : -1;
},
/**
* check if a market name exists in current mcdev config
*
* @param {string} market market localizations
* @param {Mcdevrc} properties local mcdev config
* @returns {boolean} found market or not
*/
checkMarket(market, properties) {
if (properties.markets[market]) {
return true;
} else {
Util.logger.error(`Could not find the market '${market}' in your configuration file.`);
const marketArr = Object.keys(properties.markets);
if (marketArr.length) {
Util.logger.info('Available markets are: ' + marketArr.join(', '));
}
return false;
}
},
/**
* check if a market name exists in current mcdev config
*
* @param {string[]} marketArr market localizations
* @param {Mcdevrc} properties local mcdev config
* @returns {boolean} found market or not
*/
checkMarketList(marketArr, properties) {
if (marketArr[0] !== '__clone__') {
// if __clone__ is passed, we don't want to actually change anything but simply clone the metadata as-is
for (const market of marketArr) {
if (!Util.checkMarket(market, properties)) {
return false;
}
}
}
return true;
},
/**
* ensure provided MarketList exists and it's content including markets and BUs checks out
*
* @param {string} mlName name of marketList
* @param {Mcdevrc} properties General configuration to be used in retrieve
*/
verifyMarketList(mlName, properties) {
if (properties.marketList[mlName]) {
// ML exists, check if it is properly set up
// check if BUs in marketList are valid
let buCounter = 0;
for (const businessUnit in properties.marketList[mlName]) {
if (businessUnit !== 'description') {
buCounter++;
Util.isValidBU(properties, businessUnit, true);
if (!Util.isValidBU(properties, businessUnit, true)) {
throw new Error(`'${businessUnit}' in Market ${mlName} is not defined.`);
}
// check if markets are valid
let marketArr = properties.marketList[mlName][businessUnit];
if ('string' === typeof marketArr) {
marketArr = [marketArr];
}
for (const market of marketArr) {
if (properties.markets[market]) {
// * markets can be empty or include variables. Nothing we can test here
} else {
throw new Error(`Market '${market}' is not defined.`);
}
}
}
}
if (!buCounter) {
throw new Error(`No BUs defined in marketList ${mlName}`);
}
} else {
// ML does not exist
throw new Error(`Market List ${mlName} is not defined`);
}
},
/**
*
* @param {string | TypeKeyCombo} selectedTypes supported metadata type
* @param {string[]} [keyArr] name/key of the metadata
* @param {string} [commandName] for log output only
* @returns {TypeKeyCombo | undefined} true if everything is valid; false otherwise
*/
checkAndPrepareTypeKeyCombo(selectedTypes, keyArr, commandName) {
if ('string' === typeof selectedTypes) {
// ensure we have TypeKeyCombo here
/** @type {TypeKeyCombo} */
selectedTypes = this.createTypeKeyCombo(selectedTypes, keyArr);
}
// check if types are valid
for (const type of Object.keys(selectedTypes)) {
if (!this._isValidType(type)) {
return;
}
if (!Array.isArray(selectedTypes[type]) || !selectedTypes[type].length) {
this.logger.error(
'You need to define keys, not just types to run ' + (commandName || '')
);
// we need an array of keys here
return;
}
// ensure keys are sorted to enhance log readability
selectedTypes[type].sort();
}
return selectedTypes;
},
/**
* used to ensure the program tells surrounding software that an unrecoverable error occured
*
* @returns {void}
*/
signalFatalError() {
// Util.logger.debug('Util.signalFataError() sets process.exitCode = 1 unless already set');
process.exitCode ||= 1;
},
/**
* SFMC accepts multiple true values for Boolean attributes for which we are checking here.
* The same problem occurs when evaluating boolean CLI flags
*
* @param {*} attrValue value
* @returns {boolean} attribute value == true ? true : false
*/
isTrue(attrValue) {
return ['true', 'TRUE', 'True', '1', 1, 'Y', 'y', true].includes(attrValue);
},
/**
* SFMC accepts multiple false values for Boolean attributes for which we are checking here.
* The same problem occurs when evaluating boolean CLI flags
*
* @param {*} attrValue value
* @returns {boolean} attribute value == false ? true : false
*/
isFalse(attrValue) {
return ['false', 'FALSE', 'False', '0', 0, 'N', 'n', false].includes(attrValue);
},
/**
* helper to test if two variables are equal
*
* @param {string | number | boolean | Array | object} item1 -
* @param {string | number | boolean | Array | object} item2 -
* @returns {boolean} equal or not
*/
isEqual: function (item1, item2) {
if (item1 === item2) {
return true;
}
if (typeof item1 !== typeof item2) {
return false;
}
if (Array.isArray(item1) && Array.isArray(item2)) {
return this._isEqualArray(item1, item2);
}
if (typeof item1 === 'object') {
return this._isEqualObject(item1, item2);
}
return false;
},
/**
* helper to test if two arrays are equal
*
* @param {Array} array1 -
* @param {Array} array2 -
* @returns {boolean} equal or not
*/
_isEqualArray: function (array1, array2) {
return (
array1.length === array2.length &&
array1.every((val) => array2.includes(val)) &&
array2.every((val) => array1.includes(val))
);
},
/**
* helper to test if two objects are equal
*
* @param {object} item1 -
* @param {object} item2 -
* @returns {boolean} equal or not
*/
_isEqualObject: function (item1, item2) {
if (!this._isEqualArray(Object.keys(item1), Object.keys(item2))) {
return false;
}
for (const key in item1) {
if (!this.isEqual(item1[key], item2[key])) {
return false;
}
}
return true;
},
/**
* helper for Mcdev.retrieve, Mcdev.retrieveAsTemplate and Mcdev.deploy
*
* @param {string} selectedType type or type-subtype
* @param {boolean} [handleOutside] if the API reponse is irregular this allows you to handle it outside of this generic method
* @returns {boolean} type ok or not
*/
_isValidType(selectedType, handleOutside) {
const { type, subType } = Util.getTypeAndSubType(selectedType);
if (type && !MetadataDefinitions[type]) {
if (!handleOutside) {
Util.logger.error(`:: '${type}' is not a valid metadata type`);
}
return false;
} else if (
type &&
subType &&
(!MetadataDefinitions[type] || !MetadataDefinitions[type].subTypes.includes(subType))
) {
if (!handleOutside) {
Util.logger.error(`:: '${selectedType}' is not a valid metadata type`);
}
return false;
}
return true;
},
/**
* helper for Mcdev.retrieve, Mcdev.retrieveAsTemplate and Mcdev.deploy
*
* @param {Mcdevrc} properties javascript object in .mcdevrc.json
* @param {string} businessUnit name of BU
* @param {boolean} [handleOutside] if the API reponse is irregular this allows you to handle it outside of this generic method
* @returns {boolean} bu found or not
*/
isValidBU(properties, businessUnit, handleOutside) {
const [cred, bu] = businessUnit ? businessUnit.split('/') : [null, null];
if (!properties.credentials[cred]) {
if (!handleOutside) {
Util.logger.error(`Credential not found`);
}
return false;
} else if (!properties.credentials[cred].businessUnits[bu]) {
if (!handleOutside) {
Util.logger.error(`BU not found in credential`);
}
return false;
}
return true;
},
/**
* helper that deals with extracting type and subtype
*
* @param {string} selectedType "type" or "type-subtype"
* @returns {{type:string, subType:string}} first elem is type, second elem is subType
*/
getTypeAndSubType(selectedType) {
if (selectedType) {
const temp = selectedType.split('-');
const type = temp.shift(); // remove first item which is the main typ
const subType = temp.join('-'); // subType can include "-"
return { type, subType };
} else {
return { type: null, subType: null };
}
},
/**
* helper for getDefaultProperties()
*
* @param {'typeRetrieveByDefault'|'typeCdpByDefault'} field relevant field in type definition
* @returns {string[]} type choices
*/
getTypeChoices(field) {
const typeChoices = [];
for (const el in MetadataDefinitions) {
if (
Array.isArray(MetadataDefinitions[el][field]) ||
MetadataDefinitions[el][field] === true
) {
// complex types like assets are saved as array but to ease upgradability we
// save the main type only unless the user deviates from our pre-selection.
// Types that dont have subtypes set this field to true or false
typeChoices.push(MetadataDefinitions[el].type);
}
}
typeChoices.sort((a, b) => {
if (a.toLowerCase() < b.toLowerCase()) {
return -1;
}
if (a.toLowerCase() > b.toLowerCase()) {
return 1;
}
return 0;
});
return typeChoices.filter(Boolean);
},
/**
* helper for cli.selectTypes and init.config.fixMcdevConfig that converts subtypes back to main type if all and only defaults were selected
* this keeps the config automatically upgradable when we add new subtypes or change what is selected by default
*
* @param {'typeRetrieveByDefault'|'typeCdpByDefault'} field relevant field in type definition
* @param {string[]} selectedTypes what types the user selected
* @param {'asset'} [type] metadata type
* @returns {string[]} filtered selectedTypes
*/
summarizeSubtypes(field, selectedTypes, type = 'asset') {
const selectedAssetSubtypes = selectedTypes.filter((str) => str.includes(type + '-'));
/** @type {string[]|boolean} */
const config = MetadataDefinitions[type][field];
if (Array.isArray(config) && selectedAssetSubtypes.length === config.length) {
const nonDefaultSelectedAssetSubtypes = selectedAssetSubtypes
.map((subtype) => subtype.replace(type + '-', ''))
.filter((subtype) => !config.includes(subtype));
if (!nonDefaultSelectedAssetSubtypes.length) {
// found all defaults and nothing else. replace with main type
selectedTypes = selectedTypes.filter((str) => !str.includes(type + '-'));
selectedTypes.push(type);
}
}
selectedTypes.sort();
return selectedTypes;
},
/** @type {string} last used timestamp to create log filename with */
logFileName: null,
/**
* wrapper around our standard winston logging to console and logfile
*
* @param {boolean} [noLogFile] optional flag to indicate if we should log to file; CLI logs are always on
* @returns {object} initiated logger for console and file
*/
_createNewLoggerTransport: function (noLogFile = false) {
// {
// error: 0,
// warn: 1,
// info: 2,
// http: 3,
// verbose: 4,
// debug: 5,
// silly: 6
// }
if (
process.env.FORK_PROCESS_ID || // run via Git-Fork
process.env.PATH.toLowerCase().includes('sourcetree') // run via Atlassian SourceTree
) {
Util.OPTIONS.noLogColors = true;
}
Util.logFileName = new Date().toISOString().split(':').join('.');
const transports = {
console: new winston.transports.Console({
// Write logs to Console
level: Util.OPTIONS.loggerLevel || 'info',
format: winston.format.combine(
Util.OPTIONS.noLogColors
? winston.format.uncolorize()
: winston.format.colorize(),
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.simple(),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`
)
),
}),
};
if (!noLogFile) {
transports.file = new winston.transports.File({
// Write logs to logfile
filename: 'logs/' + Util.logFileName + '.log',
level: 'debug', // log everything
format: winston.format.combine(
winston.format.uncolorize(),
winston.format.timestamp({ format: 'HH:mm:ss.SSS' }),
winston.format.simple(),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`
)
),
});
if (Util.OPTIONS.errorLog) {
// used by CI/CD solutions like Copado to quickly show the error message to admins/users
transports.fileError = new winston.transports.File({
// Write logs to additional error-logfile for better visibility of errors
filename: 'logs/' + Util.logFileName + '-errors.log',
level: 'error', // only log errors
lazy: true, // if true, log files will be created on demand, not at the initialization time.
format: winston.format.combine(
winston.format.uncolorize(),
winston.format.timestamp({ format: 'HH:mm:ss.SSS' }),
winston.format.simple(),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`
)
),
});
}
}
return transports;
},
loggerTransports: null,
/**
* Logger that creates timestamped log file in 'logs/' directory
*
* @type {Logger}
*/
logger: null,
/**
* initiate winston logger
*
* @param {boolean} [restart] if true, logger will be restarted; otherwise, an existing logger will be used
* @param {boolean} [noLogFile] if false, logger will log to file; otherwise, only to console
* @returns {void}
*/
startLogger: function (restart = false, noLogFile = false) {
if (
!(
Util.loggerTransports === null ||
restart ||
(!Util.loggerTransports?.file && !noLogFile && !Util.OPTIONS?.noLogFile)
)
) {
// logger already started
return;
}
Util.loggerTransports = this._createNewLoggerTransport(
noLogFile || Util.OPTIONS?.noLogFile
);
const myWinston = winston.createLogger({
level: Util.OPTIONS.loggerLevel,
levels: winston.config.npm.levels,
transports: Object.values(Util.loggerTransports),
});
const winstonError = myWinston.error;
/** @type {McdevLogger} */
const winstonExtension = {
/**
* helper that prints better stack trace for errors
*
* @param {SDKError} ex the error
* @param {string} [message] optional custom message to be printed as error together with the exception's message
* @returns {void}
*/
errorStack: function (ex, message) {
if (message) {
// ! this method only sets exitCode=1 if message-param was set
// if not, then this method purely outputs debug information and should not change the exitCode
winstonError(`${message}: ${ex.message}${ex.code ? ' (' + ex.code + ')' : ''}`);
if (ex.endpoint) {
// ex.endpoint is only available if 'ex' is of type RestError
winstonError(' endpoint: ' + ex.endpoint);
}
Util.signalFatalError();
} else {
myWinston.debug(`${ex.message}${ex.code ? ' (' + ex.code + ')' : ''}`);
if (ex.endpoint) {
// ex.endpoint is only available if 'ex' is of type RestError
myWinston.debug(' endpoint: ' + ex.endpoint);
}
}
let stack;
/* eslint-disable unicorn/prefer-ternary */
if (
[
'ETIMEDOUT',
'EHOSTUNREACH',
'ENOTFOUND',
'ECONNRESET',
'ECONNABORTED',
].includes(ex.code)
) {
// the stack would just return a one-liner that does not help
stack = new Error().stack; // eslint-disable-line unicorn/error-message
} else {
stack = ex.stack;
}
/* eslint-enable unicorn/prefer-ternary */
myWinston.debug(stack);
},
/**
* errors should cause surrounding applications to take notice
* hence we overwrite the default error function here
*
* @param {string} msg - the message to log
* @returns {void}
*/
error: function (msg) {
winstonError(msg);
Util.signalFatalError();
},
};
Util.logger = Object.assign(myWinston, winstonExtension);
const processArgv = process.argv.slice(2);
Util.logger.debug(
`:: mcdev ${Util.packageJsonMcdev.version} :: ⚡ mcdev ${processArgv.join(' ')}`
);
},
/**
* Logger helper for Metadata functions
*
* @param {string} level of log (error, info, warn)
* @param {string} type of metadata being referenced
* @param {string} method name which log was called from
* @param {*} payload generic object which details the error
* @param {string} [source] key/id of metadata which relates to error
* @returns {void}
*/
metadataLogger: function (level, type, method, payload, source) {
let prependSource = '';
if (source) {
prependSource = source + ' - ';
}
if (payload instanceof Error) {
// extract error message
Util.logger[level](`${type}.${method}: ${prependSource}${payload.message}`);
} else if (typeof payload === 'string') {
// print out simple string
Util.logger[level](`${type} ${method}: ${prependSource}${payload}`);
} else {
// Print out JSON String as default.
Util.logger[level](`${type}.${method}: ${prependSource}${JSON.stringify(payload)}`);
}
},
/**
* replaces values in a JSON object string, based on a series of
* key-value pairs (obj)
*
* @param {string | object} str JSON object or its stringified version, which has values to be replaced
* @param {TemplateMap} obj key value object which contains keys to be replaced and values to be replaced with
* @returns {string | object} replaced version of str
*/
replaceByObject: function (str, obj) {
let convertType = false;
if ('string' !== typeof str) {
convertType = true;
str = JSON.stringify(str);
}
// sort by value length
const sortable = [];
for (const key in obj) {
// only push in value if not null
if (obj[key]) {
sortable.push([key, obj[key]]);
}
}
sortable.sort((a, b) => b[1].length - a[1].length);
for (const pair of sortable) {
const escVal = pair[1].toString().replaceAll(/[-/\\^$*+?.()|[\]{}]/g, String.raw`\$&`);
const regString = new RegExp(escVal, 'g');
str = str.replace(regString, '{{{' + pair[0] + '}}}');
}
if (convertType) {
str = JSON.parse(str);
}
return str;
},
/**
* get key of an object based on the first matching value
*
* @param {object} objs object of objects to be searched
* @param {string | number} val value to be searched for
* @returns {string} key
*/
inverseGet: function (objs, val) {
for (const obj in objs) {
if (objs[obj] === val) {
return obj;
}
}
throw new Error(`${val} not found in object`);
},
/**
*helper for Mcdev.fixKeys. Retrieve dependent metadata
*
* @param {string} fixedType type of the metadata passed as a parameter to fixKeys function
* @returns {string[]} array of types that depend on the given type
*/
getDependentMetadata(fixedType) {
const dependencies = new Set();
for (const dependentType of Object.keys(MetadataDefinitions)) {
if (MetadataDefinitions[dependentType].dependencies.includes(fixedType)) {
// fixedType was found as a dependency of dependentType
dependencies.add(dependentType);
} else if (
MetadataDefinitions[dependentType].dependencies.some((dependency) =>
dependency.startsWith(fixedType + '-')
)
) {
// if MetadataTypeDefinitions[dependentType].dependencies start with type then also add dependentType to the set; use some to check if any of the dependencies start with type
dependencies.add(dependentType);
}
}
return [...dependencies];
},
/**
* Returns Order in which metadata needs to be retrieved/deployed
*
* @param {string[]} typeArr which should be retrieved/deployed
* @returns {Object.<string, string[]>} retrieve/deploy order as array
*/
getMetadataHierachy(typeArr) {
const dependencies = [];
// loop through all metadata types which are being retrieved/deployed
const subTypeDeps = {};
for (const typeSubType of typeArr) {
const type = typeSubType.split('-')[0];
// if they have dependencies then add a dependency pair for each type
if (MetadataDefinitions[type].dependencies.length > 0) {
dependencies.push(
...MetadataDefinitions[type].dependencies.map((dep) => {
if (dep.includes('-')) {
// log subtypes to be able to replace them if main type is also present
subTypeDeps[dep.split('-')[0]] ||= new Set();
subTypeDeps[dep.split('-')[0]].add(dep);
}
return [dep, typeSubType];
})
);
}
// if they have no dependencies then just add them with undefined.
else {
dependencies.push([undefined, typeSubType]);
}
}
const allDeps = dependencies.map((dep) => dep[0]);
// remove subtypes if main type is in the list
for (const type of Object.keys(subTypeDeps)
// only look at subtype deps that are also supposed to be retrieved or cached fully
.filter((type) => typeArr.includes(type) || allDeps.includes(type))) {
// convert set into array to walk its elements
for (const subType of subTypeDeps[type]) {
for (const item of dependencies) {
if (item[0] === subType) {
// if subtype recognized, replace with main type
item[0] = type;
}
}
}
}
// sort list & remove the undefined dependencies
const flatList = toposort(dependencies).filter((a) => !!a);
/** @type {Object.<string, null | Set.<string>>} */
const setList = {};
// group subtypes per type
for (const flatType of flatList) {
if (flatType.includes('-')) {
const { type, subType } = Util.getTypeAndSubType(flatType);
if (setList[type] === null) {
// if main type is already required, then don't filter by subtypes
continue;
} else if (setList[type] && subType) {
// add another subtype to the set
setList[type].add(subType);
continue;
} else {
// create a new set with the first subtype; subKey will be always set here
setList[type] = new Set([subType]);
}
if (subTypeDeps[type]) {
// check if there are depndent subtypes that need to be added
/** @type {string[]} */
const temp = [...subTypeDeps[type]].map((a) => {
const temp = a.split('-');
temp.shift(); // remove first item which is the main type
return temp.join('-'); // subType can include "-"
});
for (const item of temp) {
setList[type].add(item);
}
}
} else {
setList[flatType] = null;
}
}
// convert sets into arrays
/** @type {Object.<string, string[]>} */
const finalList = {};
for (const type of Object.keys(setList)) {
finalList[type] = setList[type] instanceof Set ? [...setList[type]] : null;
}
return finalList;
},
/**
* let's you dynamically walk down an object and get a value
*
* @param {string} path 'fieldA.fieldB.fieldC'
* @param {object} obj some parent object
* @returns {any} value of obj.path
*/
resolveObjPath(path, obj) {
return path.split('.').reduce((prev, curr) => (prev ? prev[curr] : null), obj);
},
/**
* helper to run other commands as if run manually by user
*
* @param {string} cmd to be executed command
* @param {string[]} [args] list of arguments
* @param {boolean} [hideOutput] if true, output of command will be hidden from CLI
* @returns {string|void} output of command if hideOutput is true
*/
execSync(cmd, args, hideOutput) {
args ||= [];
Util.logger.info('⚡ ' + cmd + ' ' + args.join(' '));
try {
if (hideOutput) {
// no output displayed to user but instead returned to parsed elsewhere
return child_process
.execSync(cmd + ' ' + args.join(' '))
.toString()
.trim();
} else {
// the following options ensure the user sees the same output and
// interaction options as if the command was manually run
child_process.execSync(cmd + ' ' + args.join(' '), { stdio: [0, 1, 2] });
return null;
}
} catch {
// avoid errors from execSync to bubble up
return null;
}
},
/**
* standardize check to ensure only one result is returned from template search
*
* @param {MetadataTypeItem[]} results array of metadata
* @param {string} keyToSearch the field which contains the searched value
* @param {string} searchValue the value which is being looked for
* @returns {MetadataTypeItem} metadata to be used in building template
*/
templateSearchResult(results, keyToSearch, searchValue) {
const matching = results.filter((item) => item[keyToSearch] === searchValue);
if (matching.length === 0) {
throw new Error(`No metadata found with name "${searchValue}"`);
} else if (matching.length > 1) {
throw new Error(
`Multiple metadata with name "${searchValue}" please rename to be unique to avoid issues`
);
} else {
return matching[0];
}
},
/**
* configures what is displayed in the console
*
* @param {object} argv list of command line parameters given by user
* @param {boolean} [argv.silent] only errors printed to CLI
* @param {boolean} [argv.verbose] chatty user CLI output
* @param {boolean} [argv.debug] enables developer output & features
* @returns {void}
*/
setLoggingLevel(argv) {
if (argv.silent) {
// only errors printed to CLI
Util.OPTIONS.loggerLevel = 'error';
Util.logger.debug('CLI logger set to: silent');
} else if (argv.verbose) {
// chatty user cli logs
Util.OPTIONS.loggerLevel = 'verbose';
Util.logger.debug('CLI logger set to: verbose');
} else {
// default user cli logs
Util.OPTIONS.loggerLevel = 'info';
Util.logger.debug('CLI logger set to: info / default');
}
if (argv.debug) {
// enables developer output & features. no change to actual logs
Util.OPTIONS.loggerLevel = 'debug';
Util.logger.debug('CLI logger set to: debug');
}
if (Util.loggerTransports?.console) {
Util.loggerTransports.console.level = Util.OPTIONS.loggerLevel;
}
if (Util.logger) {
Util.logger.level = Util.OPTIONS.loggerLevel;
}
},
/**
* outputs a warning that the given type is still in beta
*
* @param {string} type api name of the type thats in beta
*/
logBeta(type) {
Util.logger.warn(
` - 🚧 ${type} support is currently still in beta. Please report any issues here: https://github.com/Accenture/sfmc-devtools/issues/new/choose`
);
},
/**
* outputs a warning that the given method is deprecated
*
* @param {string} method name of the method
* @param {string} [useInstead] optionally specify which method to use instead
*/
logDeprecated(method, useInstead = '') {
Util.logger.warn(
` 💥 '${method}' is deprecated and will be sunset in a future version. ${useInstead ? `Please use ${useInstead} instead.` : ''}`
);
},
/**
* helper for MetadataType class to issue a similar error message for unsupported methods
*
* @param {any} definition type definition object
* @param {string} method name of the method thats not supported
* @param {MetadataTypeItem} [item] metadata item
*/
logNotSupported: function (definition, method, item) {
Util.logger.error(
` ☇ skipping ${item ? Util.getTypeKeyName(definition, item) : definition.type}: ${method} is not supported for ${definition.type}`
);
},
// defined colors for logging things in different colors
color: {
reset: '\x1B[0m',
dim: '\x1B[2m',
bright: '\x1B[1m',
underscore: '\x1B[4m',
blink: '\x1B[5m',
reverse: '\x1B[7m',
hidden: '\x1B[8m',
fgBlack: '\x1B[30m',
fgRed: '\x1B[31m',
fgGreen: '\x1B[32m',
fgYellow: '\x1B[33m',
fgBlue: '\x1B[34m',
fgMagenta: '\x1B[35m',
fgCyan: '\x1B[36m',
fgWhite: '\x1B[37m',
fgGray: '\x1B[90m',
bgBlack: '\x1B[40m',
bgRed: '\x1B[41m',
bgGreen: '\x1B[42m',
bgYellow: '\x1B[43m',
bgBlue: '\x1B[44m',
bgMagenta: '\x1B[45m',
bgCyan: '\x1B[46m',
bgWhite: '\x1B[47m',
bgGray: '\x1B[100m',
},
/**
* helper that wraps a message in the correct color codes to have them printed gray
*
* @param {string} msg log message that should be wrapped with color codes
* @returns {string} gray msg
*/
getGrayMsg(msg) {
return `${Util.color.dim}${msg}${Util.color.reset}`;
},
/**
* helper that returns the prefix of item specific log messages
*
* @param {any} definition metadata definition
* @param {MetadataTypeItem} metadataItem metadata item
* @returns {string} msg prefix
*/
getMsgPrefix(definition, metadataItem) {
return ` - ${Util.getTypeKeyName(definition, metadataItem)}`;
},
/**
* helper that returns the prefix of item specific log messages
*
* @param {any} definition metadata definition
* @param {MetadataTypeItem} metadataItem metadata item
* @returns {string} key or key/name combo
*/
getTypeKeyName(definition, metadataItem) {
return `${definition.type}: ${Util.getKeyName(definition, metadataItem)}`;
},
/**
* helper that returns the prefix of item specific log messages
*
* @param {any} definition metadata definition
* @param {MetadataTypeItem} metadataItem metadata item
* @returns {string} key or key/name combo
*/
getKeyName(definition, metadataItem) {
const key = metadataItem[definition.keyField] || metadataItem[definition.nameField];
const name = metadataItem[definition.nameField];
return !name || name == key ? key : key + ` / ` + name;
},
/**
* helper to print the subtypes we filtered by
*
* @param {string[]} subTypeArr list of subtypes to be printed
* @param {string} [indent] optional prefix of spaces to be added to the log message
* @returns {void}
*/
logSubtypes(subTypeArr, indent = '') {
if (subTypeArr && subTypeArr.length > 0) {
Util.logger.info(
Util.getGrayMsg(
`${indent} - Subtype${subTypeArr.length > 1 ? 's' : ''}: ${[...subTypeArr].sort().join(', ')}`
)
);
}
},
/**
* helper to print the subtypes we filtered by
*
* @param {string[] | string} keyArr list of subtypes to be printed
* @param {boolean} [isId] optional flag to indicate if key is an id
* @returns {string} string to be appended to log message
*/
getKeysString(keyArr, isId) {
if (!keyArr) {
return '';
}
if (!Array.isArray(keyArr)) {
// if only one key, make it an array
keyArr = [keyArr];
}
if (keyArr.length > 0) {
return Util.getGrayMsg(
` - ${isId ? 'ID' : 'Key'}${keyArr.length > 1 ? 's' : ''}: ${keyArr.join(', ')}`
);
}
return '';
},
/**
* pause execution of code; useful when multiple server calls are dependent on each other and might not be executed right away
*
* @param {number} ms time in miliseconds to wait
* @returns {Promise.<void>} - promise to wait for
*/
async sleep(ms) {
if (Util.OPTIONS._runningTest) {
Util.logger.debug('Skipping sleep in test mode');
return;
}
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
},
/**
* helper for Asset.extractCode and Script.prepExtractedCode to determine if a code block is a valid SSJS block
*
* @example the following is invalid:
* <script runat="server">
* // 1
* </script>
* <script runat="server">
* // 2
* </script>
*
* the following is valid:
* <script runat="server">
* // 3
* </script>
* @param {string} code code block to check
* @returns {string} the SSJS code if code block is a valid SSJS block, otherwise null
*/
getSsjs(code) {
if (!code) {
return null;
}
// \s* whitespace characters, zero or more times
// [^>]*? any character that is not a >, zero or more times, un-greedily
// (.*) capture any character, zero or more times
// /s dotall flag
// ideally the code looks like <script runat="server">...</script>
// regex that matches <script runat="server">...</script>, <script runat="server" language="javascript">...</script> or <script language="JavaScript" runat="server">...</script>, but it may not match <script language="ampscript" runat="server">...</script> and it may also not match <script runat="server" language="ampscript">...</script>
const scriptRegex =
/^<\s*script\s*(language=["']{1}javascript["']{1})?\s?[^>]*?runat\s*=\s*["']{1}server["']{1}\s*?(language=["']{1}javascript["']{1})?\s*>(.*)<\/\s*script\s*>$/is;
code = code.trim();
const regexMatches = scriptRegex.exec(code);
// regexMatches indexes:
// 1: first optional language block
// 2: second optional language block
// 3: the actual code
if (regexMatches?.length > 1 && regexMatches[3]) {
// script found
/* eslint-disable unicorn/prefer-ternary */
if (regexMatches[3].includes('<script')) {
// nested script found
return null;
} else {
// no nested script found: return the assumed SSJS-code
return regexMatches[3];
}
/* eslint-enable unicorn/prefer-ternary */
}
// no script found
return null;
},
/**
* allows us to filter just like with SQL's LIKE operator
*
* @param {string} testString field value to test
* @param {string} search search string in SQL LIKE format
* @returns {boolean} true if testString matches search
*/
stringLike(testString, search) {
if (typeof search !== 'string' || this === null) {
return false;
}
// Remove special chars
search = search.replaceAll(
new RegExp(String.raw`([\.\\\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:\-])`, 'g'),
String.raw`\$1`
);
// Replace % and _ with equivalent regex
search = search.replaceAll('%', '.*').replaceAll('_', '.');
// Check matches
return new RegExp('^' + search + '$', 'gi').test(testString);
},
/**
* returns true if no LIKE filter is defined or if all filters match
*
* @param {MetadataTypeItem} metadata a single metadata item
* @param {object} [filters] only used in recursive calls
* @returns {boolean} true if no LIKE filter is defined or if all filters match
*/
fieldsLike(metadata, filters) {
if (metadata.json && metadata.codeArr) {
// Compensate for CodeExtractItem format
metadata = metadata.json;
}
filters ||= Util.OPTIONS.like;
if (!filters) {
return true;
}
const fields = Object.keys(filters);
return fields.every((field) => {
// to allow passing in an array via cli, e.g. --like=field1,field2, we need to convert comma separated lists into arrays
const filter =
typeof filters[field] === 'string' && filters[field].includes(',')
? filters[field].split(',')
: filters[field];
if (Array.isArray(metadata[field])) {
return metadata[field].some((f) => Util.fieldsLike(f, filter));
} else {
if (typeof filter === 'string') {
return Util.stringLike(metadata[field], filter);
} else if (Array.isArray(filter)) {
return filter.some((f) => Util.stringLike(metadata[field], f));
} else if (typeof filter === 'object') {
return Util.fieldsLike(metadata[field], filter);
}
}
return false;
});
},
/**
* helper used by SOAP methods to ensure the type always uses an upper-cased first letter
*
* @param {string} str string to capitalize
* @returns {string} str with first letter capitalized
*/
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
},
/**
* helper for Retriever and Deployer class
*
* @param {string | string[]} typeArr -
* @param {string[]} keyArr -
* @param {boolean} [returnEmpty] returns array with null element if false/not set; Retriever needs this to be false; Deployer needs it to be true
* @returns {TypeKeyCombo} -
*/
createTypeKeyCombo(typeArr, keyArr, returnEmpty = false) {
if (!keyArr || (Array.isArray(keyArr) && !keyArr.length)) {
// no keys were provided, ensure we retrieve all
keyArr = returnEmpty ? null : [null];
}
/** @type {TypeKeyCombo} */
const typeKeyMap = {};
if ('string' === typeof typeArr) {
typeArr = [typeArr];
}
// no keys or array of keys was provided (likely called via CLI or to retrieve all)
// transform into TypeKeyCombo to iterate over it
for (const type of typeArr) {
typeKeyMap[type] = keyArr;
}
return typeKeyMap;
},
/**
* helper that converts TypeKeyCombo objects into a string with all relevant -m parameters
*
* @param {TypeKeyCombo} [selectedTypes] selected metadata types & key
* @returns {string} object converted into --metadata parameters
*/
convertTypeKeyToCli(selectedTypes) {
return selectedTypes
? Object.keys(selectedTypes)
.reduce((previousValue, type) => {
previousValue.push(
...selectedTypes[type].map((key) =>
key === null ? `-m ${type}` : `-m "${type}:${key}"`
)
);
return previousValue;
}, [])
.join(' ')
: '';
},
/**
* helper that converts TypeKeyCombo objects into a string with all relevant -m parameters
*
* @param {TypeKeyCombo} [selectedTypes] selected metadata types & key
* @returns {string} object converted into --metadata parameters
*/
convertTypeKeyToString(selectedTypes) {
return selectedTypes
? Object.keys(selectedTypes)
.reduce((previousValue, type) => {
previousValue.push(
selectedTypes[type]
.map((key, index) => {
let response = '';
if (index === 0) {
response += `${type}`;
}
if (key !== null && index === 0) {
response += ' (';
}
response += key === null ? `` : `"${key}"`;
if (key !== null && index === selectedTypes[type].length - 1) {
response += ')';
}
return response;
})
.join(', ')
);
return previousValue;
}, [])
.join(', ')
: '';
},
/**
* helper that checks how many keys are defined in TypeKeyCombo object
*
* @param {TypeKeyCombo} [selectedTypes] selected metadata types & key
* @returns {number} amount of keys across all types
*/
getTypeKeyCount(selectedTypes) {
return Object.keys(selectedTypes).reduce(
(previousValue, type) =>
previousValue + (selectedTypes[type] ? selectedTypes[type].length : 0),
0
);
},
/**
* async version of Ar