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
567 lines (562 loc) • 27.9 kB
JavaScript
/* jscpd:ignore-start */
import { SfCommand, Flags, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import c from 'chalk';
import fs from 'fs-extra';
import open from 'open';
import * as path from 'path';
import { createTempDir, execCommand, getCurrentGitBranch, git, gitHasLocalUpdates, normalizeFileStatusPath, uxLog, } from '../../../common/utils/index.js';
import { exportData } from '../../../common/utils/dataUtils.js';
import { forceSourcePull } from '../../../common/utils/deployUtils.js';
import { callSfdxGitDelta, getGitDeltaScope, selectTargetBranch } from '../../../common/utils/gitUtils.js';
import { prompts } from '../../../common/utils/prompts.js';
import { appendPackageXmlFilesContent, parseXmlFile, removePackageXmlFilesContent, writeXmlFile, } from '../../../common/utils/xmlUtils.js';
import { WebSocketClient } from '../../../common/websocketClient.js';
import { CONSTANTS, getApiVersion, getConfig, setConfig } from '../../../config/index.js';
import CleanReferences from '../project/clean/references.js';
import CleanXml from '../project/clean/xml.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class SaveTask extends SfCommand {
static title = 'Save work task';
static description = `When a work task is completed, guide user to create a merge request
Advanced instructions in [Publish a task](${CONSTANTS.DOC_URL_ROOT}/salesforce-ci-cd-publish-task/)
- Generate package-xml diff using sfdx-git-delta
- Automatically update \`manifest/package.xml\` and \`manifest/destructiveChanges.xml\` according to the committed updates
- Automatically Clean XML files using \`.sfdx-hardis.yml\` properties
- \`autocleantypes\`: List of auto-performed sources cleanings, available on command [hardis:project:clean:references](${CONSTANTS.DOC_URL_ROOT}/hardis/project/clean/references/)
- \`autoRemoveUserPermissions\`: List of userPermission to automatically remove from profile metadatas
Example:
\`\`\`yaml
autoCleanTypes:
- checkPermissions
- destructivechanges
- datadotcom
- minimizeProfiles
- listViewsMine
autoRemoveUserPermissions:
- EnableCommunityAppLauncher
- FieldServiceAccess
- OmnichannelInventorySync
- SendExternalEmailAvailable
- UseOmnichannelInventoryAPIs
- ViewDataLeakageEvents
- ViewMLModels
- ViewPlatformEvents
- WorkCalibrationUser
\`\`\`
- Push commit to server
`;
static examples = ['$ sf hardis:work:task:save', '$ sf hardis:work:task:save --nopull --nogit --noclean'];
// public static args = [{name: 'file'}];
static flags = {
nopull: Flags.boolean({
char: 'n',
default: false,
description: 'No scratch pull before save',
}),
nogit: Flags.boolean({
char: 'g',
default: false,
description: 'No automated git operations',
}),
noclean: Flags.boolean({
char: 'c',
default: false,
description: 'No cleaning of local sources',
}),
auto: Flags.boolean({
default: false,
description: 'No user prompts (when called from CI for example)',
}),
targetbranch: Flags.string({
description: 'Name of the Merge Request target branch. Will be guessed or prompted if not provided.',
}),
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 = true;
// List required plugins, their presence will be tested before running the command
static requiresSfdxPlugins = ['sfdx-git-delta'];
debugMode = false;
noPull = false;
noGit = false;
noClean = false;
auto = false;
gitUrl;
currentBranch;
targetBranch;
/* jscpd:ignore-end */
async run() {
const { flags } = await this.parse(SaveTask);
this.noPull = flags.nopull || false;
this.noGit = flags.nogit || false;
this.noClean = flags.noclean || false;
this.auto = flags.auto || false;
this.targetBranch = flags.targetbranch || null;
this.debugMode = flags.debug || false;
const localBranch = (await getCurrentGitBranch()) || '';
// Define current and target branches
this.gitUrl = await git().listRemote(['--get-url']);
this.currentBranch = (await getCurrentGitBranch()) || '';
if (this.targetBranch == null) {
const userConfig = await getConfig('user');
if (userConfig?.localStorageBranchTargets && userConfig?.localStorageBranchTargets[localBranch]) {
this.targetBranch = userConfig?.localStorageBranchTargets[localBranch];
}
}
if (this.targetBranch == null) {
this.targetBranch = await selectTargetBranch({
message: 'Please select the target branch of your Merge Request',
});
}
// User log info
uxLog(this, c.cyan(`This script will prepare the merge request from your local branch ${c.green(localBranch)} to remote ${c.green(this.targetBranch)}`));
// Make sure git is clean before starting operations
await this.cleanGitStatus();
// Make sure commit is ready before starting operations
const orgPullStateRes = await this.ensureCommitIsReady(flags);
if (orgPullStateRes && orgPullStateRes.outputString) {
return orgPullStateRes;
}
// Update package.xml files using sfdx-git-delta
const gitStatusWithConfig = await this.upgradePackageXmlFilesWithDelta();
// Apply cleaning on sources
await this.applyCleaningOnSources();
// Build automated deployment plan
const gitStatusAfterDeployPlan = await this.buildDeploymentPlan();
// Push new commit(s)
await this.manageCommitPush(gitStatusWithConfig, gitStatusAfterDeployPlan);
// Merge request
uxLog(this, c.cyan(`If your work is ${c.bold('completed')}, you can create a ${c.bold('merge request')}:`));
uxLog(this, c.cyan(`- click on the link in the upper text, below ${c.italic('To create a merge request for ' + this.currentBranch + ', visit')}`));
uxLog(this, c.cyan(`- or manually create the merge request on repository UI: ${c.green(this.gitUrl)}`));
// const remote = await git().listRemote();
// const remoteMergeRequest = `${remote.replace('.git','-/merge_requests/new')}`;
// await open(remoteMergeRequest, {wait: true});
uxLog(this, c.cyan(c.bold(`${c.yellow('When your Merge Request will have been merged:')}
- ${c.yellow('DO NOT REUSE THE SAME BRANCH')}
- Use New task menu (sf hardis:work:new), even if you work in the same sandbox or scratch org :)`)));
uxLog(this, c.cyan(`If you are working with a ticketing system like JIRA, try to add the FULL URL of the tickets in the MR/PR description
- Good example: https://sfdx-hardis.atlassian.net/browse/CLOUDITY-4
- Less good example but will work anyway on most cases: CLOUDITY-4
`));
uxLog(this, c.cyan(`Merge request documentation is available here -> ${c.bold(`${CONSTANTS.DOC_URL_ROOT}/salesforce-ci-cd-publish-task/#create-merge-request`)}`));
// Return an object to be displayed with --json
return { outputString: 'Saved the task' };
}
// Clean git status
async cleanGitStatus() {
// Skip git stuff if requested
if (this.noGit) {
uxLog(this, c.cyan(`[Expert mode] Skipped git reset`));
return;
}
let gitStatusInit = await git().status();
// Cancel merge if ongoing merge
if (gitStatusInit.conflicted.length > 0) {
await git({ output: true }).merge(['--abort']);
gitStatusInit = await git().status();
}
// Unstage files
if (gitStatusInit.staged.length > 0) {
await execCommand('git reset', this, { output: true, fail: true });
}
}
async ensureCommitIsReady(flags) {
// Manage project deploy start from scratch org
if (this.noPull || this.auto) {
// Skip pull
uxLog(this, c.cyan(`Skipped sf project:retrieve:start from scratch org`));
return;
}
// Request user if commit is ready
const commitReadyRes = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright('Have you already committed the updated metadata you want to deploy ?'),
choices: [
{
title: '😎 Yes, my commit(s) is ready ! I staged my files then created one or multiple commits !',
value: 'commitReady',
description: "You have already pulled updates from your org (or locally updated the files if you're a nerd) then staged your files and created a commit",
},
{
title: '😐 No, please pull my latest updates from my org so I can commit my metadatas',
value: 'pleasePull',
description: 'Pull latest updates from org so then you can stage files and create your commit',
},
{
title: '😱 What is a commit ? What does mean pull ? Help !',
value: 'help',
description: "Don't panic, just click on the link that will appear in the console (CTRL + Click) and then you will know :)",
},
],
});
if (commitReadyRes.value === 'pleasePull') {
// Process sf project retrieve start
uxLog(this, c.cyan(`Pulling sources from scratch org ${flags['target-org'].getUsername()}...`));
await forceSourcePull(flags['target-org'].getUsername(), this.debugMode);
uxLog(this, c.cyan(`Sources has been pulled from ${flags['target-org'].getUsername()}, now you can stage and commit your updates !`));
return { outputString: 'Pull performed' };
}
else if (commitReadyRes.value === 'help') {
// Show pull commit stage help
const commitHelpUrl = `${CONSTANTS.DOC_URL_ROOT}/hardis/scratch/pull/`;
uxLog(this, c.cyan(`Opening help at ${commitHelpUrl} ...`));
await open(commitHelpUrl, { wait: true });
return { outputString: 'Help displayed at ' };
}
// Extract data from org
const dataSources = [
{
label: 'Email templates',
dataPath: './scripts/data/EmailTemplate',
},
];
for (const dataSource of dataSources) {
if (fs.existsSync(dataSource.dataPath)) {
const exportDataRes = await prompts({
type: 'confirm',
name: 'value',
message: c.cyan(`Did you update ${c.green(dataSource.label)} and want to export related data ?`),
});
if (exportDataRes.value === true) {
await exportData(dataSource.dataPath, this, {
sourceUsername: flags['target-org'].getUsername(),
});
}
}
}
}
async upgradePackageXmlFilesWithDelta() {
// Retrieving info about current branch latest commit and master branch latest commit
const gitDeltaScope = await getGitDeltaScope(this.currentBranch, this.targetBranch || '');
// Build package.xml delta between most recent commit and developpement
const localPackageXml = path.join('manifest', 'package.xml');
const toCommitMessage = gitDeltaScope.toCommit ? gitDeltaScope.toCommit.message : '';
uxLog(this, c.cyan(`Calculating package.xml diff from [${c.green(this.targetBranch)}] to [${c.green(this.currentBranch)} - ${c.green(toCommitMessage)}]`));
const tmpDir = await createTempDir();
const packageXmlResult = await callSfdxGitDelta(gitDeltaScope.fromCommit, gitDeltaScope.toCommit ? gitDeltaScope.toCommit.hash : gitDeltaScope.fromCommit, tmpDir);
if (packageXmlResult.status === 0) {
// Upgrade local destructivePackage.xml
const localDestructiveChangesXml = path.join('manifest', 'destructiveChanges.xml');
if (!fs.existsSync(localDestructiveChangesXml)) {
// Create default destructiveChanges.xml if not defined
const blankDestructiveChanges = `<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<version>${getApiVersion()}</version>
</Package>
`;
await fs.writeFile(localDestructiveChangesXml, blankDestructiveChanges);
}
const diffDestructivePackageXml = path.join(tmpDir, 'destructiveChanges', 'destructiveChanges.xml');
const destructivePackageXmlDiffStr = await fs.readFile(diffDestructivePackageXml, 'utf8');
uxLog(this, c.bold(c.cyan(`destructiveChanges.xml diff to be merged within ${c.green(localDestructiveChangesXml)}:\n`)) +
c.red(destructivePackageXmlDiffStr));
await appendPackageXmlFilesContent([localDestructiveChangesXml, diffDestructivePackageXml], localDestructiveChangesXml);
if ((await gitHasLocalUpdates()) && !this.noGit) {
await git().add(localDestructiveChangesXml);
}
// Upgrade local package.xml
const diffPackageXml = path.join(tmpDir, 'package', 'package.xml');
const packageXmlDiffStr = await fs.readFile(diffPackageXml, 'utf8');
uxLog(this, c.bold(c.cyan(`package.xml diff to be merged within ${c.green(localPackageXml)}:\n`)) +
c.green(packageXmlDiffStr));
await appendPackageXmlFilesContent([localPackageXml, diffPackageXml], localPackageXml);
await removePackageXmlFilesContent(localPackageXml, localDestructiveChangesXml, {
outputXmlFile: localPackageXml,
});
if ((await gitHasLocalUpdates()) && !this.noGit) {
await git().add(localPackageXml);
}
}
else {
uxLog(this, `[error] ${c.grey(JSON.stringify(packageXmlResult))}`);
uxLog(this, c.red(`Unable to build git diff.${c.yellow(c.bold('Please update package.xml and destructiveChanges.xml manually'))}`));
}
// Commit updates
let gitStatusWithConfig = await git().status();
if (gitStatusWithConfig.staged.length > 0 && !this.noGit) {
uxLog(this, `Committing files in local git branch ${c.green(this.currentBranch)}...`);
try {
await git({ output: true }).commit('[sfdx-hardis] Update package content');
}
catch (e) {
uxLog(this, c.yellow(`There may be an issue while committing files but it can be ok to ignore it\n${c.grey(e.message)}`));
gitStatusWithConfig = await git().status();
}
}
return gitStatusWithConfig;
}
// Apply automated cleaning to avoid to have to do it manually
async applyCleaningOnSources() {
const config = await getConfig('branch');
if (!this.noClean) {
const gitStatusFilesBeforeClean = (await git().status()).files.map((file) => file.path);
uxLog(this, JSON.stringify(gitStatusFilesBeforeClean, null, 2));
// References cleaning
uxLog(this, c.cyan('Cleaning sfdx project from obsolete references...'));
// User defined cleaning
await CleanReferences.run(['--type', 'all']);
if (globalThis?.displayProfilesWarning === true) {
uxLog(this, c.yellow(c.bold('Please make sure the attributes removed from Profiles are defined on Permission Sets :)')));
}
uxLog(this, c.cyan('Cleaning sfdx project using patterns and xpaths defined in cleanXmlPatterns...'));
await CleanXml.run([]);
// Manage git after cleaning
const gitStatusAfterClean = await git().status();
uxLog(this, JSON.stringify(gitStatusAfterClean, null, 2));
const cleanedFiles = gitStatusAfterClean.files
.filter((file) => !gitStatusFilesBeforeClean.includes(file.path))
.map((file) => normalizeFileStatusPath(file.path, config));
if (cleanedFiles.length > 0) {
uxLog(this, c.cyan(`Cleaned the following list of files:\n${cleanedFiles.join('\n')}`));
if (!this.noGit) {
try {
await git().add(cleanedFiles);
await git({ output: true }).commit('[sfdx-hardis] Clean sfdx project');
}
catch (e) {
uxLog(this, c.yellow(`There may be an issue while adding cleaned files but it can be ok to ignore it\n${c.grey(e.message)}`));
}
}
}
}
}
async buildDeploymentPlan() {
// Build deployment plan splits
let splitConfig = await this.getSeparateDeploymentsConfig();
const localPackageXml = path.join('manifest', 'package.xml');
const packageXml = await parseXmlFile(localPackageXml);
for (const type of packageXml.Package.types || []) {
const typeName = type.name[0];
splitConfig = splitConfig.map((split) => {
if (split.types.includes(typeName) && type.members[0] !== '*') {
split.content[typeName] = type.members;
}
return split;
});
}
// Generate deployment plan items
const config = await getConfig('project');
const deploymentPlan = config?.deploymentPlan || {};
let packages = deploymentPlan?.packages || [];
const blankPackageXml = packageXml;
blankPackageXml.Package.types = [];
for (const split of splitConfig) {
if (Object.keys(split.content).length > 0) {
// data case
if (split.data) {
const label = `Import ${split.types.join('-')} records`;
packages = this.addToPlan(packages, {
label: label,
dataPath: split.data,
order: split.dataPos,
waitAfter: split.waitAfter,
});
}
// single split file case
if (split.file) {
const splitPackageXml = blankPackageXml;
blankPackageXml.Package.types = [];
for (const type of Object.keys(split.content)) {
splitPackageXml.Package.types.push({
name: [type],
members: split.content[type],
});
}
await writeXmlFile(split.file, splitPackageXml);
const label = `Deploy ${split.types.join('-')}`;
packages = this.addToPlan(packages, {
label: label,
packageXmlFile: split.file,
order: split.filePos,
waitAfter: split.waitAfter,
});
}
// Multiple split file case
if (split.files) {
let pos = split.filePos;
for (const mainTypeMember of split.content[split.mainType] || []) {
const splitFile = split.files.replace(`{{name}}`, mainTypeMember);
const splitPackageXml = blankPackageXml;
blankPackageXml.Package.types = [];
for (const type of Object.keys(split.content)) {
if (type !== split.mainType) {
const filteredMembers = split.content[type].filter((member) => member.includes(`${mainTypeMember}.`));
splitPackageXml.Package.types.push({
name: [type],
members: filteredMembers,
});
}
}
splitPackageXml.Package.types.push({
name: [split.mainType],
members: [mainTypeMember],
});
await writeXmlFile(splitFile, splitPackageXml);
const label = `Deploy ${split.mainType} - ${mainTypeMember}`;
packages = this.addToPlan(packages, {
label: label,
packageXmlFile: splitFile,
order: pos,
waitAfter: split.waitAfter,
});
pos++;
}
}
}
}
// Update deployment plan in config
deploymentPlan.packages = packages.sort((a, b) => (a.order > b.order ? 1 : -1));
await setConfig('project', { deploymentPlan: deploymentPlan });
if (!this.noGit) {
await git({ output: true }).add(['./config']);
await git({ output: true }).add(['./manifest']);
}
let gitStatusAfterDeployPlan = await git().status();
if (gitStatusAfterDeployPlan.staged.length > 0 && !this.noGit) {
try {
await git({ output: true }).commit('[sfdx-hardis] Update deployment plan');
}
catch (e) {
uxLog(this, c.yellow(`There may be an issue while committing files but it can be ok to ignore it\n${c.grey(e.message)}`));
gitStatusAfterDeployPlan = await git().status();
}
}
return gitStatusAfterDeployPlan;
}
// Manage push from user
async manageCommitPush(gitStatusWithConfig, gitStatusAfterDeployPlan) {
if ((gitStatusWithConfig.staged.length > 0 ||
gitStatusAfterDeployPlan.staged.length > 0 ||
gitStatusAfterDeployPlan?.ahead > 0 ||
gitStatusAfterDeployPlan.tracking == null) &&
!this.noGit &&
!this.auto) {
const pushResponse = await prompts({
type: 'confirm',
name: 'push',
default: true,
message: c.cyanBright(`Do you want to push your commit(s) on git server ? (git push in remote git branch ${c.green(this.currentBranch)})`),
});
if (pushResponse.push === true) {
uxLog(this, c.cyan(`Pushing new commit(s) in remote git branch ${c.green(`origin/${this.currentBranch}`)}...`));
const configUSer = await getConfig('user');
let pushResult;
if (configUSer.canForcePush === true) {
// Force push if hardis:work:resetselection has been called before
pushResult = await git({ output: true }).push(['-u', 'origin', this.currentBranch, '--force']);
await setConfig('user', { canForcePush: false });
}
else {
pushResult = await git({ output: true }).push(['-u', 'origin', this.currentBranch]);
}
// Update merge request info
if (pushResult && pushResult.remoteMessages) {
let mergeRequestsStored = configUSer.mergeRequests || [];
if (mergeRequestsStored.filter((mergeRequest) => mergeRequest?.branch === this.currentBranch).length === 1) {
mergeRequestsStored = mergeRequestsStored.map((mergeRequestStored) => {
if (mergeRequestStored?.branch === this.currentBranch) {
return this.updateMergeRequestInfo(mergeRequestStored, pushResult);
}
});
}
else {
mergeRequestsStored.push(this.updateMergeRequestInfo({ branch: this.currentBranch }, pushResult));
}
// Update user config file & send Websocket event
await setConfig('user', { mergeRequests: mergeRequestsStored.filter((mr) => mr !== null) });
WebSocketClient.sendMessage({ event: 'refreshStatus' });
}
}
}
}
updateMergeRequestInfo(mergeRequestStored, mergeRequestInfo) {
if (this.debugMode) {
uxLog(this, c.grey(JSON.stringify(mergeRequestInfo, null, 2)));
}
if (mergeRequestInfo?.remoteMessages?.id) {
mergeRequestStored.id = mergeRequestInfo.remoteMessages.id;
}
else {
delete mergeRequestStored.id;
}
if (mergeRequestInfo?.remoteMessages?.pullRequestUrl) {
mergeRequestStored.urlCreate = mergeRequestInfo.remoteMessages.pullRequestUrl;
}
else {
delete mergeRequestStored.urlCreate;
}
if (mergeRequestInfo?.remoteMessages?.all[0] &&
mergeRequestInfo?.remoteMessages?.all[0].includes('View merge request')) {
mergeRequestStored.url = mergeRequestInfo?.remoteMessages?.all[1];
}
else {
delete mergeRequestStored.url;
}
return mergeRequestStored;
}
async getSeparateDeploymentsConfig() {
const config = await getConfig('project');
if (config.separateDeploymentsConfig || config.separateDeploymentsConfig === false) {
return config.separateDeploymentConfig || [];
}
const separateDeploymentConfig = [
/* NV: Commented because seems to be now useless
{
types: ["EmailTemplate"],
file: "manifest/splits/packageXmlEmails.xml",
filePos: -20,
data: "scripts/data/EmailTemplate",
dataPos: -21,
content: {},
},
{
types: ["Flow", "Workflow"],
file: "manifest/splits/packageXmlFlowWorkflow.xml",
filePos: 6,
content: {},
}, */
{
types: ['SharingRules', 'SharingOwnerRule'],
files: 'manifest/splits/packageXmlSharingRules{{name}}.xml',
filePos: 30,
mainType: 'SharingRules',
waitAfter: 30,
content: {},
},
];
return separateDeploymentConfig;
}
// Add item to .sfdx-hardis.yml deploymentPlan property
addToPlan(packages, item) {
let updated = false;
if (item.waitAfter === null) {
delete item.waitAfter;
}
packages = packages.map((pckg) => {
if (pckg.label === item.label) {
pckg = item;
updated = true;
}
return pckg;
});
if (updated === false) {
packages.push(item);
}
return packages;
}
}
//# sourceMappingURL=save.js.map