Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wr/delete #199

Merged
merged 12 commits into from
Sep 21, 2021
17 changes: 17 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
"plugin": "@salesforce/plugin-source",
"flags": ["json", "loglevel", "manifest", "metadata", "outputdir", "packagename", "rootdir", "sourcepath"]
},
{
"command": "force:source:delete",
"plugin": "@salesforce/plugin-source",
"flags": [
"apiversion",
"checkonly",
"json",
"loglevel",
"metadata",
"noprompt",
"sourcepath",
"targetusername",
"testlevel",
"verbose",
"wait"
]
},
{
"command": "force:source:deploy",
"plugin": "@salesforce/plugin-source",
Expand Down
18 changes: 18 additions & 0 deletions messages/delete.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"description": "delete source from your project and from a non-source-tracked org \n Use this command to delete components from orgs that don’t have source tracking.\nTo remove deleted items from scratch orgs, which have change tracking, use \"sfdx force:source:push\".",
"examples": ["$ sfdx force:source:delete -m <metadata>", "$ sfdx force:source:delete -p path/to/source"],
"flags": {
"sourcepath": "comma-separated list of source file paths to delete",
"metadata": "comma-separated list of names of metadata components to delete",
"noprompt": "do not prompt for delete confirmation",
"wait": "wait time for command to finish in minutes",
"checkonly": "validate delete command but do not delete from the org or delete files locally",
"testLevel": "deployment testing level",
"runTests": "tests to run if --testlevel RunSpecifiedTests",
"verbose": "verbose output of delete result",

"checkonlyLong": "Validates the deleted metadata and runs all Apex tests, but prevents the deletion from being saved to the org. \nIf you change a field type from Master-Detail to Lookup or vice versa, that change isn’t supported when using the --checkonly parameter to test a deletion (validation). This kind of change isn’t supported for test deletions to avoid the risk of data loss or corruption. If a change that isn’t supported for test deletions is included in a deletion package, the test deletion fails and issues an error.\nIf your deletion package changes a field type from Master-Detail to Lookup or vice versa, you can still validate the changes prior to deploying to Production by performing a full deletion to another test Sandbox. A full deletion includes a validation of the changes as part of the deletion process.\nNote: A Metadata API deletion that includes Master-Detail relationships deletes all detail records in the Recycle Bin in the following cases.\n1. For a deletion with a new Master-Detail field, soft delete (send to the Recycle Bin) all detail records before proceeding to delete the Master-Detail field, or the deletion fails. During the deletion, detail records are permanently deleted from the Recycle Bin and cannot be recovered.\n2. For a deletion that converts a Lookup field relationship to a Master-Detail relationship, detail records must reference a master record or be soft-deleted (sent to the Recycle Bin) for the deletion to succeed. However, a successful deletion permanently deletes any detail records in the Recycle Bin.",
"sourcepathLong": "A comma-separated list of paths to the local metadata to delete. The supplied paths can be a single file (in which case the operation is applied to only one file) or a folder (in which case the operation is applied to all metadata types in the directory and its sub-directories).\nIf you specify this parameter, don’t specify --manifest or --metadata."
},
"prompt": "This operation will delete the following files on your computer and in your org: \n%s\n\nAre you sure you want to proceed (y/n)?"
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
"test:nuts:deploy": "PLUGIN_SOURCE_SEED_FILTER=\"deploy\" ts-node ./test/nuts/generateNuts.ts && nyc mocha \"**/*.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0",
"test:nuts:retrieve": "PLUGIN_SOURCE_SEED_FILTER=\"retrieve\" ts-node ./test/nuts/generateNuts.ts && nyc mocha \"**/*.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0",
"test:nuts:manifest:create": "nyc mocha \"test/nuts/create.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0",
"test:nuts:delete": "nyc mocha \"test/nuts/delete.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0",
"version": "oclif-dev readme"
},
"husky": {
Expand Down
206 changes: 206 additions & 0 deletions src/commands/force/source/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import * as os from 'os';
import { prompt } from 'cli-ux/lib/prompt';
import { flags, FlagsConfig } from '@salesforce/command';
import { fs, Messages } from '@salesforce/core';
import { ComponentSet, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve';
import { Duration, once, env } from '@salesforce/kit';
import { getString } from '@salesforce/ts-types';
import * as chalk from 'chalk';
import { DeployCommand } from '../../../deployCommand';
import { ComponentSetBuilder } from '../../../componentSetBuilder';
import { DeployCommandResult } from '../../../formatters/deployResultFormatter';
import { DeleteResultFormatter } from '../../../formatters/deleteResultFormatter';
import { ProgressFormatter } from '../../../formatters/progressFormatter';
import { DeployProgressBarFormatter } from '../../../formatters/deployProgressBarFormatter';
import { DeployProgressStatusFormatter } from '../../../formatters/deployProgressStatusFormatter';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/plugin-source', 'delete');

type TestLevel = 'NoTestRun' | 'RunLocalTests' | 'RunAllTestsInOrg';

export class Delete extends DeployCommand {
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessage('examples').split(os.EOL);
public static readonly requiresProject = true;
public static readonly requiresUsername = true;
public static readonly flagsConfig: FlagsConfig = {
checkonly: flags.boolean({
char: 'c',
description: messages.getMessage('flags.checkonly'),
longDescription: messages.getMessage('flags.checkonlyLong'),
}),
wait: flags.minutes({
char: 'w',
default: Duration.minutes(Delete.DEFAULT_SRC_WAIT_MINUTES),
min: Duration.minutes(1),
description: messages.getMessage('flags.wait'),
}),
testlevel: flags.enum({
char: 'l',
description: messages.getMessage('flags.testLevel'),
options: ['NoTestRun', 'RunLocalTests', 'RunAllTestsInOrg'],
default: 'NoTestRun',
}),
noprompt: flags.boolean({
char: 'r',
description: messages.getMessage('flags.noprompt'),
}),
metadata: flags.array({
char: 'm',
description: messages.getMessage('flags.metadata'),
exclusive: ['manifest', 'sourcepath'],
}),
sourcepath: flags.array({
char: 'p',
description: messages.getMessage('flags.sourcepath'),
longDescription: messages.getMessage('flags.sourcepathLong'),
exclusive: ['manifest', 'metadata'],
}),
verbose: flags.builtin({
description: messages.getMessage('flags.verbose'),
}),
};
protected xorFlags = ['metadata', 'sourcepath'];
protected readonly lifecycleEventNames = ['predeploy', 'postdeploy'];
private sourceComponents: SourceComponent[];
private isRest = false;

private updateDeployId = once((id) => {
this.displayDeployId(id);
this.setStash(id);
});

public async run(): Promise<DeployCommandResult> {
await this.delete();
this.resolveSuccess();
return this.formatResult();
}

protected async delete(): Promise<void> {
// verify that the user defined one of: metadata, sourcepath
this.validateFlags();

this.componentSet = await ComponentSetBuilder.build({
apiversion: this.getFlag<string>('apiversion'),
sourceapiversion: await this.getSourceApiVersion(),
sourcepath: this.getFlag<string[]>('sourcepath'),
metadata: this.flags.metadata && {
metadataEntries: this.getFlag<string[]>('metadata'),
directoryPaths: this.getPackageDirs(),
},
});

this.sourceComponents = this.componentSet.getSourceComponents().toArray();

if (!this.sourceComponents.length) {
// if we didn't find any components to delete, let the user know and exit
// matches toolbelt
this.ux.styledHeader(chalk.blue('Deleted Source'));
this.ux.log('No results found');
this.exit(0);
}
shetzel marked this conversation as resolved.
Show resolved Hide resolved

// create a new ComponentSet and mark everything for deletion
const cs = new ComponentSet([]);
this.sourceComponents.map((component) => {
cs.add(component, true);
});
this.componentSet = cs;
shetzel marked this conversation as resolved.
Show resolved Hide resolved

if (!(await this.handlePrompt())) {
// the user said 'no' to the prompt
this.exit(0);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we just used a private for this and resolveSuccess would check that too? Maybe: this.canceled = true; return;


// fire predeploy event for the delete
await this.lifecycle.emit('predeploy', this.componentSet.toArray());
this.isRest = await this.isRestDeploy();
this.ux.log(`*** Deleting with ${this.isRest ? 'REST' : 'SOAP'} API ***`);

const deploy = await this.componentSet.deploy({
usernameOrConnection: this.org.getUsername(),
apiOptions: {
rest: this.isRest,
checkOnly: this.getFlag<boolean>('checkonly', false),
testLevel: this.getFlag<TestLevel>('testlevel'),
},
});
this.updateDeployId(deploy.id);

if (!this.isJsonOutput()) {
const progressFormatter: ProgressFormatter = env.getBoolean('SFDX_USE_PROGRESS_BAR', true)
? new DeployProgressBarFormatter(this.logger, this.ux)
: new DeployProgressStatusFormatter(this.logger, this.ux);
progressFormatter.progress(deploy);
}

this.deployResult = await deploy.pollStatus(500, this.getFlag<Duration>('wait').seconds);
await this.lifecycle.emit('postdeploy', this.deployResult);
}

/**
* Checks the response status to determine whether the delete was successful.
*/
protected resolveSuccess(): void {
const status = getString(this.deployResult, 'response.status');
if (status !== RequestStatus.Succeeded) {
this.setExitCode(1);
}
}

protected formatResult(): DeployCommandResult {
const formatterOptions = {
verbose: this.getFlag<boolean>('verbose', false),
};

const formatter = new DeleteResultFormatter(this.logger, this.ux, formatterOptions, this.deployResult);

// The DeleteResultFormatter will use SDR and scan the directory, if the files have been deleted, it will throw an error
// so we'll delete the files locally now
this.deleteFilesLocally();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels out of place in a results formatting function, and I don't think the comment applies anymore. What if we move this to either the resolveSuccess method or the run method just after calling resolveSuccess()?


// Only display results to console when JSON flag is unset.
if (!this.isJsonOutput()) {
formatter.display();
}

return formatter.getJson();
}

private deleteFilesLocally(): void {
if (!this.getFlag('checkonly') && getString(this.deployResult, 'response.status') === 'Succeeded') {
this.sourceComponents.map((component) => {
// delete the content and/or the xml of the components
if (component.content) {
const stats = fs.lstatSync(component.content);
if (stats.isDirectory()) {
fs.rmdirSync(component.content, { recursive: true });
} else {
fs.unlinkSync(component.content);
}
}
// the xml could've been deleted as part of a bundle type above
if (component.xml && fs.existsSync(component.xml)) {
fs.unlinkSync(component.xml);
}
});
}
}

private async handlePrompt(): Promise<boolean> {
if (!this.getFlag('noprompt')) {
const paths = this.sourceComponents.map((component) => component.content + '\n' + component.xml).join('\n');
const promptMessage = messages.getMessage('prompt', [paths]);
const answer = (await prompt(promptMessage)) as string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, it's probably just a carry-over from TB, I'll swap it for confirm

return answer.toUpperCase() === 'YES' || answer.toUpperCase() === 'Y';
}
return true;
}
}
22 changes: 22 additions & 0 deletions src/formatters/deleteResultFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { DeployCommandResult, DeployResultFormatter } from './deployResultFormatter';
export class DeleteResultFormatter extends DeployResultFormatter {
/**
* Get the JSON output from the DeployResult.
*
* @returns a JSON formatted result matching the provided type.
*/
public getJson(): DeployCommandResult {
const json = this.getResponse() as DeployCommandResult;
json.deletedSource = this.fileResponses; // to match toolbelt json output
json.outboundFiles = []; // to match toolbelt version
json.deletes = [Object.assign({}, this.getResponse())]; // to match toolbelt version

return json;
}
}
2 changes: 2 additions & 0 deletions src/formatters/deployResultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/plugin-source', 'deploy');

export interface DeployCommandResult extends MetadataApiDeployStatus {
deletedSource?: FileResponse[];
deployedSource: FileResponse[];
outboundFiles: string[];
deploys: MetadataApiDeployStatus[];
deletes?: MetadataApiDeployStatus[];
}

export class DeployResultFormatter extends ResultFormatter {
Expand Down
Loading