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
JavaScript
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