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
273 lines • 13.1 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 columnify from 'columnify';
import { execSfdxJson, extractRegexMatches, isCI, uxLog } 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 = messages.getMessage('orgPurgeFlow');
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(this, c.yellow(outputString));
return { deleted: [], outputString };
}
// Simplify results format & display them
this.formatFlowRecords();
// Confirm deletion
if (this.promptUser) {
const confirmDelete = await prompts({
type: 'confirm',
name: 'value',
message: c.cyanBright(`Do you confirm you want to delete these ${this.flowRecords.length} flow versions ?`),
});
if (confirmDelete.value === false) {
uxLog(this, c.magenta('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
? `[sfdx-hardis] Deleted the following list of record(s):\n${columnify(this.deletedRecords)}`
: '[sfdx-hardis] No record(s) to delete';
uxLog(this, c.green(summary));
// Return an object to be displayed with --json
return { orgId: flags['target-org'].getOrgId(), outputString: summary };
}
async processDeleteFlowVersions(conn, tryDeleteInterviews) {
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(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(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 ?`),
});
if (confirmDelete.value === false) {
uxLog(this, c.magenta('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(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) => {
return {
Id: record.Id,
MasterLabel: record.MasterLabel,
VersionNumber: record.VersionNumber,
DefinitionDevName: record.Definition.DeveloperName,
Status: record.Status,
Description: record.Description,
};
});
uxLog(this, `[sfdx-hardis] Found ${c.bold(this.flowRecords.length)} records:\n${c.yellow(columnify(this.flowRecords))}`);
}
async listFlowVersionsToDelete(manageableConstraint) {
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
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',
choices: flowNamesChoice,
},
{
type: 'multiselect',
name: 'status',
message: 'Please select the status(es) you want to delete',
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;
uxLog(this, c.yellow(`Flow interviews to be deleted would be the following:\n${columnify(flowsInterviewsToDelete)}`));
}
}
//# sourceMappingURL=flow.js.map