UNPKG

mcdev

Version:

Accenture Salesforce Marketing Cloud DevTools

661 lines (634 loc) 28.9 kB
'use strict'; import BuHelper from './businessUnit.js'; import File from './file.js'; import config from './config.js'; import { select, checkbox, input, number, confirm, Separator } from '@inquirer/prompts'; import MetadataDefinitions from './../MetadataTypeDefinitions.js'; import { Util } from './util.js'; import auth from './auth.js'; import 'console.table'; import MetadataTypeInfo from './../MetadataTypeInfo.js'; import TransactionalMessage from './../metadataTypes/TransactionalMessage.js'; /** * @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').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').SoapRequestParams} SoapRequestParams * @typedef {import('../../types/mcdev.d.js').TemplateMap} TemplateMap * @typedef {import('../../types/mcdev.d.js').TypeKeyCombo} TypeKeyCombo * @typedef {import('../../types/mcdev.d.js').ExplainType} ExplainType */ /** * CLI helper class */ const Cli = { /** * used when initially setting up a project. * loads default config and adds first credential * * @returns {Promise.<string | boolean>} success of init */ async initMcdevConfig() { Util.logger.info('-- Initialising server connection --'); Util.logger.info('Please enter a name for your "Installed Package" credentials:'); const propertiesTemplate = await config.getDefaultProperties(); delete propertiesTemplate.credentials.default; // wait for the interaction to finish or else an outer await will run before this is done return this._setCredential(propertiesTemplate, null); }, /** * Extends template file for properties.json * * @param {Mcdevrc} properties config file's json * @returns {Promise.<boolean | string>} status */ async addExtraCredential(properties) { const skipInteraction = Util.skipInteraction; if (await config.checkProperties(properties)) { this.logExistingCredentials(properties); Util.logger.info('\nPlease enter your new credentials'); if (skipInteraction && properties.credentials[skipInteraction.credentialName]) { Util.logger.error( `Credential '${skipInteraction.credentialName}' already existing. If you tried updating please provide run 'mcdev init ${skipInteraction.credentialName}'` ); return null; } return this._setCredential(properties, null); } else { // return null here to avoid seeing 2 error messages for the same issue return null; } }, /** * * @param {string[]} dependentTypes types that depent on type * @returns {Promise.<boolean>} true if user wants to continue with retrieve */ async postFixKeysReretrieve(dependentTypes) { if (Util.isTrue(Util.skipInteraction?.fixKeysReretrieve)) { return true; } else if (Util.isFalse(Util.skipInteraction?.fixKeysReretrieve)) { return false; } else { const fixKeysReretrieve = await confirm({ message: `Do you want to re-retrieve dependent types (${dependentTypes.join( ', ' )}) now?`, default: true, }); if (Util.OPTIONS._multiBuExecution) { const rememberFixKeysReretrieve = await confirm({ message: `Remember answer for other BUs?`, default: true, }); if (rememberFixKeysReretrieve) { Util.skipInteraction ||= {}; Util.skipInteraction.fixKeysReretrieve = fixKeysReretrieve; } } return fixKeysReretrieve; } }, /** * helper that logs to cli which credentials are already existing in our config file * * @param {Mcdevrc} properties config file's json * @returns {void} */ logExistingCredentials(properties) { Util.logger.info('Found the following credentials in your config file:'); for (const cred in properties.credentials) { if (Object.prototype.hasOwnProperty.call(properties.credentials, cred)) { Util.logger.info(` - ${cred}`); } } }, /** * Extends template file for properties.json * update credentials * * @param {Mcdevrc} properties config file's json * @param {string} credName name of credential that needs updating * @param {boolean} [refreshBUs] if this was triggered by mcdev join, do not refresh BUs * @returns {Promise.<string | boolean>} success of update */ async updateCredential(properties, credName, refreshBUs = true) { const skipInteraction = Util.skipInteraction; if (credName) { if (!skipInteraction) { Util.logger.info(`Please enter the details for '${credName}'`); } return await this._setCredential(properties, credName, refreshBUs); } }, /** * Returns Object with parameters required for accessing API * * @param {Mcdevrc} properties object of all configuration including credentials * @param {string} target code of BU to use * @param {boolean | string} [isCredentialOnly] true:don't ask for BU | string: name of BU * @param {boolean} [allowAll] Offer ALL as option in BU selection * @returns {Promise.<BuObject>} credential to be used for Business Unit */ async getCredentialObject(properties, target, isCredentialOnly, allowAll) { try { if (!(await config.checkProperties(properties))) { // return null here to avoid seeing 2 error messages for the same issue return null; } let [credential, businessUnit] = target ? target.split('/') : [null, null]; if ( credential && properties.credentials[credential] && !businessUnit && 'string' === typeof isCredentialOnly ) { // correct credential provided and BU pre-selected businessUnit = isCredentialOnly; } else if (!credential || !properties.credentials[credential]) { // no or unknown credential provided; BU either to be selected or pre-selected if (credential !== null) { const msg = `Credential '${credential}' not found`; if (Util.skipInteraction) { throw new Error(msg); } Util.logger.warn(msg); } const response = await this._selectBU( properties, null, !!isCredentialOnly, allowAll ); credential = response.credential; businessUnit = response.businessUnit; if (!isCredentialOnly) { Util.logger.info( `You could directly pass in this info with '${credential}/${businessUnit}'` ); } else if (credential && !businessUnit && 'string' === typeof isCredentialOnly) { // BU pre-selected businessUnit = isCredentialOnly; } } else if ( !isCredentialOnly && (!businessUnit || !properties.credentials[credential].businessUnits[businessUnit]) ) { // correct credential provided but BU still needed if (businessUnit && businessUnit !== 'undefined') { const msg = `Business Unit '${businessUnit}' not found for credential '${credential}'`; if (Util.skipInteraction) { throw new Error(msg); } Util.logger.warn(msg); } const response = await this._selectBU(properties, credential, null, allowAll); businessUnit = response.businessUnit; Util.logger.info( `You could directly pass in this info with '${credential}/${businessUnit}'` ); } return { eid: properties.credentials[credential].eid, mid: properties.credentials[credential].businessUnits[businessUnit], businessUnit: businessUnit, credential: credential, }; } catch (ex) { Util.logger.error(ex.message); return null; } }, /** * helps select the right credential in case of bad initial input * * @param {Mcdevrc} properties config file's json * @param {string} [credential] name of valid credential * @param {boolean} [isCredentialOnly] don't ask for BU if true * @param {boolean} [allowAll] Offer ALL as option in BU selection * @returns {Promise.<{businessUnit:string, credential:string}>} selected credential/BU combo */ async _selectBU(properties, credential, isCredentialOnly, allowAll) { const credList = []; const buList = []; const allBUsAnswer = { value: '*', name: '* (All BUs)' }; const answer = {}; // no proper credential nor BU was given. ask for credential first if (!credential) { for (const cred in properties.credentials) { if (Object.keys(properties.credentials[cred].businessUnits).length) { // only add credentials that have BUs const credential = { value: cred, name: cred }; if ( !isCredentialOnly && (!properties.credentials[cred]?.businessUnits || !Object.keys(properties.credentials[cred].businessUnits).length) ) { credential.disabled = 'No Business Units defined'; } credList.push(credential); } } answer.credential = await select({ message: 'Please select the credential you were looking for:', choices: credList, }); if (!isCredentialOnly) { for (const bu in properties.credentials[answer.credential].businessUnits) { buList.push({ value: bu, name: bu }); } if (!buList.length) { // unlikely error as we are filtering for this already while creating credList throw new Error('No Business Unit defined for this credential'); } else if (allowAll) { // add ALL option to beginning of list buList.unshift(allBUsAnswer); } } } else if (credential) { for (const bu in properties.credentials[credential].businessUnits) { buList.push({ value: bu, name: bu }); } if (!buList.length) { // that could only happen if config is faulty throw new Error('No Business Unit defined for this credential'); } else if (allowAll) { // add ALL option to beginning of list buList.unshift(allBUsAnswer); } } if ((credential && buList.length) || (!credential && !isCredentialOnly)) { answer.businessUnit = await select({ message: 'Please select the right BU:', choices: buList, }); } if (!answer || !Object.keys(answer).length) { throw new Error('credentials / BUs not configured'); } return answer; }, /** * helper around _askCredentials * * @param {Mcdevrc} properties from config file * @param {string} [credName] name of credential that needs updating * @param {boolean} [refreshBUs] if this was triggered by mcdev join, do not refresh BUs * @returns {Promise.<boolean | string>} success of refresh or credential name */ async _setCredential(properties, credName, refreshBUs = true) { const skipInteraction = Util.skipInteraction; // Get user input let credentialsGood = null; let inputData; do { if (skipInteraction) { if ( skipInteraction.client_id && skipInteraction.client_secret && skipInteraction.auth_url && skipInteraction.account_id && skipInteraction.credentialName ) { // assume skipInteraction=={client_id,client_secret,auth_url,credentialName} inputData = skipInteraction; } else { throw new Error( '--skipInteraction flag found but not defined for all required inputs: client_id,client_secret,auth_url,account_id,credentialName' ); } } else { inputData = await this._askCredentials(properties, credName); } // test if credentials are valid try { await auth.saveCredential( { client_id: inputData.client_id, client_secret: inputData.client_secret, auth_url: inputData.auth_url, account_id: Number.parseInt(inputData.account_id), }, inputData.credentialName ); credentialsGood = true; // update central config now that the credentials are verified properties.credentials[inputData.credentialName] = { eid: Number.parseInt(inputData.account_id), businessUnits: {}, }; } catch (ex) { Util.logger.error( `We could not verify your credential due to a problem (${ex.message}). Please try again.` ); credentialsGood = false; if (skipInteraction) { // break the otherwise infinite loop return; } } } while (!credentialsGood); if (refreshBUs) { // Get all business units and add them to the properties const status = await BuHelper.refreshBUProperties(properties, inputData.credentialName); return status ? inputData.credentialName : status; } else { return credentialsGood; } }, /** * helper for {@link Cli.addExtraCredential} * * @param {Mcdevrc} properties from config file * @param {string} [credName] name of credential that needs updating * @returns {Promise.<object>} credential info */ async _askCredentials(properties, credName) { const responses = {}; if (!credName) { responses.credentialName = await input({ message: 'Credential name (your choice)', // eslint-disable-next-line jsdoc/require-jsdoc validate: function (value) { if (!value || value.trim().length < 2) { return 'Please enter at least 2 characters'; } if (properties && properties.credentials[value]) { return `There already is an account with the name '${value}' in your config.`; } const converted = encodeURIComponent(value).replaceAll(/[*]/g, '_STAR_'); if (value != converted) { return 'Please do not use any special chars'; } return true; }, }); } const tenantRegex = /^https:\/\/([\w-]{28})\.(auth|soap|rest)\.marketingcloudapis\.com[/]?$/iu; responses.client_id = await input({ message: 'Client Id', // eslint-disable-next-line jsdoc/require-jsdoc validate: function (value) { if (!value || value.trim().length < 10) { return 'Please enter valid client id'; } return true; }, }); responses.client_secret = await input({ message: 'Client Secret', // eslint-disable-next-line jsdoc/require-jsdoc validate: function (value) { if (!value || value.trim().length < 10) { return 'Please enter valid client secret'; } return true; }, }); responses.auth_url = await input({ message: 'Authentication Base URI', validate: (value) => { if (!value || value.trim().length < 10) { return 'Please enter a valid tenant identifier'; } else if (tenantRegex.test(value.trim())) { // all good return true; } else { return `Please copy the URI directly from the installed package's "API Integration" section. It looks like this: https://a1b2b3xy56z.auth.marketingcloudapis.com/`; } }, }); responses.account_id = await number({ message: 'MID of Parent Business Unit', }); // remove extra white space responses.client_id = responses.client_id.trim(); responses.client_secret = responses.client_secret.trim(); responses.auth_url = responses.auth_url.trim(); if (credName) { // if credential name was provided as parameter, we didn't ask the user for it responses.credentialName = credName; } return responses; }, /** * allows updating the metadata types that shall be retrieved * * @param {Mcdevrc} properties config file's json * @param {string[]} [setTypesArr] skip user prompt and overwrite with this list if given * @returns {Promise.<void>} - */ async selectTypes(properties, setTypesArr) { let selectedTypes; if (setTypesArr) { selectedTypes = setTypesArr; } else { if (Util.logger.level === 'debug') { Util.logger.warn( 'Debug mode enabled. Allowing selection of "disabled" types. Please be aware that these might be unstable.' ); } else { Util.logger.info( 'Run mcdev selectTypes --debug if you need to use "disabled" types.' ); } const flattenedDefinitions = []; for (const el in MetadataDefinitions) { if (MetadataDefinitions[el].type === '') { // dont offer wrapper types like TransactionalMessage which don't have a value in "type" continue; } // if subtypes on metadata (eg. Assets) then add each nested subtype if ( MetadataDefinitions[el].subTypes && Array.isArray(MetadataDefinitions[el].typeRetrieveByDefault) ) { for (const subtype of MetadataDefinitions[el].subTypes) { flattenedDefinitions.push({ typeName: MetadataDefinitions[el].typeName.replace('-[Subtype]', ': ') + subtype, type: MetadataDefinitions[el].type + '-' + subtype, mainType: MetadataDefinitions[el].type, typeRetrieveByDefault: MetadataDefinitions[el].typeRetrieveByDefault.includes(subtype), }); } } // else just return normal type else { flattenedDefinitions.push({ typeName: MetadataDefinitions[el].typeName, type: MetadataDefinitions[el].type, typeRetrieveByDefault: MetadataDefinitions[el].typeRetrieveByDefault, }); } } // walk through all definitions (sub and main) and select them if already selected const typeChoices = flattenedDefinitions.map((def) => ({ name: def.typeName + (Util.logger.level === 'debug' && !def.typeRetrieveByDefault ? ' \x1B[1;30;40m(non-default)\u001B[0m' : ''), value: def.type, disabled: Util.logger.level === 'debug' || def.typeRetrieveByDefault ? false : 'disabled', // subtypes can be activated through their main type checked: properties.metaDataTypes.retrieve.includes(def.type) || (properties.metaDataTypes.retrieve.includes(def.mainType) && def.typeRetrieveByDefault) ? true : false, })); // sort types by 1) initially selected and 2) alphabetically typeChoices.sort((a, b) => { if (a.name && b.name && a.name.toLowerCase() < b.name.toLowerCase()) { return -1; } if (a.name && b.name && a.name.toLowerCase() > b.name.toLowerCase()) { return 1; } if (a.value.toLowerCase() < b.value.toLowerCase()) { return -1; } if (a.value.toLowerCase() > b.value.toLowerCase()) { return 1; } return 0; }); selectedTypes = await checkbox({ message: 'Select Metadata types for retrieval', pageSize: 10, choices: [...typeChoices, new Separator(' ==== ')], }); } if (selectedTypes) { selectedTypes = Util.summarizeSubtypes('typeRetrieveByDefault', selectedTypes); // update config properties.metaDataTypes.retrieve = selectedTypes; await File.saveConfigFile(properties); } }, /** * shows metadata type descriptions * * @returns {ExplainType[]} list of supported types with their apiNames */ explainTypes() { /** @type {ExplainType[]} */ const json = []; const apiNameArr = Object.keys(MetadataDefinitions); for (const apiName of apiNameArr) { const details = MetadataDefinitions[apiName]; if (details.type === '') { // skip wrapper types like TransactionalMessage which don't have a value in "type" continue; } const supportCheckClass = apiName.startsWith('transactional') ? TransactionalMessage : MetadataTypeInfo[apiName]; json.push({ name: details.typeName, apiName: details.type, retrieveByDefault: details.typeRetrieveByDefault, supports: { retrieve: Object.prototype.hasOwnProperty.call(supportCheckClass, 'retrieve'), create: Object.prototype.hasOwnProperty.call(supportCheckClass, 'create'), update: Object.prototype.hasOwnProperty.call(supportCheckClass, 'update'), delete: Object.prototype.hasOwnProperty.call(supportCheckClass, 'deleteByKey'), changeKey: supportCheckClass.definition.keyIsFixed === false && supportCheckClass.definition.keyField !== supportCheckClass.definition.idField && supportCheckClass.definition.fields[supportCheckClass.definition.keyField] .isUpdateable && Object.prototype.hasOwnProperty.call(supportCheckClass, 'update'), buildTemplate: Object.prototype.hasOwnProperty.call( supportCheckClass, 'create' ), // supported for all types that can be created retrieveAsTemplate: Object.prototype.hasOwnProperty.call( supportCheckClass, 'retrieveAsTemplate' ), }, description: details.typeDescription, }); } if (Util.OPTIONS.json) { if (Util.OPTIONS.loggerLevel !== 'error') { console.log(JSON.stringify(json, null, 2)); // eslint-disable-line no-console } return json; } const typeChoices = []; for (const el in MetadataDefinitions) { if (MetadataDefinitions[el].type === '') { // skip wrapper types like TransactionalMessage which don't have a value in "type" continue; } if (MetadataDefinitions[el].subTypes && MetadataDefinitions[el].extendedSubTypes) { // used for assets to show whats available by default typeChoices.push({ Name: MetadataDefinitions[el].typeName, Default: '┐', Description: MetadataDefinitions[el].typeDescription, }); let lastCountdown = MetadataDefinitions[el].subTypes.length; for (const subtype of MetadataDefinitions[el].subTypes) { lastCountdown--; const subTypeRetrieveByDefault = Array.isArray(MetadataDefinitions[el].typeRetrieveByDefault) && MetadataDefinitions[el].typeRetrieveByDefault.includes(subtype); const definition = ' ' + MetadataDefinitions[el].extendedSubTypes?.[subtype]?.join(', '); typeChoices.push({ Name: MetadataDefinitions[el].typeName.replace('-[Subtype]', ': ') + subtype, Default: (lastCountdown > 0 ? '├ ' : '└ ') + (subTypeRetrieveByDefault ? 'yes' : '-'), Description: definition.length > 90 ? definition.slice(0, 90) + '...' : definition, }); } // change leading symbol of last subtype to close the tree visually } else { // types without subtypes typeChoices.push({ Name: MetadataDefinitions[el].typeName, Default: MetadataDefinitions[el].typeRetrieveByDefault ? 'yes' : '-', Description: MetadataDefinitions[el].typeDescription, }); } } typeChoices.sort((a, b) => { if (a.Name.toLowerCase() < b.Name.toLowerCase()) { return -1; } if (a.Name.toLowerCase() > b.Name.toLowerCase()) { return 1; } return 0; }); if (Util.OPTIONS.loggerLevel !== 'error') { console.table(typeChoices); // eslint-disable-line no-console } return json; }, }; export default Cli;