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

549 lines • 25.4 kB
import { requiredOrgFlagWithDeprecations, SfCommand, } from "@salesforce/sf-plugins-core"; import { Flags } from "@salesforce/sf-plugins-core"; import { SfError, Messages } from "@salesforce/core"; import { soqlQuery, soqlQueryTooling, } from "../../../../common/utils/apiUtils.js"; import { execCommand, uxLog } from "../../../../common/utils/index.js"; import { prompts } from "../../../../common/utils/prompts.js"; import c from "chalk"; import path from "path"; import fs from "fs"; import * as fsExtra from "fs-extra"; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages("sfdx-hardis", "org"); import { parseXmlFile, writeXmlFile, } from "../../../../common/utils/xmlUtils.js"; import { MetadataUtils } from "../../../../common/metadata-utils/index.js"; // Constants const ALLOWED_AUTOMATIONS = ["Flow", "Trigger", "VR"]; const CREDITS_TEXT = "by sfdx-hardis : https://sfdx-hardis.cloudity.com/hardis/project/generate/bypass/"; const STATUS = { ADDED: "added", SKIPPED: "skipped", IGNORED: "ignored", FAILED: "failed", }; export default class HardisProjectGenerateBypass extends SfCommand { skipCredits = false; retrieveFromOrg; static flags = { "target-org": requiredOrgFlagWithDeprecations, objects: Flags.string({ aliases: ["sObjects"], char: "s", description: "Comma-separated list of sObjects to bypass (e.g., Account,Contact,Opportunity). If omitted, you will be prompted to select.", required: false, }), automations: Flags.string({ char: "a", description: `Comma-separated automations to bypass: ${ALLOWED_AUTOMATIONS.join(", ")}`, required: false, }), websocket: Flags.string({ description: messages.getMessage("websocket"), }), skipauth: Flags.boolean({ description: "Skip authentication check when a default username is required", }), "skip-credits": Flags.boolean({ aliases: ["skipCredits"], char: "k", description: 'Omit the "Generated by" line in the XML files', required: false, default: false, }), "apply-to-vrs": Flags.boolean({ aliases: ["applyToVrs"], description: "Apply bypass to Validation Rules", required: false, default: false, }), "apply-to-triggers": Flags.boolean({ aliases: ["applyToTriggers"], description: "Apply bypass to Triggers", required: false, default: false, }), "metadata-source": Flags.string({ char: "r", aliases: ["metadataSource"], description: "Source of metadata elements to apply bypass to. Options: 'org' or 'local'.", required: false, }), }; static description = ` Generates bypass custom permissions and permission sets for specified sObjects and automations (Flows, Triggers, and Validation Rules). If no parameters are provided, it prompts for user selection. `; static examples = [ "$ sf hardis:project:generate:bypass", "$ sf hardis:project:generate:bypass --sObjects Account,Contact,Opportunity", "$ sf hardis:project:generate:bypass --automations Flow,Trigger,VR", "$ sf hardis:project:generate:bypass --sObjects Account,Opportunity --automations Flow,Trigger", "$ sf hardis:project:generate:bypass --skipCredits", "$ sf hardis:project:generate:bypass --apply-to-vrs", "$ sf hardis:project:generate:bypass --apply-to-triggers", "$ sf hardis:project:generate:bypass --metadata-source org", ]; // Main run method async run() { // Collect options const { flags } = await this.parse(HardisProjectGenerateBypass); const connection = flags["target-org"].getConnection(); if (flags["metadata-source"] !== undefined && flags["metadata-source"] !== null) { this.retrieveFromOrg = String(flags["metadata-source"]).trim().toLowerCase() === "org"; } this.skipCredits = flags["skip-credits"] || false; let applyToTriggers = flags["apply-to-triggers"] || null; let applyToVrs = flags["apply-to-vrs"] || null; const sObjects = flags.objects || null; const automations = flags.automations || null; const availableSObjects = await this.getFilteredSObjects(connection); let targetSObjects = {}; let targetAutomations = []; // Filter objects if (sObjects) { const sObjectsFromFlag = flags.sObjects.split(",").map((s) => s.trim()); targetSObjects = Object.fromEntries(Object.entries(availableSObjects).filter(([key]) => { const res = sObjectsFromFlag.includes(key); if (!res) { uxLog(this, c.yellow(`Warning: sObject "${key}" is not available or not customizable. Skipping.`)); } return res; })); } if (automations) { targetAutomations = automations .split(",") .map((s) => s.trim()) .filter((s) => ALLOWED_AUTOMATIONS.includes(s)); } // Generate global bypasses this.generateFiles({ All: "All" }, ALLOWED_AUTOMATIONS); // Handle prompts if needed const promptsNeeded = []; if (!Object.keys(targetSObjects).length) { promptsNeeded.push({ type: "multiselect", name: "sobjects", message: "Select sObjects for bypass", choices: Object.entries(availableSObjects).map(([devName, label]) => ({ title: label, value: devName, })), }); } if (!targetAutomations.length) { promptsNeeded.push({ type: "multiselect", name: "automations", message: "Select automations to bypass", choices: ALLOWED_AUTOMATIONS.map((a) => ({ title: a, value: a })), }); } if (applyToVrs == null && applyToTriggers == null) { promptsNeeded.push({ type: "multiselect", name: "applyTo", message: "To which automations do you want to automatically apply the bypass?", choices: [ { title: "Validation Rules", value: "applyToVrs" }, { title: "Triggers", value: "applyToTriggers" }, ], }); } if (this.retrieveFromOrg == undefined || this.retrieveFromOrg == null) { promptsNeeded.push({ type: "select", name: "elementSource", message: "Where do you want to get the elements to apply bypass to?", choices: [ { title: "Retrieve from org (recommended)", value: "org" }, { title: "Use local elements in the project", value: "local" }, ], }); } if (promptsNeeded.length) { const promptResults = await prompts(promptsNeeded); if (promptResults.sobjects) { targetSObjects = Object.fromEntries(Object.entries(availableSObjects).filter(([key]) => promptResults.sobjects.includes(key))); } if (promptResults.automations) { targetAutomations = promptResults.automations; } if (!applyToTriggers) { applyToTriggers = promptResults.applyTo?.includes("applyToTriggers"); } if (!applyToVrs) { applyToVrs = promptResults.applyTo?.includes("applyToVrs"); } if (promptResults.elementSource) { this.retrieveFromOrg = promptResults.elementSource === "org"; } } // Validate selections if (!Object.keys(targetSObjects).length) { throw new SfError(c.red("ERROR: You must select at least one sObject.")); } if (!targetAutomations.length) { throw new SfError(c.red("ERROR: You must select at least one automation type.")); } // Generate files and apply bypasses this.generateFiles(targetSObjects, targetAutomations); if (applyToVrs) { await this.applyBypassToValidationRules(connection, targetSObjects); } if (applyToTriggers) { await this.applyBypassToTriggers(connection, targetSObjects); } return { outputString: "Generated bypass custom permissions and permission sets", }; } // Query methods async querySObjects(connection) { const sObjectsQuery = ` Select Id, Label, DeveloperName, QualifiedApiName, DurableId, IsTriggerable, IsCustomizable, IsApexTriggerable FROM EntityDefinition WHERE IsTriggerable = true AND IsCustomizable = true and IsCustomSetting = false ORDER BY DeveloperName`; const results = await soqlQuery(sObjectsQuery, connection); uxLog(this, `Found ${results.records.length} sObjects.`); return results; } async getFilteredSObjects(connection) { const sObjectResults = await this.querySObjects(connection); const sObjectsDict = {}; for (const record of sObjectResults.records) { if (!record.DeveloperName.endsWith("__Share") && !record.DeveloperName.endsWith("__ChangeEvent")) { sObjectsDict[record.DeveloperName] = `${record.Label} (${record.QualifiedApiName})`; } } return sObjectsDict; } async queryTriggers(connection) { const query = `SELECT Id, Name, Status, IsValid, Body, BodyCrc, TableEnumOrId, ManageableState From ApexTrigger WHERE ManageableState != 'installed'`; const results = await soqlQueryTooling(query, connection); uxLog(this, `Found ${results.records.length} Triggers.`); return results; } filterTriggerResults(triggerResults, sObjects) { return triggerResults.records.filter((trigger) => { const sObjectApiNameWithoutC = trigger.TableEnumOrId?.replace("__c", ""); return (sObjectApiNameWithoutC && Object.keys(sObjects).includes(sObjectApiNameWithoutC) && trigger.Body != "(hidden)"); }); } async queryValidationRules(connection, sObjects) { const query = `SELECT ValidationName, EntityDefinition.QualifiedApiName, ManageableState FROM ValidationRule WHERE ManageableState != 'installed' AND EntityDefinition.DeveloperName IN (${Object.keys(sObjects) .map((s) => `'${s}'`) .join(", ")})`; const results = await soqlQueryTooling(query, connection); uxLog(this, `Found ${results.records.length} Validation Rules.`); return results; } // XML Generation generateXML(type, sObject, automation) { const creditsText = this.skipCredits ? "" : `Generated ${CREDITS_TEXT}`; if (type === "customPermission") { return `<?xml version="1.0" encoding="UTF-8"?> <CustomPermission xmlns="http://soap.sforce.com/2006/04/metadata"> <isLicensed>false</isLicensed> <label>Bypass ${automation}s for ${sObject}</label> <description>If assigned (through a Permission Set), this Custom Permission will disable the execution of ${automation}s defined on the ${sObject} sObject.${creditsText}</description> </CustomPermission>`; } else { return `<?xml version="1.0" encoding="UTF-8"?> <PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata"> <customPermissions> <enabled>true</enabled> <name>Bypass${sObject}${automation}s</name> </customPermissions> <hasActivationRequired>false</hasActivationRequired> <label>Bypass ${automation}s for ${sObject}</label> <description>If assigned, this Permission Set will disable the execution of ${automation}s defined on the ${sObject} sObject.${creditsText}</description> </PermissionSet>`; } } generateXMLFiles(sObject, automation) { const customPermissionFile = path.join(`force-app/main/default/customPermissions/Bypass${sObject}${automation}s.customPermission-meta.xml`); const permissionSetFile = path.join(`force-app/main/default/permissionsets/Bypass${sObject}${automation}s.permissionset-meta.xml`); fsExtra.ensureDirSync(path.dirname(customPermissionFile)); fs.writeFileSync(customPermissionFile, this.generateXML("customPermission", sObject, automation), "utf-8"); fsExtra.ensureDirSync(path.dirname(permissionSetFile)); fs.writeFileSync(permissionSetFile, this.generateXML("permissionSet", sObject, automation), "utf-8"); uxLog(this, `Created: ${path.basename(customPermissionFile)} for ${sObject}`); uxLog(this, `Created: ${path.basename(permissionSetFile)} for ${sObject}`); } generateFiles(targetSObjects, targetAutomations) { Object.keys(targetSObjects).forEach((developerName) => { targetAutomations.forEach((automation) => { this.generateXMLFiles(developerName, automation); }); }); } // Metadata handling async retrieveMetadataFiles(records, metadataType) { const recordsChunks = this.chunkArray(records); const results = []; for (const chunk of recordsChunks) { let command = `sf project retrieve start --metadata`; command += chunk .map((record) => { return metadataType === "ValidationRule" ? ` ValidationRule:${record.EntityDefinition.QualifiedApiName}.${record.ValidationName}` : ` ApexTrigger:${record.Name}`; }) .join(" "); try { const result = await execCommand(`${command} --ignore-conflicts --json`, this, { debug: false, retry: { retryDelay: 30, retryStringConstraint: "error", retryMaxAttempts: 3, }, }); results.push(result); } catch (error) { uxLog(this, c.red(`Error retrieving ${metadataType}: ${error}`)); } } return results; } chunkArray(array, chunkSize = 25) { return Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) => array.slice(i * chunkSize, (i + 1) * chunkSize)); } // Validation Rules async handleValidationRuleFile(filePath, sObject, name) { try { const fileContent = await parseXmlFile(filePath); if (!fileContent?.ValidationRule?.errorConditionFormula?.[0] || typeof fileContent.ValidationRule.errorConditionFormula[0] !== "string") { return { sObject, name, action: STATUS.FAILED, comment: "Invalid validation rule format or missing error condition formula", }; } const validationRuleContent = fileContent.ValidationRule.errorConditionFormula[0]; const bypassPermissionName = `$Permission.Bypass${sObject}VRs`; if (typeof validationRuleContent === "string" && validationRuleContent.includes(bypassPermissionName)) { return { sObject, name, action: STATUS.IGNORED, comment: "SFDX-Hardis Bypass already implemented", }; } if (typeof validationRuleContent === "string" && /bypass/i.test(validationRuleContent)) { return { sObject, name, action: STATUS.SKIPPED, comment: "Another bypass mechanism exists", }; } const creditsText = this.skipCredits ? "" : `/* Updated ${CREDITS_TEXT} */ `; fileContent.ValidationRule.errorConditionFormula[0] = `${creditsText} AND( AND(NOT(${bypassPermissionName}), NOT($Permission.BypassAllVRs)), ${validationRuleContent})`; await writeXmlFile(filePath, fileContent); return { sObject, name, action: STATUS.ADDED, comment: "SFDX-Hardis Bypass implemented", }; } catch (error) { return { sObject, name, action: STATUS.FAILED, comment: `Error processing file : ${error}`, }; } } async applyBypassToValidationRules(connection, sObjects) { const validationRuleRecords = await this.queryValidationRules(connection, sObjects); if (!validationRuleRecords || validationRuleRecords.records.length === 0) { uxLog(this, "No validation rules found for the specified sObjects."); return; } uxLog(this, `Processing ${validationRuleRecords.records.length} Validation Rules.`); const validationRulesTableReport = []; const eligibleMetadataFilePaths = []; if (this.retrieveFromOrg) { const retrievedValidationRulesChunks = await this.retrieveMetadataFiles(validationRuleRecords.records, "ValidationRule"); for (const retrievedValidationRules of retrievedValidationRulesChunks) { if (retrievedValidationRules?.status !== 1 && retrievedValidationRules?.result?.files && Array.isArray(retrievedValidationRules.result.files) && retrievedValidationRules.result.files.length > 0) { for (const metadataFile of retrievedValidationRules.result.files) { if (metadataFile?.type !== "ValidationRule" || metadataFile?.problemType === "Error") { continue; } const [sObject, name] = metadataFile.fullName.split("."); const filePath = metadataFile.filePath; eligibleMetadataFilePaths.push({ filePath, sObject, name }); } } else { uxLog(this, "No Validation Rule files found in the retrieved metadata chunk."); } } } else { if (validationRuleRecords?.records) { for (const record of validationRuleRecords.records) { const sObject = record.EntityDefinition.QualifiedApiName; const name = record.ValidationName; const filePath = await MetadataUtils.findMetaFileFromTypeAndName("ValidationRule", name); if (filePath === null) { // TODO: add to report instead of log uxLog(this, `The validation rule ${name} for sObject ${sObject} does not have a corresponding metadata file locally. Skipping.`); } else { eligibleMetadataFilePaths.push({ filePath, sObject, name }); } } } } for (const eligibleMetadataFilePath of eligibleMetadataFilePaths) { validationRulesTableReport.push(await this.handleValidationRuleFile(eligibleMetadataFilePath.filePath, eligibleMetadataFilePath.sObject, eligibleMetadataFilePath.name)); } console.table(validationRulesTableReport); } // Triggers async handleTriggerFile(filePath, name) { try { if (!fs.existsSync(filePath)) { return { sObject: null, name, action: STATUS.FAILED, comment: "File not found", }; } const fileContent = fs.readFileSync(filePath, "utf-8"); if (typeof fileContent !== "string") { return { sObject: null, name, action: STATUS.FAILED, comment: "Invalid file content format", }; } const match = fileContent.match(/trigger\s+\w+\s+on\s+(\w+)\s*\([^)]*\)\s*{\s*/i); if (!match) { return { sObject: null, name, action: STATUS.FAILED, comment: "Unable to detect sObject", }; } const sObject = match[1].replace(/__c$/, ""); const bypassCheckLine = `if(FeatureManagement.checkPermission('Bypass${sObject}Triggers') || FeatureManagement.checkPermission('BypassAllTriggers')) { return; }`; if (fileContent.includes(bypassCheckLine)) { return { sObject, name, action: STATUS.IGNORED, comment: "Bypass already implemented", }; } if (/bypass|PAD\.can/i.test(fileContent)) { return { sObject, name, action: STATUS.SKIPPED, comment: "Another bypass exists", }; } const fullBypassLine = `${bypassCheckLine}${this.skipCredits ? "" : "// Updated " + CREDITS_TEXT}`; const openBraceIndex = fileContent.indexOf("{"); const beforeBrace = fileContent.substring(0, openBraceIndex + 1); const afterBrace = fileContent.substring(openBraceIndex + 1).trimStart(); fsExtra.ensureDirSync(path.dirname(filePath)); fs.writeFileSync(filePath, `${beforeBrace}\n\t${fullBypassLine}\n\t${afterBrace}`, "utf-8"); return { sObject, name, action: STATUS.ADDED, comment: "Bypass implemented", }; } catch (error) { return { sObject: null, name, action: STATUS.FAILED, comment: `Error processing file : ${error}`, }; } } async applyBypassToTriggers(connection, sObjects) { const triggerResults = await this.queryTriggers(connection); const filteredTriggersResults = this.filterTriggerResults(triggerResults, sObjects); if (!filteredTriggersResults || filteredTriggersResults?.length === 0) { uxLog(this, "No triggers found for the specified sObjects."); return; } const triggerReport = []; const eligibleMetadataFilePaths = []; if (this.retrieveFromOrg) { const retrievedTriggersChunks = await this.retrieveMetadataFiles(filteredTriggersResults, "ApexTrigger"); for (const retrievedTriggers of retrievedTriggersChunks) { if (retrievedTriggers?.status !== 1 && retrievedTriggers?.result?.files && Array.isArray(retrievedTriggers.result.files) && retrievedTriggers.result.files.length > 0) { for (const metadataFile of retrievedTriggers.result.files) { if (metadataFile?.type !== "ApexTrigger" || !metadataFile?.filePath?.endsWith(".trigger") || metadataFile?.problemType === "Error") { continue; } const name = metadataFile.fullName; const filePath = metadataFile.filePath; eligibleMetadataFilePaths.push({ filePath, name }); } } else { uxLog(this, "No Trigger files found in the retrieved metadata chunk."); } } } else { if (filteredTriggersResults) { for (const record of filteredTriggersResults) { const name = record.Name; const filePath = await MetadataUtils.findMetaFileFromTypeAndName("ApexTrigger", name); if (filePath === null) { // TODO: add to report instead of log uxLog(this, `The trigger ${name} does not have a corresponding metadata file locally. Skipping.`); } else { eligibleMetadataFilePaths.push({ filePath, name }); } } } } for (const eligibleMetadataFilePath of eligibleMetadataFilePaths) { triggerReport.push(await this.handleTriggerFile(eligibleMetadataFilePath.filePath, eligibleMetadataFilePath.name)); } console.table(triggerReport); } } //# sourceMappingURL=bypass.js.map