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
529 lines (504 loc) β’ 25.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, git, gitCheckOutRemote, 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 work task';
static description = `Assisted menu to start working on a Salesforce task.
Advanced instructions in [Create New Task documentation](${CONSTANTS.DOC_URL_ROOT}/salesforce-ci-cd-create-new-task/)
At the end of the command, it will allow you to work on either a scratch org or a sandbox, depending on your choices.
Under the hood, it can:
- Make **git pull** to be up to date with target branch
- Create **new git branch** with formatted name (you can override the choices using .sfdx-hardis.yml property **branchPrefixChoices**)
- Create and initialize a scratch org or a source-tracked sandbox (config can be defined using \`config/.sfdx-hardis.yml\`):
- (and for scratch org only for now):
- **Install packages**
- Use property \`installedPackages\`
- **Push sources**
- **Assign permission sets**
- Use property \`initPermissionSets\`
- **Run apex initialization scripts**
- Use property \`scratchOrgInitApexScripts\`
- **Load data**
- Use property \`dataPackages\`
## Override .sfdx-hardis.yml config
### availableTargetBranches
By default, there is only one target branch (value of property **developmentBranch**).
You can define multiple target branches (for the future Pull Request) by setting the property **availableTargetBranches** in your .sfdx-hardis.yml file.
The selected branch will checked out and be used as base to create the user new feature branch.
Examples:
\`\`\`yaml
availableTargetBranches:
- integration
- preprod
\`\`\`
\`\`\`yaml
availableTargetBranches:
- integration,Select this to work from the integration branch (project stream)
- preprod,Select this to work from the preprod branch (run stream)
\`\`\`
### availableProjects
You can add a first question "What is the project your task is for" if you define a property **availableProjects**
The select will be used as first part of the git branch name. (ex: france/features/dev/JIRA123-webservice-get-account)
Examples:
\`\`\`yaml
availableProjects:
- build
- run
- some-big-project
- france
- uk
\`\`\`
\`\`\`yaml
availableProjects:
- build,Select this to work on the build project
- run,Select this to work on the run project
- some-big-project,Select this to work on the some big project
- france,Select this to work on the France project
- uk,Select this to work on the UK project
\`\`\`
### newTaskNameRegex
If you want to force a specific format for the task name, you can define a property **newTaskNameRegex** in your .sfdx-hardis.yml file.
Please also define a property **newTaskNameRegexExample** to give an example to the user.
Example:
\`\`\`yaml
newTaskNameRegex: '^[A-Z]+-[0-9]+ .*'
newTaskNameRegexExample: 'MYPROJECT-123 Update account status validation rule'
\`\`\`
### sharedDevSandboxes
If contributors can share dev sandboxes, let's not ask them if they want to overwrite their colleagues' changes when creating a new task :)
`;
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(this, c.cyan('This tool will assist you to create a new task (dev or config) with Hardis CI/CD'));
uxLog(this, c.cyan("When you don't know what to answer, you can let the default value and push ENTER"));
// 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: 'features',
description: "New feature, evolution of an existing feature... If you don't know, just select Feature",
},
{
title: 'π οΈ Debug',
value: 'fixes',
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 task is for'),
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 is the type of the task you want to do ?'),
initial: 0,
choices: branchPrefixChoices,
},
{
type: 'select',
name: 'sources',
message: c.cyanBright('What type(s) of Salesforce updates will you have to perform for this task ?'),
initial: 0,
choices: [
{
title: 'π₯Έ Configuration',
value: 'config',
description: 'You will update anything in the setup except apex code :)',
},
{
title: 'π€ Development',
value: 'dev',
description: 'You are a developer who will do magic with Apex or Javascript !',
},
{
title: 'π₯Έπ€ Configuration + Development',
value: 'dev',
description: 'Like the unicorn you are, you will update configuration but also write code :)',
},
],
}
]);
// Request task name
const taskName = await this.promptTaskName(config.newTaskNameRegex || null, config.newTaskNameRegexExample || null);
// Checkout development main branch
const branchName = `${projectBranchPart}${response.branch || 'features'}/${response.sources || 'dev'}/${taskName}`;
uxLog(this, c.cyan(`Checking out the most recent version of branch ${c.bold(this.targetBranch)} from git server...`));
await gitCheckOutRemote(this.targetBranch);
// Pull latest version of target branch
await git().pull();
// Create new branch
uxLog(this, c.cyan(`Creating new git 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 git branch to ${c.green(this.targetBranch)} ?`),
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: "Release manager told me that I can work on Sandboxes on my project so let's use fresh dedicated one",
});
}
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: `Your default org with username ${flags['target-org']?.getUsername()} is already the org you want to use to work :)`,
});
}
orgTypeChoices.push({
title: "π€ I'm hardcore, I don't need an org !",
value: 'noOrg',
description: 'You just want to play with XML and sfdx-hardis configuration, and you know what you are doing !',
});
const orgTypeResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Do you want to use a scratch org or a tracked sandbox org ?`),
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(this, c.yellow(`No org selected... I hope you know what you are doing, don't break anything :)`));
}
uxLog(this, c.cyan(`You are now ready to work in branch ${c.green(branchName)} :)`));
// Return an object to be displayed with --json
return { outputString: 'Created new task' };
}
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 task ? (example: ${taskNameExample}). Please avoid accents or special characters`),
});
const taskName = taskResponse.taskName.replace(/[^a-zA-Z0-9 -]|\s/g, '-');
if (validationRegex != null && !new RegExp(validationRegex).test(taskName)) {
uxLog(this, c.yellow(`The task 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: "This will generate a new scratch org, and in a few minutes you'll be ready to work",
},
];
if (currentOrg) {
baseChoices.push({
title: c.yellow(`β»οΈ Reuse current org`),
value: currentOrg,
description: `This will reuse current org ${currentOrg.instanceUrl}. Beware of conflicts if others merged merge requests :)`,
});
}
const scratchResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Please select a scratch org to use for your branch ${c.green(branchName)}`),
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(this, c.cyan(`Selected and opening scratch org ${c.green(scratchResponse.value.instanceUrl)} with user ${c.green(scratchResponse.value.username)}`));
// Open selected org
await execSfdxJson('sf org open', this, {
fail: true,
output: false,
debug: this.debugMode,
});
// Trigger a status refresh on VsCode WebSocket Client
WebSocketClient.sendMessage({ event: 'refreshStatus' });
}
}
// 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 according to git branch "${this.targetBranch}" current state ? (packages,SOURCES,permission set assignments,apex scripts,initial data)`),
choices: [
{
title: 'π§βπ€βπ§ No, continue working on my current sandbox state',
value: 'no',
description: 'Use if you are multiple users in the same SB, or have have uncommitted changes in your sandbox',
},
{
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 task. 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(`Are you really sure you want to update the dev sandbox with the state of git branch ${this.targetBranch} ? This will overwrite setup updates that you or other users have not committed yet`),
});
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(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(this, c.grey('Error(s) while initializing sandbox: ' + initSandboxErr.message + '\n' + initSandboxErr.stack));
uxLog(this, c.yellow('Your sandbox may not be completely initialized from git. You can send the error above to your release manager'));
}
if (initSourcesErr) {
uxLog(this, c.grey('Error(s) while pushing sources to sandbox: ' + initSourcesErr.message + '\n' + initSourcesErr.stack));
uxLog(this, c.yellow(`If you really want your sandbox to be up to date with branch ${c.bold(this.targetBranch)}, you may:
- ${c.bold('Fix the errors')} (probably by manually updating the target sandbox in setup), then run new task again and select again the same sandbox
- ${c.bold('Refresh your sandbox')} (ask your release manager if you don't know how)
Else, you can start working now (but beware of conflicts ^^):)
`));
}
}
}
// Open of if not already open
if (openOrg === true) {
await execSfdxJson('sf org open', this, {
fail: true,
output: false,
debug: this.debugMode,
});
}
// Trigger a status refresh on VsCode WebSocket Client
WebSocketClient.sendMessage({ event: 'refreshStatus' });
}
async promptSandbox(flags, branchName) {
const hubOrgUsername = flags['target-dev-hub']?.getUsername();
const sandboxOrgList = await MetadataUtils.listLocalOrgs('devSandbox', { devHubUsername: hubOrgUsername });
const sandboxResponse = await prompts({
type: 'select',
name: 'value',
message: c.cyanBright(`Please select a sandbox org to use for your branch ${c.green(branchName)} (if you want to avoid conflicts, you should often refresh your sandbox)`),
initial: 0,
choices: [
...[
{
title: c.yellow('π Connect to a sandbox not appearing in this list'),
description: 'Login in 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: `βοΈ Use sandbox org ${c.yellow(sandboxOrg.username || sandboxOrg.alias)}`,
description: sandboxOrg.instanceUrl,
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);
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