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

925 lines (917 loc) • 46.2 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, uxLogTable } 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"; import { generateCsvFile, generateReportPath } from "../../../../common/utils/filesUtils.js"; import { WebSocketClient } from "../../../../common/websocketClient.js"; import { listOrgSObjectsFiltered } from "../../../../common/utils/orgUtils.js"; // Constants const ALLOWED_AUTOMATIONS = ["Flow", "Trigger", "VR"]; // TODO: type and remove hardcoded const CREDITS_TEXT = "by sfdx-hardis : https://sfdx-hardis.cloudity.com/hardis/project/generate/bypass/"; const IMPLEMENTATION_OUTCOME = { ADDED: "added", SKIPPED: "skipped", IGNORED: "ignored", FAILED: "failed", }; const METADATA_GENERATION_OUTCOME = { GENERATED: "generated", OVERRIDDEN: "overridden", FAILED: "failed", }; export default class HardisProjectGenerateBypass extends SfCommand { skipCredits = false; retrieveFromOrg; outputFile; reports = { metadataGeneration: [], implementation: [], }; static flags = { "target-org": requiredOrgFlagWithDeprecations, outputfile: Flags.string({ char: 'f', description: 'Force the path and name of output report file. Must end with .csv', }), // TODO: detect sObjects from folder and use them instead of asking the user 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, }), "apply-to-flows": Flags.boolean({ aliases: ["applyToFlows"], description: "Apply bypass to Flows", 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 = ` ## Command Behavior **Generates custom permissions and permission sets to bypass specified Salesforce automations (Flows, Triggers, and Validation Rules) for specific sObjects, with optional automatic implementation of bypass logic.** This command provides a controlled mechanism to temporarily or permanently disable automations for certain sObjects, which is invaluable for: - **Data Loading:** Bypassing validation rules or triggers during large data imports. - **Troubleshooting:** Isolating automation issues by temporarily disabling them. - **Development:** Allowing developers to work on specific sObjects without triggering complex automations. Key functionalities: - **Global Bypass Generation:** Automatically creates global bypass permissions (\`BypassAllFlows\`, \`BypassAllTriggers\`, \`BypassAllVRs\`) that work across all sObjects. - **sObject Selection:** Specify a comma-separated list of sObjects via \`--objects\` flag (e.g., \`Account,Contact\`), or use interactive prompts to select from available triggerable and customizable sObjects. - **Automation Type Selection:** Choose which automation types to bypass via \`--automations\` flag: \`Flow\`, \`Trigger\`, or \`VR\` (Validation Rules), or select interactively. - **Automatic Bypass Implementation:** Optionally inject bypass logic directly into automation metadata using: - \`--apply-to-flows\`: Adds a decision node at the start of record-triggered flows to check bypass permissions. - \`--apply-to-triggers\`: Inserts a bypass check at the beginning of Apex trigger bodies using \`FeatureManagement.checkPermission()\`. - \`--apply-to-vrs\`: Wraps validation rule error conditions with bypass permission checks. - **Metadata Source Control:** Choose where to retrieve automation metadata from via \`--metadata-source\`: - \`org\`: Retrieves the latest metadata from the connected org (recommended for accuracy). - \`local\`: Uses local metadata files from the project (faster but may be outdated). - **Metadata Generation:** For each selected sObject and automation type, generates: - **Custom Permission** (e.g., \`BypassAccountFlows\`) - The bypass switch that can be assigned via Permission Sets. - **Permission Set** (e.g., \`BypassAccountFlows\`) - Grants the corresponding Custom Permission to users. - **Comprehensive Reporting:** Generates two detailed CSV reports: - **Metadata Generation Report:** Lists all custom permissions and permission sets created. - **Implementation Report:** Shows which automations had bypass logic added, skipped, ignored, or failed. - **Credits Control:** Use \`--skip-credits\` to omit the "Generated by sfdx-hardis" attribution in XML files. <details markdown="1"> <summary>Technical explanations</summary> The command's technical implementation involves: - **SOQL Queries:** - Queries \`EntityDefinition\` to list all triggerable and customizable sObjects. - Queries \`ValidationRule\` (Tooling API) to find validation rules for selected sObjects. - Queries \`ApexTrigger\` (Tooling API) to find triggers related to selected sObjects. - Queries \`FlowDefinitionView\` to find record-triggered flows for selected sObjects. - **Interactive Prompts:** Uses the \`prompts\` library for user-friendly selection of sObjects, automation types, and implementation options when flags are not provided. - **XML Generation:** Dynamically generates Custom Permission and Permission Set XML files with descriptive labels and comments indicating their purpose. - **File System Operations:** Uses \`fs-extra\` to create directory structures and write metadata files to \`force-app/main/default/customPermissions/\` and \`force-app/main/default/permissionsets/\`. - **Metadata Retrieval:** When \`--metadata-source org\` is used, executes \`sf project retrieve start --metadata\` commands in chunks of 25 records to retrieve current automation metadata from the org. - **Smart Bypass Implementation:** - **Validation Rules:** Modifies \`errorConditionFormula\` XML nodes to wrap existing formulas with \`AND(NOT($Permission.Bypass...), ...)\` checks. - **Triggers:** Injects \`if(FeatureManagement.checkPermission('Bypass...')) { return; }\` at the start of trigger bodies. - **Flows:** Adds a decision node named \`SFDX_HARDIS_FLOW_BYPASS_DO_NOT_RENAME\` as the first node, checking both specific and global bypass permissions. - **Duplicate Detection:** Skips implementation if bypass logic is already present, preventing duplicate additions. - **Error Handling:** - Validates sObject and automation selections. - Handles missing files gracefully when using local metadata source. - Reports errors for each automation that fails processing. - Continues processing remaining items even when individual items fail (unless critical errors occur). - **Reporting:** Generates timestamped CSV reports showing outcomes for both metadata generation and bypass implementation operations. </details> `; 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; let applyToFlows = flags["apply-to-flows"] || null; const sObjects = flags.objects || null; const automations = flags.automations || null; this.outputFile = flags.outputfile || null; const availableSObjects = await listOrgSObjectsFiltered(connection); let targetSObjects = {}; let targetAutomations = []; // Filter objects if (sObjects) { const sObjectsFromFlag = sObjects.split(",").map((s) => s.trim()); targetSObjects = Object.fromEntries(Object.entries(availableSObjects).filter(([key]) => { const res = sObjectsFromFlag.includes(key); if (!res) { uxLog("warning", 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 uxLog("action", this, c.cyan(`Generating 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", description: "Choose which sObjects should have automation bypass functionality", choices: Object.entries(availableSObjects).map(([devName, label]) => ({ title: label, value: devName, })), }); } if (!targetAutomations.length) { promptsNeeded.push({ type: "multiselect", name: "automations", message: "Select which automations to bypass", description: "This will generate bypass custom permissions and permission sets for the selected automation types and sObjects", choices: [ { title: "Flows", value: "Flow" }, { title: "Triggers", value: "Trigger" }, { title: "Validation Rules", value: "VR" }, ], }); } if (applyToVrs == null && applyToTriggers == null && applyToFlows == null) { promptsNeeded.push({ type: "multiselect", name: "applyTo", message: "Where do you wish to have the bypass applied?", description: "Choose which automation types should have the bypass logic applied automatically. The metadata files will be modified accordingly.", choices: [ { title: "Flows (as a decision node)", value: "applyToFlows" }, { title: "Triggers (within the .trigger file)", value: "applyToTriggers" }, { title: "Validation Rules (encapsulating the existing validation logic)", value: "applyToVrs" }, ], }); } 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?", description: "Choose the source for retrieving automation elements", placeholder: "Select source", 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 (!applyToFlows) { applyToFlows = promptResults.applyTo?.includes("applyToFlows"); } 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) { uxLog("action", this, c.cyan(`Implementing the bypass logic to Validation Rules...`)); await this.applyBypassToValidationRules(connection, targetSObjects); } if (applyToTriggers) { uxLog("action", this, c.cyan(`Implementing the bypass logic to Triggers...`)); await this.applyBypassToTriggers(connection, targetSObjects); } if (applyToFlows) { uxLog("action", this, c.cyan(`Implementing the bypass logic to Flows...`)); await this.applyBypassToFlows(connection, targetSObjects); } uxLog("action", this, c.cyan(`Bypass generation and implementation is completed.`)); if (applyToVrs || applyToTriggers || applyToFlows) { uxLog("action", this, c.cyan(`Bypass implementation report:`)); uxLogTable(this, this.reports.implementation); } uxLog("action", this, c.cyan(`Generating report files...`)); await this.generateReports(); return { outputString: "Bypass generation and implementation completed.", }; } async generateReports() { const baseFilePath = await generateReportPath('project-generate-bypass-<REPLACEME>', this.outputFile, { withDate: true, withBranchName: false }); let metadataGenerationReportFilePath = baseFilePath; let implementationReportFilePath = baseFilePath; if (baseFilePath.includes('<REPLACEME>')) { metadataGenerationReportFilePath = baseFilePath.replace('<REPLACEME>', 'generation'); implementationReportFilePath = baseFilePath.replace('<REPLACEME>', 'implementation'); } else { metadataGenerationReportFilePath = baseFilePath.replace('.csv', '-generation.csv'); implementationReportFilePath = baseFilePath.replace('.csv', '-implementation.csv'); } await generateCsvFile(this.reports.metadataGeneration, metadataGenerationReportFilePath, { fileTitle: 'Bypass Metadata Generation Report' }); await generateCsvFile(this.reports.implementation, implementationReportFilePath, { fileTitle: 'Bypass Implementation Report' }); } 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("log", this, c.grey(`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("log", this, c.grey(`Found ${results.records.length} Validation Rules.`)); return results; } async queryFlows(connection) { const query = `SELECT Id, ApiName, Label, TriggerObjectOrEvent.QualifiedApiName FROM FlowDefinitionView WHERE ManageableState = 'unmanaged'`; const results = await soqlQuery(query, connection); uxLog("log", this, c.grey(`Found ${results.records.length} Flows.`)); return results; } filterFlowResults(flowResults, sObjects) { return flowResults.records.filter((flow) => { const triggerObject = flow.TriggerObjectOrEvent?.QualifiedApiName; return triggerObject && Object.keys(sObjects).includes(triggerObject.replace("__c", "")); }); } // 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) { // TODO: use current folder path from sf project const customPermissionFilePath = path.join(`force-app/main/default/customPermissions/Bypass${sObject}${automation}s.customPermission-meta.xml`); const permissionSetFilePath = path.join(`force-app/main/default/permissionsets/Bypass${sObject}${automation}s.permissionset-meta.xml`); const baseReportItem = { sObject, automation, outcome: METADATA_GENERATION_OUTCOME.FAILED, }; try { fsExtra.ensureDirSync(path.dirname(customPermissionFilePath)); fs.writeFileSync(customPermissionFilePath, this.generateXML("customPermission", sObject, automation), "utf-8"); fsExtra.ensureDirSync(path.dirname(permissionSetFilePath)); fs.writeFileSync(permissionSetFilePath, this.generateXML("permissionSet", sObject, automation), "utf-8"); const createdMessages = [ `Created: ${path.basename(customPermissionFilePath)} for ${sObject}`, `Created: ${path.basename(permissionSetFilePath)} for ${sObject}`, ]; // In VS Code UI, prefer sending details through the websocket so they appear within the progress widget. if (WebSocketClient.isAliveWithLwcUI()) { for (const message of createdMessages) { WebSocketClient.sendCommandLogLineMessage(message, 'log'); } } else { for (const message of createdMessages) { uxLog("log", this, c.grey(message)); } } baseReportItem.outcome = METADATA_GENERATION_OUTCOME.GENERATED; baseReportItem.customPermissionFilePath = customPermissionFilePath; baseReportItem.permissionSetFilePath = permissionSetFilePath; this.reports.metadataGeneration.push(baseReportItem); } catch (error) { uxLog("error", this, c.red(`Error generating XML files for ${sObject} and ${automation}: ${error}`)); this.reports.metadataGeneration.push(baseReportItem); } } generateFiles(targetSObjects, targetAutomations) { let counter = 0; const totalSteps = Object.keys(targetSObjects).length * targetAutomations.length; WebSocketClient.sendProgressStartMessage("Generating bypass metadata files...", totalSteps); for (const developerName of Object.keys(targetSObjects)) { counter++; WebSocketClient.sendProgressStepMessage(counter, totalSteps); for (const automation of targetAutomations) { this.generateXMLFiles(developerName, automation); counter++; WebSocketClient.sendProgressStepMessage(counter, totalSteps); } } WebSocketClient.sendProgressEndMessage(totalSteps); } // 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) => { if (metadataType === "Flow") { return ` Flow:${record.ApiName}`; } else if (metadataType === "ValidationRule") { return ` ValidationRule:${record.EntityDefinition.QualifiedApiName}.${record.ValidationName}`; } else { return ` 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("error", 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, automation: "ValidationRule", name, outcome: IMPLEMENTATION_OUTCOME.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) || validationRuleContent.includes('BypassAllVRs'))) { return { sObject, automation: "ValidationRule", name, outcome: IMPLEMENTATION_OUTCOME.IGNORED, comment: "SFDX-Hardis Bypass already implemented", }; } if (typeof validationRuleContent === "string" && /bypass/i.test(validationRuleContent)) { return { sObject, automation: "ValidationRule", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Another bypass mechanism exists", }; } fileContent.ValidationRule.errorConditionFormula[0] = `AND( AND(NOT(${bypassPermissionName}), NOT($Permission.BypassAllVRs)), ${validationRuleContent})`; await writeXmlFile(filePath, fileContent); return { sObject, automation: "ValidationRule", name, outcome: IMPLEMENTATION_OUTCOME.ADDED, comment: "SFDX-Hardis Bypass implemented", }; } catch (error) { return { sObject, automation: "ValidationRule", name, outcome: IMPLEMENTATION_OUTCOME.FAILED, comment: `Error processing file : ${error}`, }; } } async applyBypassToValidationRules(connection, sObjects) { const validationRuleRecords = await this.queryValidationRules(connection, sObjects); if (!validationRuleRecords || validationRuleRecords.records.length === 0) { uxLog("log", this, c.grey("No validation rules found for the specified sObjects.")); } uxLog("log", this, c.grey(`Processing ${validationRuleRecords.records.length} Validation Rules.`)); 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("log", this, c.grey("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) { this.reports.implementation.push({ sObject, automation: "ValidationRule", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: `File not found locally.`, }); } else { eligibleMetadataFilePaths.push({ filePath, sObject, name }); } } } } for (const eligibleMetadataFilePath of eligibleMetadataFilePaths) { this.reports.implementation.push(await this.handleValidationRuleFile(eligibleMetadataFilePath.filePath, eligibleMetadataFilePath.sObject, eligibleMetadataFilePath.name)); } } // Triggers async handleTriggerFile(filePath, name) { try { if (!fs.existsSync(filePath)) { return { sObject: null, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.FAILED, comment: "File not found locally.", }; } const fileContent = fs.readFileSync(filePath, "utf-8"); if (typeof fileContent !== "string") { return { sObject: null, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.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, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.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, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.IGNORED, comment: "Bypass already implemented", }; } if (/bypass|PAD\.can/i.test(fileContent)) { return { sObject, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.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, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.ADDED, comment: "Bypass implemented", }; } catch (error) { return { sObject: null, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.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("log", this, c.grey("No triggers found for the specified sObjects.")); } 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("log", this, c.grey("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) { this.reports.implementation.push({ sObject: null, automation: "Trigger", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: `File not found locally.`, }); } else { eligibleMetadataFilePaths.push({ filePath, name }); } } } } for (const eligibleMetadataFilePath of eligibleMetadataFilePaths) { this.reports.implementation.push(await this.handleTriggerFile(eligibleMetadataFilePath.filePath, eligibleMetadataFilePath.name)); } } // Flows async handleFlowFile(filePath, name) { try { if (!fs.existsSync(filePath)) { return { sObject: null, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.FAILED, comment: "File not found", }; } const fileContent = await parseXmlFile(filePath); // Validate Flow structure - ensure we have a start element if (!fileContent?.Flow?.start || !Array.isArray(fileContent.Flow.start) || fileContent.Flow.start.length === 0) { return { sObject: null, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Flow does not have a start element (may be scheduled, autolaunched, or screen flow)", }; } const startElement = fileContent.Flow.start[0]; // Validate that start element has an object (record-triggered flow) const sObject = startElement?.object?.[0] ?? null; if (sObject == null) { return { sObject: null, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "No sObject found (may be scheduled, autolaunched, or screen flow)", }; } // Validate that start element has connector and targetReference if (!startElement.connector || !Array.isArray(startElement.connector) || startElement.connector.length === 0) { return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Flow start element does not have a connector", }; } const connector = startElement.connector[0]; if (!connector.targetReference || !Array.isArray(connector.targetReference) || connector.targetReference.length === 0) { return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Flow start connector does not have a targetReference", }; } const filterFormula = startElement?.filterFormula?.[0] ?? null; // Check if a bypass already exists in formula mode if (filterFormula && typeof filterFormula === "string" && /bypass/i.test(filterFormula)) { return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Another bypass mechanism exists", }; } const firstNodeName = connector.targetReference?.[0] ?? null; if (firstNodeName === null) { return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Flow has no start connector or target reference", }; } if (firstNodeName === 'SFDX_HARDIS_FLOW_BYPASS_DO_NOT_RENAME') { return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.IGNORED, comment: "SFDX-Hardis Bypass already implemented", }; } if (!Object.keys(fileContent.Flow ?? {}).includes('decisions')) { fileContent.Flow.decisions = []; } fileContent.Flow.decisions.push({ "name": ["SFDX_HARDIS_FLOW_BYPASS_DO_NOT_RENAME"], "label": ["Is Bypass Activated?"], "description": ["Check if the bypass custom permission is assigned to the running user." + (this.skipCredits ? "" : " " + CREDITS_TEXT)], "locationX": ["0"], "locationY": ["0"], "defaultConnectorLabel": ["No"], "rules": [ { "name": ["SFDX_HARDIS_BypassYes"], "conditionLogic": ["or"], "conditions": [ { "leftValueReference": [`$Permission.Bypass${sObject}Flows`], "operator": ["EqualTo"], "rightValue": [{ "booleanValue": ["true"] }] }, { "leftValueReference": ["$Permission.BypassAllFlows"], "operator": ["EqualTo"], "rightValue": [{ "booleanValue": ["true"] }] } ], "label": ["Yes"], "connector": [{ "targetReference": [firstNodeName] }] } ] }); // Ensure the start connector and its targetReference array exist before assignment const startArray = fileContent.Flow.start; if (!Array.isArray(startArray) || startArray.length === 0 || !Array.isArray(startArray[0].connector) || startArray[0].connector.length === 0 || !Array.isArray(startArray[0].connector[0].targetReference)) { return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "Flow start connector not found; cannot attach bypass decision", }; } connector.targetReference[0] = 'SFDX_HARDIS_FLOW_BYPASS_DO_NOT_RENAME'; await writeXmlFile(filePath, fileContent); return { sObject, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.ADDED, comment: "Bypass implemented", }; } catch (error) { return { sObject: null, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.FAILED, comment: `Error processing file : ${error}`, }; } } async applyBypassToFlows(connection, sObjects) { const flowResults = await this.queryFlows(connection); const filteredFlowResults = this.filterFlowResults(flowResults, sObjects); if (!filteredFlowResults || filteredFlowResults?.length === 0) { uxLog("log", this, c.grey("No flows found for the specified sObjects.")); } const eligibleMetadataFilePaths = []; if (this.retrieveFromOrg) { const retrievedFlowChunks = await this.retrieveMetadataFiles(filteredFlowResults, "Flow"); for (const retrievedFlows of retrievedFlowChunks) { if (retrievedFlows?.status !== 1 && retrievedFlows?.result?.files && Array.isArray(retrievedFlows.result.files) && retrievedFlows.result.files.length > 0) { for (const metadataFile of retrievedFlows.result.files) { if (metadataFile?.type !== "Flow" || metadataFile?.problemType === "Error") { continue; } const name = metadataFile.fullName; const filePath = metadataFile.filePath; eligibleMetadataFilePaths.push({ filePath, name }); } } else { uxLog("log", this, c.grey("No Flow files found in the retrieved metadata chunk.")); } } } else { if (filteredFlowResults) { for (const record of filteredFlowResults) { const name = record.ApiName; const filePath = await MetadataUtils.findMetaFileFromTypeAndName("Flow", name); if (filePath === null) { this.reports.implementation.push({ sObject: null, automation: "Flow", name, outcome: IMPLEMENTATION_OUTCOME.SKIPPED, comment: "File not found locally", }); } else { eligibleMetadataFilePaths.push({ filePath, name }); } } } } for (const eligibleMetadataFilePath of eligibleMetadataFilePaths) { this.reports.implementation.push(await this.handleFlowFile(eligibleMetadataFilePath.filePath, eligibleMetadataFilePath.name)); } } } /** * Command TODO: * - Add option to determine sobjects directly from current project instead of calling API * - While generating, check if the metadata file already exists, if yes, warning * - Before implementing, check if the permission set file exists, if not, warning */ //# sourceMappingURL=bypass.js.map