UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

1,186 lines 52.9 kB
import c from 'chalk'; import * as child from 'child_process'; import { spawn as crossSpawn } from 'cross-spawn'; import * as crypto from 'crypto'; import { stringify as csvStringify } from 'csv-stringify/sync'; import fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as util from 'util'; import which from 'which'; import * as xml2js from 'xml2js'; const exec = util.promisify(child.exec); import { SfError } from '@salesforce/core'; import ora from 'ora'; import { simpleGit } from 'simple-git'; import { CONSTANTS, getApiVersion, getConfig, getReportDirectory, setConfig } from '../../config/index.js'; import { prompts } from './prompts.js'; import { encryptFile } from '../cryptoUtils.js'; import { deployMetadatas, shortenLogLines } from './deployUtils.js'; import { isProductionOrg, promptProfiles, promptUserEmail } from './orgUtils.js'; import { WebSocketClient } from '../websocketClient.js'; import moment from 'moment'; import { writeXmlFile } from './xmlUtils.js'; let pluginsStdout = null; export const isCI = process.env.CI != null; export function git(options = { output: false, displayCommand: true }) { const simpleGitInstance = simpleGit(); // Hack to be able to display executed git command (and it still doesn't work...) // cf: https://github.com/steveukx/git-js/issues/593 return simpleGitInstance.outputHandler((command, stdout, stderr, gitArgs) => { let first = true; stdout.on('data', (data) => { logCommand(); if (options.output) { uxLog(this, c.italic(c.grey(data))); } }); stderr.on('data', (data) => { logCommand(); if (options.output) { uxLog(this, c.italic(c.yellow(data))); } }); function logCommand() { if (first) { first = false; const gitArgsStr = (gitArgs || []).join(' '); if (!(gitArgsStr.includes('branch -v') || gitArgsStr.includes('config --list --show-origin --null'))) { if (options.displayCommand) { uxLog(this, `[command] ${c.bold(c.bgWhite(c.blue(command + ' ' + gitArgsStr)))}`); } } } } }); } export async function createTempDir() { const tmpDir = path.join(os.tmpdir(), 'sfdx-hardis-' + Math.random().toString(36).substring(7)); await fs.ensureDir(tmpDir); return tmpDir; } let isGitRepoCache = null; export function isGitRepo() { if (isGitRepoCache !== null) { return isGitRepoCache; } const isInsideWorkTree = child.spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', windowsHide: true, }); isGitRepoCache = isInsideWorkTree.status === 0; return isGitRepoCache; } export async function getGitRepoName() { if (!isGitRepo) { return null; } const origin = await git().getConfig('remote.origin.url'); if (origin.value && origin.value.includes('/')) { return (/[^/]*$/.exec(origin.value) || '')[0]; } return null; } export async function getGitRepoUrl() { if (!isGitRepo) { return null; } const origin = await git().getConfig('remote.origin.url'); if (origin && origin.value) { // Replace https://username:token@gitlab.com/toto by https://gitlab.com/toto return origin.value.replace(/\/\/(.*:.*@)/gm, `//`); } return null; } export async function gitHasLocalUpdates(options = { show: false }) { const changes = await git().status(); if (options.show) { uxLog(this, c.cyan(JSON.stringify(changes))); } return changes.files.length > 0; } // Install plugin if not present export async function checkSfdxPlugin(pluginName) { // Manage cache of SF CLI Plugins result if (pluginsStdout == null) { const config = await getConfig('user'); if (config.sfdxPluginsStdout) { pluginsStdout = config.sfdxPluginsStdout; } else { const pluginsRes = await exec('sf plugins'); pluginsStdout = pluginsRes.stdout; await setConfig('user', { sfdxPluginsStdout: pluginsStdout }); } } if (!(pluginsStdout || '').includes(pluginName)) { uxLog(this, c.yellow(`[dependencies] Installing SF CLI plugin ${c.green(pluginName)}... \nIf is stays stuck for too long, please run ${c.green(`sf plugins install ${pluginName}`)})`)); const installCommand = `echo y|sf plugins install ${pluginName}`; await execCommand(installCommand, this, { fail: true, output: false }); } } const dependenciesInstallLink = { git: 'Download installer at https://git-scm.com/downloads', openssl: 'Run "choco install openssl" in Windows Powershell, or use Git Bash as command line tool', }; export async function checkAppDependency(appName) { const config = await getConfig('user'); const installedApps = config.installedApps || []; if (installedApps.includes(appName)) { return true; } which(appName) .then(async () => { installedApps.push(appName); await setConfig('user', { installedApps: installedApps }); }) .catch(() => { uxLog(this, c.red(`You need ${c.bold(appName)} to be locally installed to run this command.\n${dependenciesInstallLink[appName] || ''}`)); process.exit(); }); } export async function promptInstanceUrl(orgTypes = ['login', 'test'], alias = 'default org', defaultOrgChoice = null) { const customLoginUrlExample = orgTypes && orgTypes.length === 1 && orgTypes[0] === 'login' ? 'https://myclient.lightning.force.com/' : 'https://myclient--preprod.sandbox.lightning.force.com/'; const allChoices = [ { title: '📝 Custom login URL (Sandbox, DevHub or Production Org)', description: `Recommended option :) Example: ${customLoginUrlExample}`, value: 'custom', }, { title: '🧪 Sandbox or Scratch org (test.salesforce.com)', description: 'The org I want to connect is a sandbox or a scratch org', value: 'https://test.salesforce.com', }, { title: '☢️ Other: Dev org, Production org or DevHub org (login.salesforce.com)', description: 'The org I want to connect is NOT a sandbox', value: 'https://login.salesforce.com', }, ]; const choices = allChoices.filter((choice) => { if (choice.value === 'https://login.salesforce.com' && !orgTypes.includes('login')) { return false; } if (choice.value === 'https://test.salesforce.com' && !orgTypes.includes('test')) { return false; } return true; }); if (defaultOrgChoice != null) { choices.push({ title: `♻️ ${defaultOrgChoice.instanceUrl}`, description: 'Your current default org', value: defaultOrgChoice.instanceUrl, }); } const orgTypeResponse = await prompts({ type: 'select', name: 'value', message: c.cyanBright(`What is the base URL or the org you want to connect to, as ${alias} ?`), choices: choices, initial: 1, }); // login.salesforce.com or test.salesforce.com const url = orgTypeResponse.value; if (url.startsWith('http')) { return url; } // Custom url to input const customUrlResponse = await prompts({ type: 'text', name: 'value', message: c.cyanBright('Please input the base URL of the salesforce org (ex: https://myclient.my.salesforce.com)'), }); const urlCustom = (customUrlResponse?.value || "").replace('.lightning.force.com', '.my.salesforce.com'); return urlCustom; } // Check if we are in a repo, or create it if missing export async function ensureGitRepository(options = { init: false, clone: false, cloneUrl: null }) { if (!isGitRepo()) { // Init repo if (options.init) { await exec('git init -b main'); console.info(c.yellow(c.bold(`[sfdx-hardis] Initialized git repository in ${process.cwd()}`))); isGitRepoCache = null; } else if (options.clone) { // Clone repo let cloneUrl = options.cloneUrl; if (!cloneUrl) { // Request repo url if not provided const cloneUrlPrompt = await prompts({ type: 'text', name: 'value', message: c.cyanBright('What is the URL of your git repository ? example: https://gitlab.hardis-group.com/busalesforce/monclient/monclient-org-monitoring.git'), }); cloneUrl = cloneUrlPrompt.value; } // Git lcone await new Promise((resolve) => { crossSpawn('git', ['clone', cloneUrl, '.'], { stdio: 'inherit' }).on('close', () => { resolve(null); }); }); uxLog(this, `Git repository cloned. ${c.yellow('Please run again the same command :)')}`); process.exit(0); } else { throw new SfError('You need to be at the root of a git repository to run this command'); } } // Check if root else if (options.mustBeRoot) { const gitRepoRoot = await getGitRepoRoot(); if (path.resolve(gitRepoRoot) !== path.resolve(process.cwd())) { throw new SfError(`You must be at the root of the git repository (${path.resolve(gitRepoRoot)})`); } } } export async function getGitRepoRoot() { const gitRepoRoot = await git().revparse(['--show-toplevel']); return gitRepoRoot; } // Get local git branch name export async function getCurrentGitBranch(options = { formatted: false }) { if (!isGitRepo()) { return null; } const gitBranch = process.env.CI_COMMIT_REF_NAME || (await git().branchLocal()).current; if (options.formatted === true) { return gitBranch.replace('/', '__'); } return gitBranch; } export async function getLatestGitCommit() { if (!isGitRepo()) { return null; } const log = await git().log(['-1']); return log?.latest ?? null; } // Select git branch and checkout & pull if requested export async function selectGitBranch(options = { remote: true, checkOutPull: false }) { const gitBranchOptions = ['--list']; if (options.remote) { gitBranchOptions.push('-r'); } const branches = await git().branch(gitBranchOptions); if (options.allowAll) { branches.all.unshift("ALL BRANCHES"); } const branchResp = await prompts({ type: 'select', name: 'value', message: options.message || 'Please select a Git branch', choices: branches.all.map((branchName) => { return { title: branchName.replace('origin/', ''), value: branchName.replace('origin/', '') }; }), }); const branch = branchResp.value; // Checkout & pull if requested if (options.checkOutPull && branch !== "ALL BRANCHES") { await gitCheckOutRemote(branch); WebSocketClient.sendMessage({ event: 'refreshStatus' }); } return branch; } export async function gitCheckOutRemote(branchName) { await git().checkout(branchName); await git().pull(); } // Get local git branch name export async function ensureGitBranch(branchName, options = { init: false, parent: 'current' }) { if (!isGitRepo()) { if (options.init) { await ensureGitRepository({ init: true }); isGitRepoCache = null; } else { return false; } } await git().fetch(); const branches = await git().branch(); const localBranches = await git().branchLocal(); if (localBranches.current !== branchName) { if (branches.all.includes(branchName)) { // Existing branch: checkout & pull await git().checkout(branchName); // await git().pull() } else { if (options?.parent === 'main') { // Create from main branch const mainBranch = branches.all.includes('main') ? 'main' : branches.all.includes('origin/main') ? 'main' : branches.all.includes('remotes/origin/main') ? 'main' : 'master'; await git().checkout(mainBranch); await git().checkoutBranch(branchName, mainBranch); } else { // Not existing branch: create it from current branch await git().checkoutBranch(branchName, localBranches.current); } } } return true; } // Checks that current git status is clean. export async function checkGitClean(options) { if (!isGitRepo()) { throw new SfError('[sfdx-hardis] You must be within a git repository'); } const gitStatus = await git({ output: true }).status(); if (gitStatus.files.length > 0) { const localUpdates = gitStatus.files .map((fileStatus) => { return `(${fileStatus.working_dir}) ${getSfdxFileLabel(fileStatus.path)}`; }) .join('\n'); if (options.allowStash) { try { await execCommand('git add --all', this, { output: true, fail: true }); await execCommand('git stash', this, { output: true, fail: true }); } catch (e) { uxLog(this, c.yellow(c.bold("You might need to run the following command in Powershell launched as Administrator"))); uxLog(this, c.yellow(c.bold("git config --system core.longpaths true"))); throw e; } } else { throw new SfError(`[sfdx-hardis] Branch ${c.bold(gitStatus.current)} is not clean. You must ${c.bold('commit or reset')} the following local updates:\n${c.yellow(localUpdates)}`); } } } // Interactive git add export async function interactiveGitAdd(options = { filter: [], groups: [] }) { if (!isGitRepo()) { throw new SfError('[sfdx-hardis] You must be within a git repository'); } // List all files and arrange their format const config = await getConfig('project'); const gitStatus = await git().status(); let filesFiltered = gitStatus.files .filter((fileStatus) => { return ((options.filter || []).filter((filterString) => fileStatus.path.includes(filterString)).length === 0); }) .map((fileStatus) => { fileStatus.path = normalizeFileStatusPath(fileStatus.path, config); return fileStatus; }); // Create default group if let groups = options.groups || []; if (groups.length === 0) { groups = [ { label: 'All', regex: /(.*)/i, defaultSelect: false, ignore: false, }, ]; } // Ask user what he/she wants to git add/rm const result = { added: [], removed: [] }; if (filesFiltered.length > 0) { for (const group of groups) { // Extract files matching group regex const matchingFiles = filesFiltered.filter((fileStatus) => { return group.regex.test(fileStatus.path); }); if (matchingFiles.length === 0) { continue; } // Remove remaining files list filesFiltered = filesFiltered.filter((fileStatus) => { return !group.regex.test(fileStatus.path); }); // Ask user for input const selectFilesStatus = await prompts({ type: 'multiselect', name: 'files', message: c.cyanBright(`Please select ${c.red('carefully')} the ${c.bgWhite(c.red(c.bold(group.label.toUpperCase())))} files you want to commit (save)}`), choices: matchingFiles.map((fileStatus) => { return { title: `(${getGitWorkingDirLabel(fileStatus.working_dir)}) ${getSfdxFileLabel(fileStatus.path)}`, selected: group.defaultSelect || false, value: fileStatus, }; }), optionsPerPage: 9999, }); // Add to group list of files group.files = selectFilesStatus.files; // Separate added to removed files result.added.push(...selectFilesStatus.files .filter((fileStatus) => fileStatus.working_dir !== 'D') .map((fileStatus) => fileStatus.path.replace('"', ''))); result.removed.push(...selectFilesStatus.files .filter((fileStatus) => fileStatus.working_dir === 'D') .map((fileStatus) => fileStatus.path.replace('"', ''))); } if (filesFiltered.length > 0) { uxLog(this, c.grey('The following list of files has not been proposed for selection\n' + filesFiltered .map((fileStatus) => { return ` - (${getGitWorkingDirLabel(fileStatus.working_dir)}) ${getSfdxFileLabel(fileStatus.path)}`; }) .join('\n'))); } // Ask user for confirmation const confirmationText = groups .filter((group) => group.files != null && group.files.length > 0) .map((group) => { return (c.bgWhite(c.red(c.bold(group.label))) + '\n' + group.files .map((fileStatus) => { return ` - (${getGitWorkingDirLabel(fileStatus.working_dir)}) ${getSfdxFileLabel(fileStatus.path)}`; }) .join('\n') + '\n'); }) .join('\n'); const addFilesResponse = await prompts({ type: 'select', name: 'addFiles', message: c.cyanBright(`Do you confirm that you want to add the following list of files ?\n${confirmationText}`), choices: [ { title: 'Yes, my selection is complete !', value: 'yes' }, { title: 'No, I want to select again', value: 'no' }, { title: 'Let me out of here !', value: 'bye' }, ], initial: 0, }); // Commit if requested if (addFilesResponse.addFiles === 'yes') { if (result.added.length > 0) { await git({ output: true }).add(result.added); } if (result.removed.length > 0) { await git({ output: true }).rm(result.removed); } } // restart selection else if (addFilesResponse.addFiles === 'no') { return await interactiveGitAdd(options); } // exit else { uxLog(this, 'Cancelled by user'); process.exit(0); } } else { uxLog(this, c.cyan('There is no new file to commit')); } return result; } // Shortcut to add, commit and push export async function gitAddCommitPush(options = { init: false, pattern: './*', commitMessage: 'Updated by sfdx-hardis', branch: null, }) { if (!isGitRepo()) { if (options.init) { // Initialize git repo await execCommand('git init -b main', this); isGitRepoCache = null; await git().checkoutBranch(options.branch || 'dev', 'main'); } } // Add, commit & push const currentgitBranch = (await git().branchLocal()).current; await git() .add(options.pattern || './*') .commit(options.commitMessage || 'Updated by sfdx-hardis') .push(['-u', 'origin', currentgitBranch]); } // Normalize git FileStatus path export function normalizeFileStatusPath(fileStatusPath, config) { if (fileStatusPath.startsWith('"')) { fileStatusPath = fileStatusPath.substring(1); } if (fileStatusPath.endsWith('"')) { fileStatusPath = fileStatusPath.slice(0, -1); } if (config.gitRootFolderPrefix) { fileStatusPath = fileStatusPath.replace(config.gitRootFolderPrefix, ''); } return fileStatusPath; } // Execute salesforce DX command with --json export async function execSfdxJson(command, commandThis, options = { fail: false, output: false, debug: false, }) { if (!command.includes('--json')) { command += ' --json'; } return await execCommand(command, commandThis, options); } // Execute command export async function execCommand(command, commandThis, options = { fail: false, output: false, debug: false, spinner: true, }) { let commandLog = `[sfdx-hardis][command] ${c.bold(c.bgWhite(c.blue(command)))}`; const execOptions = { maxBuffer: 10000 * 10000 }; if (options.cwd) { execOptions.cwd = options.cwd; if (path.resolve(execOptions.cwd) !== path.resolve(process.cwd())) { commandLog += c.grey(` ${c.italic('in directory')} ${execOptions.cwd}`); } } const env = Object.assign({}, process.env); // Disable colors for json parsing // Remove NODE_OPTIONS in case it contains --inspect-brk to avoid to trigger again the debugger env.FORCE_COLOR = '0'; if (env?.NODE_OPTIONS && env.NODE_OPTIONS.includes("--inspect-brk")) { env.NODE_OPTIONS = ""; } if (env?.JSFORCE_LOG_LEVEL) { env.JSFORCE_LOG_LEVEL = ""; } execOptions.env = env; let commandResult = {}; const output = options.output !== null ? options.output : !commandThis?.argv?.includes('--json'); let spinner; if (output && !(options.spinner === false)) { spinner = ora({ text: commandLog, spinner: 'moon' }).start(); } else { uxLog(this, commandLog); } try { commandResult = await exec(command, execOptions); if (spinner) { spinner.succeed(commandLog); } } catch (e) { if (spinner) { spinner.fail(commandLog); } // Display error in red if not json if (!command.includes('--json') || options.fail) { const strErr = shortenLogLines(`${e.stdout}\n${e.stderr}`); if (output) { console.error(c.red(strErr)); } e.message = e.message += '\n' + strErr; // Manage retry if requested if (options.retry != null) { options.retry.tryCount = (options.retry.tryCount || 0) + 1; if (options.retry.tryCount <= (options.retry.retryMaxAttempts || 1) && (options.retry.retryStringConstraint == null || (e.stdout + e.stderr).includes(options.retry.retryStringConstraint))) { uxLog(commandThis, c.yellow(`Retry command: ${options.retry.tryCount} on ${options.retry.retryMaxAttempts || 1}`)); if (options.retry.retryDelay) { uxLog(this, `Waiting ${options.retry.retryDelay} seconds before retrying command`); await new Promise((resolve) => setTimeout(resolve, options.retry.retryDelay * 1000)); } return await execCommand(command, commandThis, options); } } throw e; } // if --json, we should not have a crash, so return status 1 + output log return { status: 1, errorMessage: `[sfdx-hardis][ERROR] Error processing command\n$${e.stdout}\n${e.stderr}`, error: e, }; } // Display output if requested, for better user understanding of the logs if (options.output || options.debug) { uxLog(commandThis, c.italic(c.grey(shortenLogLines(commandResult.stdout)))); } // Return status 0 if not --json if (!command.includes('--json')) { return { status: 0, stdout: commandResult.stdout, stderr: commandResult.stderr, }; } // Parse command result if --json try { const parsedResult = JSON.parse(commandResult.stdout); if (options.fail && parsedResult.status && parsedResult.status > 0) { throw new SfError(c.red(`[sfdx-hardis][ERROR] Command failed: ${commandResult}`)); } if (commandResult.stderr && commandResult.stderr.length > 2) { uxLog(this, '[sfdx-hardis][WARNING] stderr: ' + c.yellow(commandResult.stderr)); } return parsedResult; } catch (e) { // Manage case when json is not parseable return { status: 1, errorMessage: c.red(`[sfdx-hardis][ERROR] Error parsing JSON in command result: ${e.message}\n${commandResult.stdout}\n${commandResult.stderr})`), }; } } /* Ex: force-app/main/default/layouts/Opportunity-Opportunity %28Marketing%29 Layout.layout-meta.xml becomes layouts/Opportunity-Opportunity (Marketing Layout).layout-meta.xml */ export function getSfdxFileLabel(filePath) { const cleanStr = decodeURIComponent(filePath.replace('force-app/main/default/', '').replace('force-app/main/', '').replace('"', '')); const dotNumbers = (filePath.match(/\./g) || []).length; if (dotNumbers > 1) { const m = /(.*)\/(.*)\..*\..*/.exec(cleanStr); if (m && m.length >= 2) { return cleanStr.replace(m[1], c.cyan(m[1])).replace(m[2], c.bold(c.yellow(m[2]))); } } else { const m = /(.*)\/(.*)\..*/.exec(cleanStr); if (m && m.length >= 2) { return cleanStr.replace(m[2], c.yellow(m[2])); } } return cleanStr; } function getGitWorkingDirLabel(workingDir) { return workingDir === '?' ? 'CREATED' : workingDir === 'D' ? 'DELETED' : workingDir === 'M' ? 'UPDATED' : 'OOOOOPS'; } const elapseAll = {}; export function elapseStart(text) { elapseAll[text] = process.hrtime.bigint(); } export function elapseEnd(text, commandThis = this) { if (elapseAll[text]) { const elapsed = Number(process.hrtime.bigint() - elapseAll[text]); const ms = elapsed / 1000000; uxLog(commandThis, c.grey(c.italic(text + ' ' + moment().startOf('day').milliseconds(ms).format('H:mm:ss.SSS')))); delete elapseAll[text]; } } // Can be used to merge 2 package.xml content export function mergeObjectPropertyLists(obj1, obj2, options) { for (const key of Object.keys(obj2)) { if (obj1[key]) { obj1[key].push(...obj2[key]); } else { obj1[key] = obj2[key]; } obj1[key] = [...new Set(obj1[key])]; // Make list unique if (options.sort) { obj1[key].sort(); } } return obj1; } // Can be used to merge 2 package.xml content export function removeObjectPropertyLists(obj1, objToRemove) { for (const key of Object.keys(objToRemove)) { if (obj1[key]) { const itemsToRemove = objToRemove[key]; obj1[key] = obj1[key].filter((item) => !itemsToRemove.includes(item)); } } return obj1; } // Filter package XML export async function filterPackageXml(packageXmlFile, packageXmlFileOut, options = { keepOnlyNamespaces: [], removeNamespaces: [], removeMetadatas: [], removeStandard: false, removeFromPackageXmlFile: null, updateApiVersion: null, }) { let updated = false; let message = `[sfdx-hardis] ${packageXmlFileOut} not updated`; const initialFileContent = await fs.readFile(packageXmlFile); const manifest = await xml2js.parseStringPromise(initialFileContent); // Keep only namespaces if ((options.keepOnlyNamespaces || []).length > 0) { uxLog(this, c.grey(`Keeping items from namespaces ${options.keepOnlyNamespaces.join(',')} ...`)); manifest.Package.types = manifest.Package.types.map((type) => { type.members = type.members.filter((member) => { const containsNamespace = options.keepOnlyNamespaces.filter((ns) => member.startsWith(ns) || member.includes(`${ns}__`)).length > 0; if (containsNamespace) { return true; } return false; }); return type; }); } // Remove namespaces if ((options.removeNamespaces || []).length > 0) { uxLog(this, c.grey(`Removing items from namespaces ${options.removeNamespaces.join(',')} ...`)); manifest.Package.types = manifest.Package.types.map((type) => { type.members = type.members.filter((member) => { const startsWithNamespace = options.removeNamespaces.filter((ns) => member.startsWith(ns)).length > 0; if (startsWithNamespace) { const splits = member.split('.'); if (splits.length === 2 && (((splits[1].match(/__/g) || []).length == 1 && splits[1].endsWith('__c')) || (splits[1].match(/__/g) || []).length == 0)) { // Keep ns__object__c.field__c and ns__object.stuff return true; } // Do not keep ns__object__c.ns__field__c or ns__object__c.ns__stuff return false; } return true; }); return type; }); } // Remove from other packageXml file if (options.removeFromPackageXmlFile) { const destructiveFileContent = await fs.readFile(options.removeFromPackageXmlFile); const destructiveManifest = await xml2js.parseStringPromise(destructiveFileContent); manifest.Package.types = manifest.Package.types .map((type) => { const destructiveTypes = destructiveManifest.Package.types.filter((destructiveType) => { return destructiveType.name[0] === type.name[0]; }); if (destructiveTypes.length > 0) { type.members = type.members.filter((member) => { return shouldRetainMember(destructiveTypes[0].members, member); }); } return type; }) .filter((type) => { // Remove types with wildcard const wildcardDestructiveTypes = destructiveManifest.Package.types.filter((destructiveType) => { return (destructiveType.name[0] === type.name[0] && destructiveType.members.length === 1 && destructiveType.members[0] === '*'); }); if (wildcardDestructiveTypes.length > 0) { uxLog(this, c.grey(`Removed ${type.name[0]} type`)); } return wildcardDestructiveTypes.length === 0; }); } // Remove standard objects if (options.removeStandard) { const customFields = manifest.Package.types.filter((t) => t.name[0] === 'CustomField')?.[0]?.members || []; manifest.Package.types = manifest.Package.types.map((type) => { if (['CustomObject'].includes(type.name[0])) { type.members = type.members.filter((customObjectName) => { // If a custom field is defined on the standard object, keep the standard object if (customFields.some((field) => field.startsWith(customObjectName + '.'))) { return true; } return customObjectName.endsWith('__c'); }); } type.members = type.members.filter((member) => { return !member.startsWith('standard__'); }); return type; }); } // Update API version if (options.updateApiVersion) { manifest.Package.version[0] = options.updateApiVersion; } if (options.keepMetadataTypes && options.keepMetadataTypes.length > 0) { // Remove metadata types (named, and empty ones) manifest.Package.types = manifest.Package.types.filter((type) => { if (options.keepMetadataTypes.includes(type.name[0])) { uxLog(this, c.grey('kept ' + type.name[0])); return true; } uxLog(this, c.grey('removed ' + type.name[0])); return false; }); } // Remove metadata types (named, and empty ones) manifest.Package.types = manifest.Package.types.filter((type) => !(options.removeMetadatas || []).includes(type.name[0]) && (type?.members?.length || 0) > 0); const builder = new xml2js.Builder({ renderOpts: { pretty: true, indent: ' ', newline: '\n' } }); const updatedFileContent = builder.buildObject(manifest); if (updatedFileContent !== initialFileContent.toString()) { await writeXmlFile(packageXmlFileOut, manifest); updated = true; if (packageXmlFile !== packageXmlFileOut) { message = `[sfdx-hardis] ${packageXmlFile} has been filtered to ${packageXmlFileOut}`; } else { message = `[sfdx-hardis] ${packageXmlFile} has been updated`; } } return { updated, message, }; } function shouldRetainMember(destructiveMembers, member) { if (destructiveMembers.length === 1 && destructiveMembers[0] === '*') { // Whole type will be filtered later in the code return true; } const matchesWithItemsToExclude = destructiveMembers.filter((destructiveMember) => { if (destructiveMember === member) { return true; } // Handle cases wild wildcards, like pi__* , *__dlm , or begin*end if (destructiveMember.includes('*')) { const regex = new RegExp(destructiveMember.replace(/\*/g, '.*')); if (regex.test(member)) { return true; } } return false; }); return matchesWithItemsToExclude.length === 0; } // Catch matches in files according to criteria export async function catchMatches(catcher, file, fileText, commandThis) { const matchResults = []; if (catcher.regex) { // Check if there are matches const matches = await countRegexMatches(catcher.regex, fileText); if (matches > 0) { // If match, extract match details const fileName = path.basename(file); const detail = {}; for (const detailCrit of catcher.detail) { const detailCritVal = await extractRegexGroups(detailCrit.regex, fileText); if (detailCritVal.length > 0) { detail[detailCrit.name] = detailCritVal; } } const catcherLabel = catcher.regex ? `regex ${catcher.regex.toString()}` : 'ERROR'; matchResults.push({ fileName, fileText, matches, type: catcher.type, subType: catcher.subType, detail, catcherLabel, }); if (commandThis.debug) { uxLog(commandThis, `[${fileName}]: Match [${matches}] occurrences of [${catcher.type}/${catcher.name}] with catcher [${catcherLabel}]`); } } } return matchResults; } // Count matches of a regex export async function countRegexMatches(regex, text) { return ((text || '').match(regex) || []).length; } // Get all captured groups of a regex in a string export async function extractRegexGroups(regex, text) { const matches = ((text || '').match(regex) || []).map((e) => e.replace(regex, '$1').trim()); return matches; // return ((text || '').matchAll(regex) || []).map(item => item.trim()); } export async function extractRegexMatches(regex, text) { let m; const matchStrings = []; while ((m = regex.exec(text)) !== null) { // This is necessary to avoid infinite loops with zero-width matches if (m.index === regex.lastIndex) { regex.lastIndex++; } // Iterate thru the regex matches m.forEach((match, group) => { if (group === 1) { matchStrings.push(match); } }); } return matchStrings; } export async function extractRegexMatchesMultipleGroups(regex, text) { let m; const matchResults = []; while ((m = regex.exec(text)) !== null) { // This is necessary to avoid infinite loops with zero-width matches if (m.index === regex.lastIndex) { regex.lastIndex++; } // Iterate thru the regex matches const matchGroups = []; m.forEach((match) => { matchGroups.push(match); }); matchResults.push(matchGroups); } return matchResults; } export function arrayUniqueByKey(array, key) { const keys = new Set(); return array.filter((el) => !keys.has(el[key]) && keys.add(el[key])); } export function arrayUniqueByKeys(array, keysIn) { const keys = new Set(); const buildKey = (el) => { return keysIn.map((key) => el[key]).join(';'); }; return array.filter((el) => !keys.has(buildKey(el)) && keys.add(buildKey(el))); } // Generate output files export async function generateReports(resultSorted, columns, commandThis, options = { logFileName: null, logLabel: 'Generated report files:' }) { const logLabel = options.logLabel || 'Generated report files:'; let logFileName = options.logFileName || null; if (!logFileName) { logFileName = 'sfdx-hardis-' + commandThis.id.substr(commandThis.id.lastIndexOf(':') + 1); } const dateSuffix = new Date().toJSON().slice(0, 10); const reportDir = await getReportDirectory(); const reportFile = path.resolve(`${reportDir}/${logFileName}-${dateSuffix}.csv`); const reportFileExcel = path.resolve(`${reportDir}/${logFileName}-${dateSuffix}.xls`); await fs.ensureDir(path.dirname(reportFile)); const csv = csvStringify(resultSorted, { delimiter: ';', header: true, columns, }); await fs.writeFile(reportFile, csv, 'utf8'); // Trigger command to open CSV file in VsCode extension try { WebSocketClient.requestOpenFile(reportFile); } catch (e) { uxLog(commandThis, c.yellow(`[sfdx-hardis] Error opening file in VsCode: ${e.message}`)); } const excel = csvStringify(resultSorted, { delimiter: '\t', header: true, columns, }); await fs.writeFile(reportFileExcel, excel, 'utf8'); uxLog(commandThis, c.cyan(logLabel)); uxLog(commandThis, c.cyan(`- CSV: ${reportFile}`)); uxLog(commandThis, c.cyan(`- XLS: ${reportFileExcel}`)); return [ { type: 'csv', file: reportFile }, { type: 'xls', file: reportFileExcel }, ]; } export function uxLog(commandThis, text, sensitive = false) { text = text.includes('[sfdx-hardis]') ? text : '[sfdx-hardis]' + (text.startsWith('[') ? '' : ' ') + text; if (commandThis?.ux) { commandThis.ux.log(text); } else if (!(globalThis?.processArgv || process?.argv || "").includes('--json')) { console.log(text); } if (globalThis.hardisLogFileStream) { if (sensitive) { globalThis.hardisLogFileStream.write('OBFUSCATED LOG LINE\n'); } else { globalThis.hardisLogFileStream.write(stripAnsi(text) + '\n'); } } } export function bool2emoji(bool) { return bool ? "✅" : "⬜"; } // Caching methods const SFDX_LOCAL_FOLDER = '/root/.sfdx'; const TMP_COPY_FOLDER = '.cache/sfdx-hardis/.sfdx'; let RESTORED = false; // Put local sfdx folder in tmp/sfdx-hardis-local for CI tools needing cache/artifacts to be within repo dir export async function copyLocalSfdxInfo() { if (!isCI) { return; } if (fs.existsSync(SFDX_LOCAL_FOLDER)) { await fs.ensureDir(path.dirname(TMP_COPY_FOLDER)); await fs.copy(SFDX_LOCAL_FOLDER, TMP_COPY_FOLDER, { dereference: true, overwrite: true, }); // uxLog(this, `[cache] Copied SF CLI cache in ${TMP_COPY_FOLDER} for later reuse`); // const files = fs.readdirSync(TMP_COPY_FOLDER, {withFileTypes: true}).map(item => item.name); // uxLog(this, '[cache]' + JSON.stringify(files)); } } // Restore only once local Sfdx folder export async function restoreLocalSfdxInfo() { if (!isCI || RESTORED === true) { return; } if (fs.existsSync(TMP_COPY_FOLDER)) { await fs.copy(TMP_COPY_FOLDER, SFDX_LOCAL_FOLDER, { dereference: true, overwrite: false, }); // uxLog(this, '[cache] Restored cache for CI'); // const files = fs.readdirSync(SFDX_LOCAL_FOLDER, {withFileTypes: true}).map(item => item.name); // uxLog(this, '[cache]' + JSON.stringify(files)); RESTORED = true; } } // Generate SSL certificate in temporary folder and copy the key in project directory export async function generateSSLCertificate(branchName, folder, commandThis, conn, options) { uxLog(commandThis, 'Generating SSL certificate...'); const tmpDir = await createTempDir(); const prevDir = process.cwd(); process.chdir(tmpDir); const sslCommand = 'openssl req -nodes -newkey rsa:2048 -keyout server.key -out server.csr -subj "/C=GB/ST=Paris/L=Paris/O=Hardis Group/OU=sfdx-hardis/CN=hardis-group.com"'; await execCommand(sslCommand, this, { output: true, fail: true }); await execCommand('openssl x509 -req -sha256 -days 3650 -in server.csr -signkey server.key -out server.crt', this, { output: true, fail: true, }); process.chdir(prevDir); // Copy certificate key in local project await fs.ensureDir(folder); const targetKeyFile = path.join(folder, `${branchName}.key`); await fs.copy(path.join(tmpDir, 'server.key'), targetKeyFile); const encryptionKey = await encryptFile(targetKeyFile); // Copy certificate file in user home project const crtFile = path.join(os.homedir(), `${branchName}.crt`); await fs.copy(path.join(tmpDir, 'server.crt'), crtFile); // delete temporary cert folder await fs.remove(tmpDir); // Generate random consumer key for Connected app const consumerKey = crypto.randomBytes(256).toString('base64').substr(0, 119); // Ask user if he/she wants to create connected app const confirmResponse = await prompts({ type: 'confirm', name: 'value', initial: true, message: c.cyanBright("Do you want sfdx-hardis to configure the SFDX connected app on your org ? (say yes if you don't know)"), }); if (confirmResponse.value === true) { uxLog(commandThis, c.cyanBright(`You must configure CI variable ${c.green(c.bold(`SFDX_CLIENT_ID_${branchName.toUpperCase()}`))} with value ${c.bold(c.green(consumerKey))}`), true); uxLog(commandThis, c.cyanBright(`You must configure CI variable ${c.green(c.bold(`SFDX_CLIENT_KEY_${branchName.toUpperCase()}`))} with value ${c.bold(c.green(encryptionKey))}`), true); uxLog(commandThis, c.yellow(`Help to configure CI variables are here: ${CONSTANTS.DOC_URL_ROOT}/salesforce-ci-cd-setup-auth/`)); await prompts({ type: 'confirm', message: c.cyanBright('Hit ENTER when the CI/CD variables are set (check info in the console below)'), }); // Request info for deployment const promptResponses = await prompts([ { type: 'text', name: 'appName', initial: 'sfdxhardis' + Math.floor(Math.random() * 9) + 1, message: c.cyanBright('How would you like to name the Connected App (ex: sfdx_hardis) ?'), }, ]); const contactEmail = await promptUserEmail('Enter a contact email for the Connect App (ex: nicolas.vuillamy@cloudity.com)'); const profile = await promptProfiles(conn, { multiselect: false, message: 'What profile will be used for the connected app ? (ex: System Administrator)', initialSelection: ['System Administrator', 'Administrateur Système'], }); const crtContent = await fs.readFile(crtFile, 'utf8'); // Build ConnectedApp metadata const connectedAppMetadata = `<?xml version="1.0" encoding="UTF-8"?> <ConnectedApp xmlns="http://soap.sforce.com/2006/04/metadata"> <contactEmail>${contactEmail}</contactEmail> <label>${promptResponses.appName.replace(/\s/g, '_') || 'sfdx-hardis'}</label> <oauthConfig> <callbackUrl>http://localhost:1717/OauthRedirect</callbackUrl> <certificate>${crtContent}</certificate> <consumerKey>${consumerKey}</consumerKey> <isAdminApproved>true</isAdminApproved> <isConsumerSecretOptional>false</isConsumerSecretOptional> <isIntrospectAllTokens>false</isIntrospectAllTokens> <isSecretRequiredForRefreshToken>false</isSecretRequiredForRefreshToken> <scopes>Api</scopes> <scopes>Web</scopes> <scopes>RefreshToken</scopes> </oauthConfig> <oauthPolicy> <ipRelaxation>ENFORCE</ipRelaxation> <refreshTokenPolicy>specific_lifetime:3:HOURS</refreshTokenPolicy> </oauthPolicy> <profileName>${profile || 'System Administrator'}</profileName> </ConnectedApp> `; const packageXml = `<?xml version="1.0" encoding="UTF-8"?> <Package xmlns="http://soap.sforce.com/2006/04/metadata"> <types> <members>${promptResponses.appName}</members> <name>ConnectedApp</name> </types> <version>${getApiVersion()}</version> </Package> `; // create metadata folder const tmpDirMd = await createTempDir(); const connectedAppDir = path.join(tmpDirMd, 'connectedApps'); await fs.ensureDir(connectedAppDir); await fs.writeFile(path.join(tmpDirMd, 'package.xml'), packageXml); await fs.writeFile(path.join(connectedAppDir, `${promptResponses.appName}.connectedApp`), connectedAppMetadata); // Deploy metadatas try { uxLog(commandThis, c.cyan(`Deploying Connected App ${c.bold(promptResponses.appName)} into target org ${options.targetUsername || ''} ...`)); uxLog(commandThis, c.yellow(`If you have an upload error, PLEASE READ THE MESSAGE AFTER, that will explain how to manually create the connected app, and don't forget the CERTIFICATE file :)`)); const isProduction = await isProductionOrg(options.targetUsername || null, { conn: conn }); const deployRes = await deployMetadatas({ deployDir: tmpDirMd, testlevel: isProduction ? 'RunLocalTests' : 'NoTestRun', targetUsername: options.targetUsername ? options.targetUsername : null, }); console.assert(deployRes.status === 0, c.red('[sfdx-hardis] Failed to deploy metadatas')); uxLog(commandThis, c.cyan(`Successfully deployed ${c.green(promptResponses.appName)} Connected App`)); await fs.remove(tmpDirMd); await fs.remove(crtFile); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { uxLog(commandThis, c.red('Error pushing ConnectedApp metadata. Maybe the app name is already taken ?\nYou may try again with another connected app name')); uxLog(commandThis, c.yellow(` ${c.bold('MANUAL INSTRUCTIONS')} If this is a Test class issue (production env), you may have to create manually connected app ${promptResponses.appName}: - Follow instructions here: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_connected_app.htm - Use certificate ${c.bold(crtFile)} in "Use Digital Signature section" (delete the file from your computer after !) - Once created, update CI/CD variable ${c.green(c.bold(`SFDX_CLIENT_ID_${branchName.toUpperCase()}`))} with the ConsumerKey of the newly created connected app`)); await prompts({ type: 'confirm', message: c.cyanBright('You need to manually configure the connected app. Follow the MANUAL INSTRUCTIONS in the console, then continue here'), }); } } else { // Tell infos to install manually uxLog(commandThis, c.yellow('Now you can configure the SF CLI connected app')); uxLog(commandThis, `Follow instructions here: ${c.bold('https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_connected_app.htm')}`); uxLog(commandThis, `Use ${c.green(crtFile)} as certificate on Connected App configuration page, ${c.bold(`then delete ${crtFile} for security`)}`); uxLog(commandThis, `- configure CI variable ${c.green(`SFDX_CLIENT_ID_${branchName.toUpperCase()}`)} with value of ConsumerKey on Connected App configuration page`); uxLog(commandThis, `- configure CI variable ${c.green(`SFDX_CLIENT_KEY_${branchName.toUpperCase()}`)} with value ${c.green(encryptionKey)} key`); } } export async function isMonitoringJob() { if (process.env.SFDX_HARDIS_MONITORING === 'true') { return true; } if (!isCI) { return false; } const repoName = await git().revparse('--show-toplevel'); if (isCI && repoName.includes('monitoring')) { return true; } return false; } export function getNested(nestedObj, pathArr) { return pathArr.reduce((obj, key) => (obj && obj[key] !== 'undefined' ? obj[key] : undefined), nestedObj); } const ansiPattern = [ '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', ].join('|'