mcdev
Version:
Accenture Salesforce Marketing Cloud DevTools
512 lines (474 loc) • 21.6 kB
JavaScript
;
import Cli from './cli.js';
import File from './file.js';
import config from './config.js';
import InitGit from './init.git.js';
import InitNpm from './init.npm.js';
import InitConfig from './init.config.js';
import { confirm, input, select } from '@inquirer/prompts';
import { Util } from './util.js';
import fs from 'node:fs';
import path from 'node:path';
/**
* @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
*/
/**
* CLI helper class
*/
const Init = {
/**
* Creates template file for properties.json
*
* @param {Mcdevrc} properties config file's json
* @param {string} [credentialName] identifying name of the installed package / project; if set, will update this credential
* @param {boolean} [refreshBUs] if this was triggered by mcdev join, do not refresh BUs
* @returns {Promise.<void>} -
*/
async initProject(properties, credentialName, refreshBUs = true) {
if (!(await Init._checkPathForCloud())) {
return;
}
const skipInteraction = Util.skipInteraction;
if (!properties) {
// try to get cached properties because we return null in case of a crucial error
properties = config.properties;
}
const missingCredentials = this._getMissingCredentials(properties);
if ((await File.pathExists(Util.configFileName)) && properties) {
// config exists
if (credentialName) {
// update-credential mode
if (!properties.credentials[credentialName]) {
Util.logger.error(
`Could not update credential '${credentialName}' because it was not found in your config. Please check your spelling and try again.`
);
Cli.logExistingCredentials(properties);
if (skipInteraction) {
return;
}
const response = await Cli._selectBU(properties, null, true);
credentialName = response.credential;
}
Util.logger.info(`Updating existing credential '${credentialName}'`);
let error;
do {
error = false;
try {
const success = await Cli.updateCredential(properties, credentialName);
if (success) {
Util.logger.info(`✔️ Credential '${credentialName}' updated.`);
} else {
error = true;
}
} catch (ex) {
if (skipInteraction) {
Util.logger.error(ex.message);
return;
} else {
// retry
error = true;
}
}
} while (error && !skipInteraction);
Util.logger.debug('reloading config');
properties = await config.getProperties(true);
} else if (missingCredentials.length) {
// forced update-credential mode - user likely cloned repo and is missing mcdev-auth.json
Util.logger.warn(
`We found ${missingCredentials.length} credential${
missingCredentials.length > 1 ? 's' : ''
} in your ${Util.configFileName} that ${
missingCredentials.length > 1 ? 'are' : 'is'
} missing details.`
);
for (const badCredName of missingCredentials) {
let error;
do {
error = false;
try {
const success = await Cli.updateCredential(
properties,
badCredName,
refreshBUs
);
if (success) {
Util.logger.info(`✔️ Credential '${badCredName}' updated.`);
} else {
error = true;
}
} catch {
error = true;
}
} while (error);
Util.logger.debug('reloading config');
properties = await config.getProperties(true);
}
Util.logger.info('✔️ All credentials updated.');
// assume node dependencies are not installed
Util.execSync('npm', ['install']);
Util.logger.info('✔️ Dependencies installed.');
Util.logger.info('You can now start using Accenture SFMC DevTools.');
} else if (!missingCredentials.length) {
// add-credential mode
Util.logger.warn(Util.configFileName + ' found in root');
let isAddCredential;
if (skipInteraction) {
if (
skipInteraction.client_id &&
skipInteraction.client_secret &&
skipInteraction.auth_url &&
skipInteraction.account_id &&
skipInteraction.credentialName
) {
// assume automated input; only option here is to add a new credential
// requires skipInteraction=={client_id,client_secret,auth_url,account_id,credentialName}
// will be checked inside of Cli.addExtraCredential()
Util.logger.info('Adding another credential');
} else {
throw new Error(
'--skipInteraction flag found but missing required input for client_id,client_secret,auth_url,account_id,credentialName'
);
}
} else {
isAddCredential = await confirm({
message: 'Do you want to add another credential instead?',
default: false,
});
}
let credentialName;
if (skipInteraction || isAddCredential) {
credentialName = await Cli.addExtraCredential(properties);
}
if (credentialName) {
await this._downloadAllBUs(`${credentialName}/*`, 'update');
}
}
} else {
// config does not exist
// assuming it's the first time this command is run for this project
// initialize git repo
const initGit = await InitGit.initGitRepo();
if (initGit.status === 'error') {
return;
}
// set up IDE files and load npm dependencies
if (!(await this.upgradeProject(properties, true, initGit.repoName))) {
return;
}
// ask for credentials and create mcdev config
if (!(await Cli.initMcdevConfig())) {
return;
}
// set up markets and market lists initially
await Init._initMarkets();
// create first commit to backup the project configuration
if (initGit.status === 'init') {
Util.logger.info(`Committing initial setup to Git:`);
Util.execSync('git', ['add', '.']);
Util.execSync('git', ['commit', '-n', '-m', '"Initial commit"', '--quiet']);
Util.logger.info(`✔️ Configuration committed`);
}
// do initial retrieve *
await this._downloadAllBUs('"*"', initGit.status);
// backup to server
await InitGit.gitPush();
// all done
Util.logger.info('You are now ready to work with Accenture SFMC DevTools!');
Util.logger.warn(
'If you use VSCode, please restart it now to install recommended extensions.'
);
}
},
/**
* Creates template file for properties.json
*
* @returns {Promise.<void>} -
*/
async joinProject() {
if (!(await Init._checkPathForCloud())) {
return;
}
const isJoin = await confirm({
message:
'Do you want to join an existing project for which you have a Git-Repository URL?',
default: true,
});
if (isJoin) {
const gitRepoQs = {
gitRepoUrl: await input({
message: 'Please enter the Git-Repository URL',
}),
gitBranch: await input({
message:
'If you were asked to work on a specific branch, please enter it now (or leave empty for default)',
}),
};
const repoName = gitRepoQs.gitRepoUrl.split('/').pop().replace('.git', '');
// clone repo into current folder
Util.logger.info(
'Cloning initiated. You might be asked for your Git credentials in a pop-up window in a few seconds.'
);
Util.execSync(
'git',
[
'clone',
gitRepoQs.gitBranch ? `--branch ${gitRepoQs.gitBranch}` : null,
'--config core.longpaths=true',
'--config core.autocrlf=input',
gitRepoQs.gitRepoUrl,
].filter(Boolean)
);
if (!fs.existsSync(repoName)) {
Util.logger.error(
'Could not clone repository. Please check your Git-Repository URL as well as your credentials and try again.'
);
Util.logger.info(
'Check if you need an "API-Token" instead of your normal user password to authenticate'
);
return;
}
// make sure we switch to the new subfolder or else the rest will fail
process.chdir(repoName);
// check if the branch looks good
const properties = await config.getProperties(true, true);
if (!properties) {
Util.logger.error(
'Could not find .mcdevrc.json file in project folder. Please check your Git repository and branch.'
);
return;
}
// get name and email that's to be used for git commits
await InitGit._updateGitConfigUser();
// ask the user to enter the server credentials
await this.initProject(properties, null, false);
} else {
return;
}
},
/**
* helper for @initProject that optionally creates markets and market lists for all BUs
*/
async _initMarkets() {
const skipInteraction = Util.skipInteraction;
const properties = await config.getProperties(true);
// get list of business units
const firstCredentialName = Object.keys(properties.credentials)[0];
const businessUnits = Object.keys(
properties.credentials[firstCredentialName].businessUnits
);
// set up empty markets for them
const markets = {};
for (const bu of businessUnits) {
markets[bu] = { suffix: '_' + bu };
}
properties.markets = markets;
let sourceBuName;
// set up default deployment market lists
if (skipInteraction) {
// don't ask, list all BUs in deployment-target and set deployment-source to ???
if (!businessUnits.includes(skipInteraction.developmentBu)) {
Util.logger.warn(
`Could not find developmentBu=${skipInteraction.developmentBu} in business units. Skipping.`
);
delete skipInteraction.developmentBu;
}
sourceBuName = skipInteraction.developmentBu || '???';
if (!skipInteraction.developmentBu) {
Util.logger.info(
'Market List "deployment-source" will need to be set up manually. Marking all BUs as target BUs in "deployment-target".'
);
}
} else {
sourceBuName = await select({
message: 'Please select your development business unit:',
choices: businessUnits.map((bu) => ({ name: bu, value: bu })),
});
}
// set source list
properties.marketList['deployment-source'][firstCredentialName + '/' + sourceBuName] =
sourceBuName;
// set target list
for (const bu of businessUnits) {
// filter out source BU & parent BU to ensure they dont get deployed to automatically
if (bu !== sourceBuName && bu !== '_ParentBU_') {
properties.marketList['deployment-target'][firstCredentialName + '/' + bu] = bu;
}
}
await File.saveConfigFile(properties);
},
/**
* helper for {@link Init.initProject}
*
* @param {string} bu cred/bu or cred/* or *
* @param {string} gitStatus signals what state the git repo is in
* @returns {Promise.<void>} -
*/
async _downloadAllBUs(bu, gitStatus) {
const skipInteraction = Util.skipInteraction;
let initialRetrieveAll;
if (!skipInteraction) {
initialRetrieveAll = await confirm({
message: 'Do you want to start downloading all Business Units (recommended)?',
default: true,
});
}
if (skipInteraction?.downloadBUs === 'true' || initialRetrieveAll) {
Util.execSync('mcdev', ['retrieve', bu]);
if (gitStatus === 'init') {
Util.logger.info(`Committing first backup of your SFMC instance:`);
Util.execSync('git', ['add', '.']);
Util.execSync('git', ['commit', '-n', '-m', '"First instance backup"', '--quiet']);
Util.logger.info(`✔️ SFMC instance backed up`);
} else if (gitStatus === 'update') {
Util.logger.warn(
'Please manually commit this backup according to your projects guidelines.'
);
// TODO create guided commit:
// 1. ask if commit with all changes shall be created
// 2. ask if that should be done to current branch (show which one we are on) or a new branch
// a. if new: ask off of which we shall branch off of (show list) and then auto-create new branch and switch to it
// 3. create commit
}
}
},
/**
* wrapper around npm dependency & configuration file setup
*
* @param {Mcdevrc} properties config file's json
* @param {boolean} [initial] print message if not part of initial setup
* @param {string} [repoName] if git URL was provided earlier, the repo name was extracted to use it for npm init
* @returns {Promise.<boolean>} success flag
*/
async upgradeProject(properties, initial, repoName) {
if (!(await Init._checkPathForCloud())) {
return;
}
let status;
const versionBeforeUpgrade = properties?.version || '0.0.0';
if (!initial) {
Util.logger.info(
'Upgrading project with newest configuration, npm dependencies & other project configurations:'
);
// ensure an existing config is up to current specs
status = await InitConfig.fixMcdevConfig(properties);
if (!status) {
return false;
}
// version 4 release to simplify auth
status = await InitConfig.upgradeAuthFile();
if (!status) {
return false;
}
}
// create files before installing dependencies to ensure .gitignore is properly set up
status = await InitConfig.createIdeConfigFiles(versionBeforeUpgrade);
if (!status) {
return false;
}
// install node dependencies
status = await InitNpm.installDependencies(repoName);
if (!status) {
return false;
}
return true;
},
/**
* check if git repo is being saved on a cloud service and warn the user
*
* @private
* @returns {Promise.<boolean>} true if path is good; false if project seems to be in a cloud service folder
*/
async _checkPathForCloud() {
const absolutePath = path.resolve('');
// popular cloud services and their respective default name for the absolute path
// * CloudDocs is the default folder name for iCloud
const cloudServices = ['Dropbox', 'OneDrive', 'Google Drive', 'iCloud', 'CloudDocs'];
let cloudServiceFound = false;
for (const variable in cloudServices) {
if (absolutePath.includes(cloudServices[variable])) {
Util.logger.warn(
`It seems your project folder will be synchronized via '${
cloudServices[variable] === 'CloudDocs' ? 'iCloud' : cloudServices[variable]
}'. This can reduce the overall performance of your computer due to conflicts with Git.`
);
Util.logger.warn(
`We strongly recommend moving your project folder outside of the '${
cloudServices[variable] === 'CloudDocs' ? 'iCloud' : cloudServices[variable]
}' folder.`
);
cloudServiceFound = true;
}
}
if (!cloudServiceFound && absolutePath.includes(process.env.USERPROFILE)) {
// warn user to not place project folder into user profile folder
Util.logger.warn(
`It seems your project folder is located in your user profile's default folder which is often synchronized to webservices like ${cloudServices.join(
', '
)}. This can reduce the overall performance of your computer due to conflicts between with Git.`
);
Util.logger.warn(
`We strongly recommend moving your project folder outside of this folder.`
);
cloudServiceFound = true;
}
if (cloudServiceFound) {
const ignoreCloudWarning = await confirm({
message: 'Do you want to continue anyways?',
default: false,
});
if (!ignoreCloudWarning) {
Util.logger.error('Exiting due to cloud service warning');
return false;
}
}
return true;
},
/**
* finds credentials that are set up in config but not in auth file
*
* @private
* @param {Mcdevrc} properties javascript object in .mcdevrc.json
* @returns {string[]} list of credential names
*/
_getMissingCredentials(properties) {
let missingCredentials;
if (properties?.credentials) {
// reload auth file because for some reason we didnt want that in our main properties object
let auth;
try {
auth = File.readJsonSync(Util.authFileName);
} catch {
// file not found
auth = [];
}
// walk through config credentials and check if the matching credential in the auth file is missing something
missingCredentials = Object.keys(properties.credentials).filter(
(cred) =>
!auth[cred] ||
!auth[cred].account_id ||
properties.credentials[cred].eid != auth[cred].account_id ||
!auth[cred].client_id ||
!auth[cred].client_secret ||
!auth[cred].auth_url
);
}
return missingCredentials || [];
},
};
export default Init;