Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
204 changes: 204 additions & 0 deletions src/commands/force/source/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* 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 { confirm } 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 { 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 deleteResultFormatter: DeleteResultFormatter;
private aborted = false;

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

public async run(): Promise<DeployCommandResult> {
await this.delete();
this.resolveSuccess();
const result = this.formatResult();
// 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();
return result;
}

protected async delete(): Promise<void> {
this.deleteResultFormatter = new DeleteResultFormatter(this.logger, this.ux, {});
// 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
this.deleteResultFormatter.displayNoResultsFound();
return;
}

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

this.aborted = !(await this.handlePrompt());
if (this.aborted) 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.aborted) {
this.setExitCode(1);
}
}

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

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

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

return this.deleteResultFormatter.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.flatMap((component) => [component.xml, ...component.walkContent()]);
const promptMessage = messages.getMessage('prompt', [[...new Set(paths)].join('\n')]);

return confirm(promptMessage);
}
return true;
}
}
59 changes: 59 additions & 0 deletions src/formatters/deleteResultFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 { DeployResult } from '@salesforce/source-deploy-retrieve';
import { UX } from '@salesforce/command';
import { Logger } from '@salesforce/core';
import * as chalk from 'chalk';
import { DeployCommandResult, DeployResultFormatter } from './deployResultFormatter';
import { ResultFormatterOptions } from './resultFormatter';

export class DeleteResultFormatter extends DeployResultFormatter {
public constructor(logger: Logger, ux: UX, options: ResultFormatterOptions, result?: DeployResult) {
super(logger, ux, options, result);
}

/**
* 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;
}

public displayNoResultsFound(): void {
// matches toolbelt
this.ux.styledHeader(chalk.blue('Deleted Source'));
this.ux.log('No results found');
}

protected displaySuccesses(): void {
if (this.isSuccess() && this.fileResponses?.length) {
const successes = this.fileResponses.filter((f) => f.state !== 'Failed');
if (!successes.length) {
return;
}
this.sortFileResponses(successes);
this.asRelativePaths(successes);

this.ux.log('');
this.ux.styledHeader(chalk.blue('Deleted Source'));
this.ux.table(successes, {
columns: [
{ key: 'fullName', label: 'FULL NAME' },
{ key: 'type', label: 'TYPE' },
{ key: 'filePath', label: 'PROJECT PATH' },
],
});
}
}
}
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