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

327 lines (320 loc) • 16.3 kB
/* jscpd:ignore-start */ import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import c from 'chalk'; import fs from 'fs-extra'; import * as path from 'path'; import { glob } from 'glob'; import { createTempDir, execCommand, isCI, removeObjectPropertyLists, uxLog } from '../../../../common/utils/index.js'; import { prompts } from '../../../../common/utils/prompts.js'; import { parsePackageXmlFile, parseXmlFile, writePackageXmlFile, writeXmlFile, } from '../../../../common/utils/xmlUtils.js'; import { getConfig, setConfig } from '../../../../config/index.js'; import { PACKAGE_ROOT_DIR } from '../../../../settings.js'; import { FilterXmlContent } from './filter-xml-content.js'; import { GLOB_IGNORE_PATTERNS } from '../../../../common/utils/projectUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); export default class CleanReferences extends SfCommand { static title = 'Clean references in dx sources'; static description = ` ## Command Behavior **Removes unwanted references and cleans up metadata within your Salesforce DX project sources.** This command provides a powerful way to maintain a clean and efficient Salesforce codebase by eliminating unnecessary or problematic metadata. It supports various cleaning types, from removing hardcoded user references in dashboards to minimizing profile attributes. Key functionalities include: - **Configurable Cleaning Types:** You can specify a particular cleaning type (e.g., - **JSON/XML Configuration:** Cleaning operations can be driven by a JSON configuration file or a - **Interactive Selection:** If no cleaning type is specified, the command interactively prompts you to select which references to clean. - **Persistent Configuration:** You can choose to save your cleaning selections in your project's configuration (\`.sfdx-hardis.yml\`) so they are automatically applied during future Work Save operations. - **File Deletion:** Beyond just cleaning XML content, it can also delete related files (e.g., custom field files and their translations when a custom field is marked for deletion). <details markdown="1"> <summary>Technical explanations</summary> The command's technical implementation involves several steps: - **Configuration Loading:** It reads the project's configuration to determine default cleaning types and user preferences. - **Cleaning Type Processing:** For each selected cleaning type, it either executes a dedicated sub-command (e.g., - **XML Filtering:** For template-based cleanings, it constructs a temporary JSON configuration file based on predefined templates or user-provided - **Package.xml Cleanup:** It iterates through - **Object Property Removal:** The </details> `; static examples = [ '$ sf hardis:project:clean:references', '$ sf hardis:project:clean:references --type all', '$ sf hardis:project:clean:references --config ./cleaning/myconfig.json', '$ sf hardis:project:clean:references --config ./somefolder/myDestructivePackage.xml', ]; // public static args = [{name: 'file'}]; static flags = { type: Flags.string({ char: 't', description: 'Cleaning type', options: [ 'all', 'caseentitlement', 'dashboards', 'datadotcom', 'destructivechanges', 'localfields', 'productrequest', 'entitlement', 'flowPositions', 'sensitiveMetadatas', 'minimizeProfiles' ], }), config: Flags.string({ char: 'c', description: 'Path to a JSON config file or a destructiveChanges.xml file', }), debug: Flags.boolean({ char: 'd', default: false, description: messages.getMessage('debugMode'), }), websocket: Flags.string({ description: messages.getMessage('websocket'), }), skipauth: Flags.boolean({ description: 'Skip authentication check when a default username is required', }), }; // Set this to true if your command requires a project workspace; 'requiresProject' is false by default static requiresProject = true; /* jscpd:ignore-end */ debugMode = false; cleaningTypes = []; allCleaningTypes = [ { value: 'checkPermissions', title: 'Check custom items are existing it at least one Permission Set', command: 'sf hardis:lint:access', }, { value: 'dashboards', title: 'Dashboards: Remove reference to hardcoded users', }, { value: 'destructivechanges', title: 'DestructiveChanges.xml: Remove source files mentioned in destructiveChanges.xml', }, { value: 'flowPositions', title: `Flows: Replace all positions in AutoLayout Flows by 0 to simplify conflicts management`, command: 'sf hardis:project:clean:flowpositions', }, { value: 'sensitiveMetadatas', title: `Remove sensitive metadata content from sources (ex: Certificates)`, command: 'sf hardis:project:clean:sensitive-metadatas', }, { value: 'listViewsMine', title: `ListViews: Convert scope "Everything" into scope "Mine" on ListViews`, command: 'sf hardis:project:clean:listviews', }, { value: 'minimizeProfiles', title: 'Profiles: Remove profile attributes that exists on permission sets', command: 'sf hardis:project:clean:minimizeprofiles', }, { value: 'caseentitlement', title: 'References to Entitlement Management items', }, { value: 'datadotcom', title: 'References to Data.com items. https://help.salesforce.com/articleView?id=000320795&type=1&mode=1', }, { value: 'entitlement', title: 'References to Entitlement object', }, { value: 'localfields', title: 'References to Local Fields items. https://help.salesforce.com/articleView?id=sf.admin_local_name_fields.htm&type=5', }, { value: 'productrequest', title: 'References to ProductRequest object', }, { value: 'systemDebug', title: 'Remove System.debug from sources', command: 'sf hardis:project:clean:systemdebug', }, { value: 'v60', title: 'Make metadata compliant with v60', }, ]; configFile; deleteItems = {}; async run() { const { flags } = await this.parse(CleanReferences); this.debugMode = flags.debug || false; this.cleaningTypes = flags.type ? [flags.type] : []; this.configFile = flags.config || null; const config = await getConfig('project'); // Config file sent by user if (this.configFile != null) { this.cleaningTypes = [this.configFile.trim()]; } else { // Read list of cleanings to perform in references if (this.cleaningTypes.length > 0 && this.cleaningTypes[0] === 'all') { this.cleaningTypes = config.autoCleanTypes || []; } // Prompt user cleanings to perform if (!isCI && this.cleaningTypes.length === 0) { const typesResponse = await prompts({ type: 'multiselect', name: 'value', message: c.cyanBright('What references do you want to clean from your SFDX project sources ?'), description: 'Select which types of reference cleaning to perform on your project', choices: this.allCleaningTypes, }); this.cleaningTypes = typesResponse.value; } } // Prompt user to save choice in configuration const autoCleanTypes = config.autoCleanTypes || []; const toAdd = this.cleaningTypes.filter((type) => !autoCleanTypes.includes(type)); if (toAdd.length > 0 && !isCI && flags.type !== 'all') { const saveResponse = await prompts({ type: 'confirm', name: 'value', default: true, message: c.cyanBright('Do you want to save this action in your project configuration, so it is executed at each Work Save ?'), description: 'Choose whether to automatically apply these cleaning types during future work saves', }); if (saveResponse.value === true) { autoCleanTypes.push(...this.cleaningTypes); await setConfig('project', { autoCleanTypes: [...new Set(autoCleanTypes)], }); } } // Process cleaning for (const cleaningType of this.cleaningTypes) { const cleaningTypeObj = this.allCleaningTypes.filter((cleaningTypeObj) => cleaningTypeObj.value === cleaningType)[0]; if (cleaningTypeObj?.command) { let command = cleaningTypeObj?.command; if (this.argv.indexOf('--websocket') > -1) { command += ` --websocket ${this.argv[this.argv.indexOf('--websocket') + 1]}`; } uxLog("action", this, c.cyan(`Run cleaning command ${c.bold(cleaningType)} (${cleaningTypeObj.title}) ...`)); // Command based cleaning await execCommand(command, this, { fail: true, output: true, debug: this.debugMode, }); } else { // Template based cleaning uxLog("action", this, c.cyan(`Apply cleaning of references to ${c.bold(cleaningType)} (${cleaningTypeObj.title})...`)); const filterConfigFile = await this.getFilterConfigFile(cleaningType); const packageDirectories = this.project?.getPackageDirectories() || []; for (const packageDirectory of packageDirectories) { await FilterXmlContent.run(['-c', filterConfigFile, '--inputfolder', packageDirectory.path, '--outputfolder', packageDirectory.path], this.config); } } } // Clean package.xml file from deleted items uxLog("log", this, c.grey(`Cleaning package.xml & files from deleted items...`)); const patternPackageXml = '**/manifest/**/package*.xml'; const packageXmlFiles = await glob(patternPackageXml, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); for (const packageXmlFile of packageXmlFiles) { const packageXmlContent = await parsePackageXmlFile(packageXmlFile); const packageXmlContentStr = JSON.stringify(packageXmlContent); const newPackageXmlContent = removeObjectPropertyLists(packageXmlContent, this.deleteItems); if (packageXmlContentStr !== JSON.stringify(newPackageXmlContent)) { await writePackageXmlFile(packageXmlFile, newPackageXmlContent); uxLog("log", this, c.grey('-- cleaned elements from ' + packageXmlFile)); } } // Delete files when necessary (in parallel) uxLog("log", this, c.grey(`Removing obsolete files...`)); await Promise.all(Object.keys(this.deleteItems).map(async (type) => { await this.manageDeleteRelatedFiles(type); })); uxLog("success", this, c.green(`Cleaning complete`)); // Return an object to be displayed with --json return { outputString: 'Cleaned references from sfdx project' }; } async getFilterConfigFile(cleaningType) { const templateFile = path.join(path.join(PACKAGE_ROOT_DIR, 'defaults/clean', 'template.txt')); // Read and complete cleaning template let templateContent = await fs.readFile(templateFile, 'utf8'); if (cleaningType === 'destructivechanges' || cleaningType.endsWith('.xml')) { // destructive changes file const destructiveChangesFile = cleaningType.endsWith('.xml') ? cleaningType : './manifest/destructiveChanges.xml'; const destructiveChanges = await parseXmlFile(destructiveChangesFile); for (const type of destructiveChanges.Package.types || []) { const members = type.members; templateContent = templateContent.replace(new RegExp(`{{ ${type.name[0]} }}`, 'g'), JSON.stringify(members, null, 2)); this.deleteItems[type.name[0]] = (this.deleteItems[type.name[0]] || []).concat(members); } } else { // Predefined destructive items file const filterConfigFileConfigPath = cleaningType.endsWith('.json') ? cleaningType : path.join(path.join(PACKAGE_ROOT_DIR, 'defaults/clean', cleaningType + '.json')); const filterConfigFileConfig = JSON.parse(await fs.readFile(filterConfigFileConfigPath, 'utf8')); for (const type of Object.keys(filterConfigFileConfig.items)) { templateContent = templateContent.replace(new RegExp(`{{ ${type} }}`, 'g'), JSON.stringify(filterConfigFileConfig.items[type], null, 2)); this.deleteItems[type] = (this.deleteItems[type] || []).concat(filterConfigFileConfig.items[type]); } } // Create temporary file templateContent = templateContent.replace(/{{ .* }}/gm, '[]'); const tmpCleanFileName = cleaningType.endsWith('.xml') || cleaningType.endsWith('.json') ? path.basename(cleaningType) : cleaningType; const filterConfigFile = path.join(await createTempDir(), `clean_${tmpCleanFileName}.json`); await fs.writeFile(filterConfigFile, templateContent); return filterConfigFile; } async manageDeleteRelatedFiles(type) { // Custom fields if (type === 'CustomField') { for (const field of this.deleteItems[type] || []) { await this.manageDeleteCustomFieldRelatedFiles(field); } } } async manageDeleteCustomFieldRelatedFiles(field) { // Remove custom field and customTranslation const [obj, fld] = field.split('.'); const patternField = `**/objects/${obj}/fields/${fld}.field-meta.xml`; const patternTranslation = `**/objectTranslations/${obj}-*/${fld}.fieldTranslation-meta.xml`; for (const pattern of [patternField, patternTranslation]) { const matchFiles = await glob(pattern, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); for (const removeFile of matchFiles) { await fs.remove(removeFile); uxLog("log", this, c.grey(`Removed file ${removeFile}`)); } } // Remove field in recordTypes const patternRecordType = `**/objects/${obj}/recordTypes/*.recordType-meta.xml`; const matchFilesPattern = await glob(patternRecordType, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); for (const recordTypeFile of matchFilesPattern) { const recordType = await parseXmlFile(recordTypeFile); if (recordType?.RecordType.picklistValues) { const updatedPicklistValues = recordType.RecordType.picklistValues.filter((picklistValue) => { return picklistValue?.picklist[0] !== fld; }); if (updatedPicklistValues.length !== recordType.RecordType.picklistValues.length) { recordType.RecordType.picklistValues = updatedPicklistValues; await writeXmlFile(recordTypeFile, recordType); uxLog("log", this, c.grey(`Cleaned file ${recordTypeFile} from ${obj}.${fld}`)); } } } } } //# sourceMappingURL=references.js.map