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
486 lines (474 loc) • 26.1 kB
JavaScript
/* jscpd:ignore-start */
import { SfCommand, Flags, optionalHubFlagWithDeprecations, optionalOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core';
import { Messages, SfError } from '@salesforce/core';
import c from 'chalk';
import * as path from 'path';
import { MetadataUtils } from '../../../common/metadata-utils/index.js';
import { checkGitClean, ensureGitBranch, execCommand, execSfdxJson, getGitRepoUrl, gitCheckOutRemote, gitPull, uxLog, } from '../../../common/utils/index.js';
import { selectTargetBranch } from '../../../common/utils/gitUtils.js';
import { initApexScripts, initOrgData, initOrgMetadatas, initPermissionSetAssignments, installPackages, makeSureOrgIsConnected, promptOrg, } from '../../../common/utils/orgUtils.js';
import { prompts } from '../../../common/utils/prompts.js';
import { WebSocketClient } from '../../../common/websocketClient.js';
import { CONSTANTS, getConfig, setConfig } from '../../../config/index.js';
import SandboxCreate from '../org/create.js';
import ScratchCreate from '../scratch/create.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class NewTask extends SfCommand {
static title = 'New User Story';
static description = `
## Command Behavior
**Assisted menu to start working on a Salesforce User Story, streamlining the setup of your development environment.**
This command guides you through the process of preparing your local environment and a Salesforce org for a new development or configuration based User Story. It automates several steps, ensuring consistency and adherence to project standards.
Key features include:
- **Git Branch Management:** Ensures your local Git repository is up-to-date with the target branch and creates a new Git branch with a formatted name based on your User Story details. Branch naming conventions can be customized via the \`branchPrefixChoices\` property in \`.sfdx-hardis.yml\`.
- **Org Provisioning & Initialization:** Facilitates the creation and initialization of either a scratch org or a source-tracked sandbox. The configuration for org initialization (e.g., package installation, source push, permission set assignments, Apex script execution, data loading) can be defined in \`config/.sfdx-hardis.yml\
- **Project-Specific Configuration:** Supports defining multiple target branches (\`availableTargetBranches\`) and projects (\`availableProjects\`) in \`.sfdx-hardis.yml\`, allowing for tailored User Stories workflows.
- **User Story Name Validation:** Enforces User Story name formatting using \`newTaskNameRegex\` and provides examples via \`newTaskNameRegexExample\
- **Shared Development Sandboxes:** Accounts for scenarios with shared development sandboxes, adjusting prompts to prevent accidental overwrites.
Advanced instructions are available in the [Create New User Story documentation](${CONSTANTS.DOC_URL_ROOT}/salesforce-ci-cd-create-new-task/).
<details markdown="1">
<summary>Technical explanations</summary>
The command's logic orchestrates various underlying processes:
- **Git Operations:** Utilizes \`checkGitClean\`, \`ensureGitBranch\`, \`gitCheckOutRemote\`, and \`git().pull()\` to manage Git repository state and branches.
- **Interactive Prompts:** Leverages the \`prompts\` library to gather user input for User Story type, source types, and User Story names.
- **Configuration Management:** Reads and applies project-specific configurations from \`.sfdx-hardis.yml\` using \`getConfig\` and \`setConfig\
- **Org Initialization Utilities:** Calls a suite of utility functions for org setup, including \`initApexScripts\`, \`initOrgData\`, \`initOrgMetadatas\`, \`initPermissionSetAssignments\`, \`installPackages\`, and \`makeSureOrgIsConnected\
- **Salesforce CLI Interaction:** Executes Salesforce CLI commands (e.g., \`sf config set target-org\`, \`sf org open\`, \`sf project delete tracking\`) via \`execCommand\` and \`execSfdxJson\
- **Dynamic Org Selection:** Presents choices for scratch orgs or sandboxes based on project configuration and existing orgs, dynamically calling \`ScratchCreate.run\` or \`SandboxCreate.run\` as needed.
- **WebSocket Communication:** Sends refresh status messages via \`WebSocketClient.sendRefreshStatusMessage()\` to update connected VS Code clients.
</details>
`;
static examples = ['$ sf hardis:work:new'];
// public static args = [{name: 'file'}];
static flags = {
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-dev-hub': optionalHubFlagWithDeprecations,
'target-org': optionalOrgFlagWithDeprecations
};
// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
static requiresProject = true;
targetBranch;
debugMode = false;
/* jscpd:ignore-end */
async run() {
const { flags } = await this.parse(NewTask);
this.debugMode = flags.debug || false;
uxLog("action", this, c.cyan('Creating a new User Story (dev or config) with SFDX Hardis CI/CD'));
uxLog("log", this, c.grey("When unsure, press ENTER to use the default value"));
// Make sure the git status is clean, to not delete uncommitted updates
await checkGitClean({ allowStash: true });
const config = await getConfig('project');
this.targetBranch = await selectTargetBranch();
const defaultBranchPrefixChoices = [
{
title: '🏗️ Feature',
value: 'feature',
description: "New feature, evolution of an existing feature... If you don't know, just select Feature",
},
{
title: '🛠️ Fix',
value: 'fix',
description: 'A bug has been identified and you are the right person to solve it !',
},
];
const branchPrefixChoices = config.branchPrefixChoices || defaultBranchPrefixChoices;
// Select project if multiple projects are defined in availableProjects .sfdx-hardis.yml property
let projectBranchPart = '';
const availableProjects = config.availableProjects || [];
if (availableProjects.length > 1) {
const projectResponse = await prompts({
type: 'select',
name: 'project',
message: c.cyanBright('Please select the project your User Story is for'),
description: 'Choose which project this new work item belongs to',
placeholder: 'Select a project',
choices: availableProjects.map((project) => {
return {
title: project.includes(',') ? project.split(',').join(' - ') : project,
value: project.includes(',') ? project.split(',')[0] : project,
};
}),
});
projectBranchPart = projectResponse.project + '/';
}
// Request info to build branch name. ex features/config/MYTASK
const response = await prompts([
{
type: 'select',
name: 'branch',
message: c.cyanBright('What type of User Story do you want to create?'),
description: 'Select the category of work that best describes your User Story',
placeholder: 'Select User Story type',
initial: 0,
choices: branchPrefixChoices,
},
]);
// Request task name
const taskName = await this.promptTaskName(config.newTaskNameRegex || null, config.newTaskNameRegexExample || null);
// Checkout development main branch
const branchName = `${projectBranchPart}${response.branch || 'feature'}/${taskName}`;
const repoUrl = await getGitRepoUrl();
uxLog("action", this, c.cyan(`Checking out latest version of branch ${c.bold(this.targetBranch)} from ${repoUrl}...`));
await gitCheckOutRemote(this.targetBranch);
// Pull latest version of target branch
await gitPull();
// Create new branch
uxLog("action", this, c.cyan(`Creating new branch ${c.green(branchName)}...`));
await ensureGitBranch(branchName);
// Update config if necessary
if (config.developmentBranch !== this.targetBranch && (config.availableTargetBranches || null) == null) {
const updateDefaultBranchRes = await prompts({
type: 'confirm',
name: 'value',
message: c.cyanBright(`Do you want to update your default target branch to ${c.green(this.targetBranch)}?`),
description: 'Set this branch as your default target for future work items',
default: false,
});
if (updateDefaultBranchRes.value === true) {
await setConfig('user', { developmentBranch: this.targetBranch });
}
}
// Update local user config files to store the target of the just created branch
const currentUserConfig = await getConfig('user');
const localStorageBranchTargets = currentUserConfig.localStorageBranchTargets || {};
localStorageBranchTargets[branchName] = this.targetBranch;
await setConfig('user', { localStorageBranchTargets: localStorageBranchTargets });
// Get allowed work org types from config if possible
const allowedOrgTypes = config?.allowedOrgTypes || [];
let selectedOrgType = allowedOrgTypes.length == 1 ? allowedOrgTypes[0] : null;
// If necessary, Prompt if you want to use a scratch org or a tracked sandbox org, or no org
const orgTypeChoices = [];
if (allowedOrgTypes.includes('sandbox') || allowedOrgTypes.length === 0) {
orgTypeChoices.push({
title: '🌎 Sandbox org with source tracking',
value: 'sandbox',
description: "Work in a developer sandbox provided by your Release Manager",
});
}
if (allowedOrgTypes.includes('scratch') || allowedOrgTypes.length === 0) {
orgTypeChoices.push({
title: '🪐 Scratch org',
value: 'scratch',
description: 'Scratch orgs are configured on my project so I want to create or reuse one',
});
}
if (flags['target-org'] && flags['target-org']?.getConnection()) {
orgTypeChoices.push({
title: `😎 Current org ${flags['target-org']?.getConnection().instanceUrl.replace("https://", "")}`,
value: 'currentOrg',
description: `Use your default org with username ${flags['target-org']?.getUsername()}`,
});
}
orgTypeChoices.push({
title: "🤠 I'm hardcore, I don't need an org !",
value: 'noOrg',
description: 'Work with XML and sfdx-hardis configuration only, without a connected org',
});
const orgTypeResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Which Salesforce org do you want to work in?`),
description: 'Choose the type of Salesforce org to use for your development work',
placeholder: 'Select org type',
initial: 0,
choices: orgTypeChoices,
});
selectedOrgType = orgTypeResponse.value;
// Select or create org that user will work in
if (selectedOrgType === 'scratch') {
// scratch org
await this.selectOrCreateScratchOrg(branchName, flags);
}
else if (selectedOrgType === 'sandbox' || selectedOrgType === 'currentOrg') {
// source tracked sandbox
await this.selectOrCreateSandbox(branchName, config, flags, selectedOrgType);
}
else {
uxLog("warning", this, c.yellow(`No org selected. Ensure you know what you're doing.`));
}
uxLog("action", this, c.cyan(`Ready to work in branch ${c.green(branchName)}`));
// Return an object to be displayed with --json
return { outputString: 'Created new User Story' };
}
async promptTaskName(validationRegex, taskNameExample) {
if (taskNameExample == null) {
taskNameExample = 'MYPROJECT-123 Update account status validation rule';
}
const taskResponse = await prompts({
type: 'text',
name: 'taskName',
message: c.cyanBright(`What is the name of your new User Story? Please avoid accents and special characters.`),
description: 'Enter a descriptive name for your User Story that will be used in the git branch name',
placeholder: `Ex: ${taskNameExample}`,
});
let taskName = taskResponse.taskName.replace(/[^a-zA-Z0-9 -]|\s/g, '-');
// If there are multiple "-" , replace by single "-", otherwise it messes with mermaid diagrams
taskName = taskName.replace(/-+/g, '-');
if (validationRegex != null && !new RegExp(validationRegex).test(taskName)) {
uxLog("warning", this, c.yellow(`The User Story name ${c.bold(taskName)} does not match the expected pattern ${c.bold(validationRegex)}. Please try again`));
return this.promptTaskName(validationRegex, taskNameExample);
}
return taskName;
}
// Select/Create scratch org
async selectOrCreateScratchOrg(branchName, flags) {
const hubOrgUsername = flags['target-dev-hub'].getUsername();
const scratchOrgList = await MetadataUtils.listLocalOrgs('scratch', { devHubUsername: hubOrgUsername });
const currentOrg = await MetadataUtils.getCurrentOrg();
const baseChoices = [
{
title: c.yellow('🆕 Create new scratch org'),
value: 'newScratchOrg',
description: "Generate a new scratch org; you'll be ready to work in a few minutes",
},
];
if (currentOrg) {
baseChoices.push({
title: c.yellow(`♻️ Reuse current org`),
value: currentOrg,
description: `Reuse current org ${currentOrg.instanceUrl}. Beware of conflicts if others have merged changes.`,
});
}
const scratchResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Select a scratch org for branch ${c.green(branchName)}`),
description: 'Choose whether to create a new scratch org or reuse an existing one',
placeholder: 'Select scratch org option',
initial: 0,
choices: [
...baseChoices,
...scratchOrgList.map((scratchOrg) => {
return {
title: `☁️ Reuse scratch org ${c.yellow(scratchOrg.alias)}`,
description: scratchOrg.instanceUrl,
value: scratchOrg,
};
}),
],
});
if (scratchResponse.value === 'newScratchOrg') {
await setConfig('user', {
scratchOrgAlias: null,
scratchOrgUsername: null,
});
// Check if DevHub is connected
await this.config.runHook('auth', {
Command: this,
devHub: true,
scratch: false,
});
// Create scratch org
const config = await getConfig();
const createResult = await ScratchCreate.run(['--forcenew', '--targetdevhubusername', config.devHubAlias]);
if (createResult == null) {
throw new SfError('Unable to create scratch org');
}
}
else {
// Set selected org as default org
await execCommand(`sf config set target-org=${scratchResponse.value.username}`, this, {
output: true,
fail: true,
});
uxLog("action", this, c.cyan(`Selected scratch org ${c.green(scratchResponse.value.instanceUrl)} with user ${c.green(scratchResponse.value.username)}`));
// Open selected org
uxLog("action", this, c.cyan('Opening scratch org in browser...'));
await execSfdxJson('sf org open', this, {
fail: true,
output: false,
debug: this.debugMode,
});
// Trigger a status refresh on VS Code WebSocket Client
WebSocketClient.sendRefreshStatusMessage();
}
}
// Select or create sandbox
async selectOrCreateSandbox(branchName, config, flags, selectedOrgType) {
let openOrg = false;
let orgUsername = "";
if (selectedOrgType === "currentOrg") {
openOrg = true;
orgUsername = flags['target-org'].getUsername();
await makeSureOrgIsConnected(orgUsername);
}
else {
const promptRes = await this.promptSandbox(flags, branchName);
orgUsername = promptRes.orgUsername;
openOrg = promptRes.openOrg;
}
// Initialize / Update existing sandbox if available
if (!(config.sharedDevSandboxes === true)) {
const initSandboxResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Do you want to update the sandbox to match branch "${this.targetBranch}" current state?`),
description: 'Choose whether to sync your sandbox with the latest changes from the target branch (packages, sources, permission sets, apex scripts, initial data)',
placeholder: 'Select sync option',
choices: [
{
title: '🧑🤝🧑 No, continue working on my current sandbox state',
value: 'no',
description: 'Use if multiple users share the same sandbox, or if you have uncommitted changes',
},
{
title: '☢️ Yes, please try to update my sandbox !',
value: 'init',
description: `Integrate new updates from the parent branch "${this.targetBranch}" before working on your new User Story. WARNING: Will overwrite uncommitted changes in your org !`,
},
],
});
let initSandbox = initSandboxResponse.value === 'init';
// Ask the user if he's really sure of what he's doing !
if (initSandbox) {
const promptConfirm = await prompts({
type: 'confirm',
message: c.cyanBright(`Confirm: Update dev sandbox with branch ${this.targetBranch} state? This will overwrite uncommitted changes by you or other users.`),
description: 'Confirm that you want to reset your sandbox to match the target branch state',
});
initSandbox = promptConfirm.value === true;
}
if (initSandbox) {
let initSourcesErr = null;
let initSandboxErr = null;
try {
if (config.installedPackages) {
await installPackages(config.installedPackages || [], orgUsername);
}
try {
// Continue initialization even if push did not work... it could work and be not such a problem 😊
uxLog("action", this, c.cyan('Resetting local SF Cli tracking...'));
await execCommand(`sf project delete tracking --no-prompt -o ${orgUsername}`, this, {
fail: false,
output: true,
});
await initOrgMetadatas(config, orgUsername, orgUsername, {}, this.debugMode, { scratch: false });
}
catch (e1) {
initSourcesErr = e1;
}
await initPermissionSetAssignments(config.initPermissionSets || [], orgUsername);
await initApexScripts(config.scratchOrgInitApexScripts || [], orgUsername);
await initOrgData(path.join('.', 'scripts', 'data', 'ScratchInit'), orgUsername);
}
catch (e) {
initSandboxErr = e;
}
if (initSandboxErr) {
uxLog("log", this, c.grey('Error(s) while initializing sandbox: ' + initSandboxErr.message + '\n' + initSandboxErr.stack));
uxLog("warning", this, c.yellow('Sandbox may not be fully initialized from git. Share the error above with your release manager.'));
}
if (initSourcesErr) {
uxLog("log", this, c.grey('Error(s) while pushing sources to sandbox: ' + initSourcesErr.message + '\n' + initSourcesErr.stack));
uxLog("warning", this, c.yellow(`To sync sandbox with branch ${c.bold(this.targetBranch)}:
- ${c.bold('Fix the errors')} (manually update target sandbox in setup), then run "New User Story" again with same sandbox
- ${c.bold('Refresh your sandbox')} (contact release manager if needed)
Otherwise, start working now (beware of potential conflicts)
`));
}
}
}
// Open of if not already open
if (openOrg === true) {
const openOrgRes = await prompts({
type: 'confirm',
name: 'value',
message: c.cyanBright(`Do you want to open org ${c.green(orgUsername)} in your browser?`),
description: 'Open the sandbox org in your web browser to start working on it',
initial: true
});
if (openOrgRes.value === true) {
uxLog("action", this, c.cyan(`Opening org ${c.green(orgUsername)}...`));
await execSfdxJson('sf org open', this, {
fail: true,
output: false,
debug: this.debugMode,
});
}
}
// Trigger a status refresh on VS Code WebSocket Client
WebSocketClient.sendRefreshStatusMessage();
}
async promptSandbox(flags, branchName) {
const hubOrgUsername = flags['target-dev-hub']?.getUsername();
const sandboxOrgList = await MetadataUtils.listLocalOrgs('devSandbox', { devHubUsername: hubOrgUsername });
const defaultSandbox = sandboxOrgList.find((org) => {
return org.username === flags['target-org']?.getUsername();
});
const sandboxResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Select a sandbox org to work in branch ${c.green(branchName)}`),
description: 'Choose an existing sandbox or connect to a new one for this branch',
placeholder: 'Select sandbox',
default: defaultSandbox ? defaultSandbox : undefined,
choices: [
...[
{
title: c.yellow('🌐 Connect to a sandbox not in this list'),
description: 'Login via web browser to your source-tracked sandbox',
value: 'connectSandbox',
},
/* {
title: c.yellow("Create new sandbox from another sandbox or production org (ALPHA -> UNSTABLE, DO NOT USE YET)"),
value: "newSandbox",
}, */
],
...sandboxOrgList.map((sandboxOrg) => {
return {
title: sandboxOrg.instanceUrl,
description: `☁️ Use sandbox org ${c.yellow(sandboxOrg.username || sandboxOrg.alias)}`,
value: sandboxOrg,
};
}),
],
});
// Remove scratch org info in user config if necessary
const config = await getConfig("user");
if (config.scratchOrgAlias || config.scratchOrgUsername) {
await setConfig('user', {
scratchOrgAlias: null,
scratchOrgUsername: null,
});
}
// Connect to a sandbox
let orgUsername = '';
let openOrg = false;
if (sandboxResponse.value === 'connectSandbox') {
const slctdOrg = await promptOrg(this, { setDefault: true, devSandbox: true });
orgUsername = slctdOrg.username;
}
// Create a new sandbox ( NOT WORKING YET, DO NOT USE)
else if (sandboxResponse.value === 'newSandbox') {
const createResult = await SandboxCreate.run();
if (createResult == null) {
throw new SfError('Unable to create sandbox org');
}
orgUsername = createResult.username;
}
// Selected sandbox from list
else {
await makeSureOrgIsConnected(sandboxResponse.value);
uxLog("action", this, c.cyan(`Setting ${c.green(sandboxResponse.value.instanceUrl)} (${sandboxResponse.value.username}) as default org...`));
await execCommand(`sf config set target-org=${sandboxResponse.value.username}`, this, {
output: true,
fail: true,
});
orgUsername = sandboxResponse.value.username;
openOrg = true;
}
return { orgUsername, openOrg };
}
}
//# sourceMappingURL=new.js.map