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
320 lines (314 loc) • 16.8 kB
JavaScript
/* jscpd:ignore-start */
import { SfCommand, Flags, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core';
import { Messages, SfError } from '@salesforce/core';
import c from 'chalk';
import { execSfdxJson, extractRegexMatches, isCI, uxLog, uxLogTable } from '../../../../common/utils/index.js';
import { prompts } from '../../../../common/utils/prompts.js';
import { bulkDelete, bulkDeleteTooling, bulkQuery } from '../../../../common/utils/apiUtils.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class OrgPurgeFlow extends SfCommand {
static title = 'Purge Flow versions';
static description = `
**Purges old or unwanted Flow versions from a Salesforce org, with an option to delete related Flow Interviews.**
This command helps maintain a clean and performant Salesforce org by removing obsolete Flow versions. Over time, multiple versions of Flows can accumulate, consuming storage and potentially impacting performance. This tool provides a controlled way to clean up these versions.
Key functionalities:
- **Targeted Flow Selection:** Allows you to filter Flow versions to delete by name (\`--name\`) and status (\`--status\`, e.g., \`Obsolete\`, \`Draft\`, \`Inactive\`).
- **Flow Interview Deletion:** If a Flow version cannot be deleted due to active Flow Interviews, the \`--delete-flow-interviews\` flag (or interactive prompt) allows you to delete these interviews first, then retry the Flow version deletion.
- **Confirmation Prompt:** In interactive mode, it prompts for confirmation before proceeding with the deletion of Flow versions and Flow Interviews.
- **Partial Success Handling:** The \`--allowpurgefailure\` flag (default \`true\`) allows the command to continue even if some deletions fail, reporting the errors.
<details markdown="1">
<summary>Technical explanations</summary>
The command's technical implementation involves:
- **SOQL Queries (Tooling API):** It queries the \`Flow\` object (using the Tooling API) to list Flow versions based on the provided filters (name, status, manageable state).
- **Bulk Deletion (Tooling API):** It uses \`bulkDeleteTooling\` to perform mass deletions of Flow versions. If deletion fails due to active interviews, it extracts the interview IDs.
- **Flow Interview Management:** If \`delete-flow-interviews\` is enabled, it queries \`FlowInterview\` objects, performs bulk deletion of the identified interviews using \`bulkDelete\`, and then retries the Flow version deletion.
- **Interactive Prompts:** Uses the \`prompts\` library to interact with the user for selecting Flows, statuses, and confirming deletion actions.
- **Error Reporting:** Logs detailed error messages for failed deletions, including the specific reasons.
- **Command-Line Execution:** Uses \`execSfdxJson\` to execute Salesforce CLI commands for querying Flow data.
</details>
`;
static examples = [
`$ sf hardis:org:purge:flow`,
`$ sf hardis:org:purge:flow --target-org nicolas.vuillamy@gmail.com --no-prompt --delete-flow-interviews`,
`$ sf hardis:org:purge:flow --target-org nicolas.vuillamy@gmail.com --status "Obsolete,Draft,InvalidDraft" --name TestFlow`,
];
// public static args = [{name: 'file'}];
static flags = {
// flag with a value (-n, --name=VALUE)
prompt: Flags.boolean({
char: 'z',
default: true,
allowNo: true,
description: messages.getMessage('prompt'),
}),
name: Flags.string({
char: 'n',
description: messages.getMessage('nameFilter'),
}),
status: Flags.string({
char: 's',
description: messages.getMessage('statusFilter'),
}),
'delete-flow-interviews': Flags.boolean({
char: 'w',
default: false,
description: `If the presence of Flow interviews prevent to delete flows versions, delete them before retrying to delete flow versions`,
}),
allowpurgefailure: Flags.boolean({
char: 'f',
default: true,
allowNo: true,
description: messages.getMessage('allowPurgeFailure'),
}),
instanceurl: Flags.string({
char: 'r',
default: 'https://login.salesforce.com',
description: messages.getMessage('instanceUrl'),
}),
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',
}),
'target-org': requiredOrgFlagWithDeprecations,
};
// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
static requiresProject = false;
debugMode = false;
statusFilter = [];
nameFilter = null;
username;
promptUser = true;
deleteFlowInterviews;
allowPurgeFailure;
flowRecordsRaw;
flowRecords;
deletedRecords = [];
deletedErrors = [];
/* jscpd:ignore-end */
async run() {
const { flags } = await this.parse(OrgPurgeFlow);
this.promptUser = flags.prompt === false ? false : true;
this.nameFilter = flags.name || null;
this.allowPurgeFailure = flags.allowpurgefailure === false ? false : true;
this.deleteFlowInterviews = flags['delete-flow-interviews'] || false;
this.debugMode = flags.debug || false;
this.username = flags['target-org'].getUsername();
// List flows to delete, prompt user if not in CI and not send as arguments
const manageableConstraint = await this.getFlowsScope(flags);
// Check we don't delete active Flows
if (this.statusFilter.includes('Active')) {
throw new SfError('You can not delete active records');
}
// Build query with name filter if sent
await this.listFlowVersionsToDelete(manageableConstraint);
// Check empty result
if (this.flowRecordsRaw.length === 0) {
const outputString = `[sfdx-hardis] No matching Flow records found`;
uxLog("warning", this, c.yellow(outputString));
return { deleted: [], outputString };
}
// Simplify results format & display them
this.formatFlowRecords();
// Confirm deletion
if (this.promptUser && !isCI) {
const confirmDelete = await prompts({
type: 'confirm',
name: 'value',
message: c.cyanBright(`Do you confirm you want to delete these ${this.flowRecords.length} flow versions ?`),
description: 'Permanently delete the selected flow versions from the Salesforce org',
});
if (confirmDelete.value === false) {
uxLog("error", this, c.red('Action cancelled by user.'));
return { outputString: 'Action cancelled by user.' };
}
}
// Perform deletion
const conn = flags['target-org'].getConnection();
await this.processDeleteFlowVersions(conn, true);
const summary = this.deletedRecords.length > 0
? `Deleted the following list of record(s)`
: 'No record(s) to delete';
uxLog("action", this, c.cyan(summary));
if (this.deletedRecords.length > 0) {
uxLogTable(this, this.deletedRecords);
}
// Return an object to be displayed with --json
return { orgId: flags['target-org'].getOrgId(), outputString: summary };
}
async processDeleteFlowVersions(conn, tryDeleteInterviews) {
uxLog("action", this, c.cyan(`Deleting Flow versions...`));
const recordsIds = this.flowRecords.map((record) => record.Id);
const deleteResults = await bulkDeleteTooling('Flow', recordsIds, conn);
for (const deleteRes of deleteResults.results) {
if (deleteRes.success) {
this.deletedRecords.push(deleteRes);
}
else {
uxLog("error", this, c.red(`[sfdx-hardis] Unable to perform deletion request: ${JSON.stringify(deleteRes)}`));
this.deletedErrors.push(deleteRes);
}
}
if (this.deletedErrors.length > 0 &&
(this.deleteFlowInterviews === true || !isCI) &&
tryDeleteInterviews === true) {
await this.manageDeleteFlowInterviews(conn);
}
if (this.deletedErrors.length > 0) {
const errMsg = `[sfdx-hardis] There have been errors while deleting ${this.deletedErrors.length} record(s): \n${JSON.stringify(this.deletedErrors)}`;
if (this.allowPurgeFailure) {
uxLog("warning", this, c.yellow(errMsg));
}
else {
throw new SfError(c.yellow(`There have been errors while deleting ${this.deletedErrors.length} record(s): \n${JSON.stringify(this.deletedErrors)}`));
}
}
}
async manageDeleteFlowInterviews(conn) {
// Gather flow interviews that prevent deleting flow versions
const flowInterviewsIds = [];
this.flowRecords = [];
const extractInterviewsRegex = /Flow Interview - ([a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})/gm;
for (const deletedError of this.deletedErrors) {
this.flowRecords.push({ Id: deletedError.Id });
const errorflowInterviewIds = await extractRegexMatches(extractInterviewsRegex, deletedError.error);
flowInterviewsIds.push(...[...new Set(errorflowInterviewIds)]); // make interview Ids unique
}
if (flowInterviewsIds.length === 0) {
return;
}
// Display flows & Prompt user if not in CI
await this.displayFlowInterviewToDelete(flowInterviewsIds, conn);
if (!isCI && this.promptUser === true) {
const confirmDelete = await prompts({
type: 'confirm',
name: 'value',
message: c.cyanBright(`Do you confirm you want to delete ${flowInterviewsIds.length} Flow Interviews ?`),
description: 'Permanently delete the selected flow interview records from the Salesforce org',
});
if (confirmDelete.value === false) {
uxLog("error", this, c.red('Action cancelled by user.'));
return { outputString: 'Action cancelled by user.' };
}
}
// Delete flow interviews
const deleteInterviewResults = await bulkDelete('FlowInterview', flowInterviewsIds, conn);
this.deletedRecords.push(deleteInterviewResults?.successfulResults || []);
this.deletedErrors = deleteInterviewResults?.failedResults || [];
// Try to delete flow versions again
uxLog("action", this, c.cyan(`Trying again to delete flow versions after deleting flow interviews...`));
this.flowRecords = [...new Set(this.flowRecords)]; // Make list unique
await this.processDeleteFlowVersions(conn, false);
}
formatFlowRecords() {
this.flowRecords = this.flowRecordsRaw.map((record) => ({
Id: record.Id,
MasterLabel: record.MasterLabel,
VersionNumber: record.VersionNumber,
DefinitionDevName: record.Definition.DeveloperName,
Status: record.Status,
Description: record.Description,
}));
if (this.flowRecords.length === 0) {
uxLog("warning", this, c.yellow('No Flow versions found to delete.'));
return;
}
const flowList = this.flowRecords
.map((flow) => `- ${c.bold(flow.DefinitionDevName)} v${c.green(flow.VersionNumber)} (${c.yellow(flow.Status)})${flow.Description ? ` - ${c.gray(flow.Description)}` : ''}`)
.join('\n');
uxLog("action", this, c.cyan(`Found ${this.flowRecords.length} Flow version(s) to delete:\n${flowList}`));
}
async listFlowVersionsToDelete(manageableConstraint) {
uxLog("action", this, c.cyan('Querying Flow versions to delete...'));
let query = `SELECT Id,MasterLabel,VersionNumber,Status,Description,Definition.DeveloperName FROM Flow WHERE ${manageableConstraint} AND Status IN ('${this.statusFilter.join("','")}')`;
if (this.nameFilter && this.nameFilter != 'all') {
query += ` AND Definition.DeveloperName = '${this.nameFilter}'`;
}
query += ' ORDER BY Definition.DeveloperName,VersionNumber';
const flowQueryCommand = 'sf data query ' + ` --query "${query}"` + ` --target-org ${this.username}` + ' --use-tooling-api';
const flowQueryRes = await execSfdxJson(flowQueryCommand, this, {
output: false,
debug: this.debugMode,
fail: true,
});
this.flowRecordsRaw = flowQueryRes?.result?.records || flowQueryRes.records || [];
}
async getFlowsScope(flags) {
const manageableConstraint = "ManageableState IN ('deprecatedEditable','installedEditable','unmanaged')";
if (flags.status) {
// Input parameter used
this.statusFilter = flags.status.split(',');
}
else if (isCI || this.promptUser === false) {
// Obsolete by default for CI
this.statusFilter = ['Obsolete'];
}
else {
// Query all flows definitions
uxLog("action", this, c.cyan('Querying all Flow definitions to select from...'));
const allFlowQueryCommand = 'sf data query ' +
` --query "SELECT Id,DeveloperName,MasterLabel,ManageableState FROM FlowDefinition WHERE ${manageableConstraint} ORDER BY DeveloperName"` +
` --target-org ${this.username}` +
' --use-tooling-api';
const allFlowQueryRes = await execSfdxJson(allFlowQueryCommand, this, {
output: false,
debug: this.debugMode,
fail: true,
});
const flowRecordsRaw = allFlowQueryRes?.result?.records || allFlowQueryRes.records || [];
const flowNamesUnique = [...new Set(flowRecordsRaw.map((flowRecord) => flowRecord.DeveloperName))];
const flowNamesChoice = flowNamesUnique.map((flowName) => {
return { title: flowName, value: flowName };
});
flowNamesChoice.unshift({ title: 'All flows', value: 'all' });
// Manually select status
const selectStatus = await prompts([
{
type: 'select',
name: 'name',
message: 'Please select the flow you want to clean',
description: 'Choose a specific flow to clean or select all flows',
placeholder: 'Select a flow',
choices: flowNamesChoice,
},
{
type: 'multiselect',
name: 'status',
message: 'Please select the status(es) you want to delete',
description: 'Choose which flow version statuses should be deleted',
choices: [
{ title: `Draft`, value: 'Draft' },
{ title: `Inactive`, value: 'Inactive' },
{ title: `Obsolete`, value: 'Obsolete' },
],
},
]);
this.nameFilter = selectStatus.name;
this.statusFilter = selectStatus.status;
}
return manageableConstraint;
}
async displayFlowInterviewToDelete(flowVInterviewIds, conn) {
const query = 'SELECT Name,InterviewLabel,InterviewStatus,CreatedBy.Username,CreatedDate,LastModifiedDate ' +
`FROM FlowInterview WHERE Id IN ('${flowVInterviewIds.join("','")}')` +
' ORDER BY Name';
const flowsInterviewsToDelete = (await bulkQuery(query, conn)).records;
if (flowsInterviewsToDelete.length === 0) {
uxLog("warning", this, c.yellow('No Flow Interviews found to delete.'));
return;
}
// Display Flow Interviews to delete
const flowList = flowsInterviewsToDelete
.map((flow) => `- ${c.bold(flow.Name)} (${c.green(flow.InterviewLabel)}) - ${c.yellow(flow.InterviewStatus)}`)
.join('\n');
uxLog("action", this, c.cyan(`Found ${flowsInterviewsToDelete.length} Flow Interviews to delete:\n${flowList}`));
}
}
//# sourceMappingURL=flow.js.map