Skip to content

Commit d8bf5dd

Browse files
feat: destructive changes, purge-on-delete
* feat: add --purge-on-delete flag * feat: add destructive changes flags
1 parent a9bf7be commit d8bf5dd

File tree

7 files changed

+298
-37
lines changed

7 files changed

+298
-37
lines changed

command-snapshot.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@
119119
"manifest",
120120
"metadata",
121121
"metadata-dir",
122+
"post-destructive-changes",
123+
"pre-destructive-changes",
124+
"purge-on-delete",
122125
"results-dir",
123126
"single-package",
124127
"source-dir",

messages/deploy.metadata.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ To deploy multiple metadata components, either set multiple --metadata <name> fl
5656

5757
Login username or alias for the target org.
5858

59+
# flags.pre-destructive-changes
60+
61+
file path for a manifest (destructiveChangesPre.xml) of components to delete before the deploy
62+
63+
# flags.post-destructive-changes
64+
65+
file path for a manifest (destructiveChangesPost.xml) of components to delete after the deploy
66+
67+
# flags.purge-on-delete
68+
69+
specify that deleted components in the destructive changes manifest file are immediately eligible for deletion rather than being stored in the Recycle Bin
70+
5971
# flags.target-org.description
6072

6173
Overrides your default org.

package.json

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
"author": "Salesforce",
66
"bugs": "https://github.com/forcedotcom/cli/issues",
77
"dependencies": {
8-
"@oclif/core": "^2.3.1",
8+
"@oclif/core": "^2.6.4",
99
"@salesforce/apex-node": "^1.6.0",
10-
"@salesforce/core": "^3.33.5",
11-
"@salesforce/kit": "^1.9.0",
12-
"@salesforce/sf-plugins-core": "^2.2.3",
10+
"@salesforce/core": "^3.34.1",
11+
"@salesforce/kit": "^1.9.2",
12+
"@salesforce/sf-plugins-core": "^2.2.4",
1313
"@salesforce/source-deploy-retrieve": "^7.12.0",
1414
"@salesforce/source-tracking": "^2.2.22",
1515
"chalk": "^4.1.2",
@@ -19,12 +19,13 @@
1919
},
2020
"devDependencies": {
2121
"@oclif/plugin-command-snapshot": "^3.2.16",
22-
"@salesforce/cli-plugins-testkit": "^3.2.23",
22+
"@salesforce/cli-plugins-testkit": "^3.2.25",
2323
"@salesforce/dev-config": "^3.1.0",
2424
"@salesforce/dev-scripts": "^4.1.1",
2525
"@salesforce/plugin-command-reference": "^2.4.1",
2626
"@salesforce/plugin-config": "^2.3.3",
2727
"@salesforce/plugin-functions": "^1.17.4",
28+
"@salesforce/plugin-settings": "^1.4.2",
2829
"@salesforce/plugin-source": "^2.5.4",
2930
"@salesforce/plugin-templates": "^55.4.4",
3031
"@salesforce/plugin-user": "^2.3.2",
@@ -33,12 +34,10 @@
3334
"@salesforce/ts-sinon": "1.4.4",
3435
"@salesforce/ts-types": "^1.5.20",
3536
"@swc/core": "^1.3.14",
36-
"@types/archiver": "^5.3.1",
3737
"@types/fs-extra": "^9.0.13",
3838
"@types/shelljs": "^0.8.11",
3939
"@typescript-eslint/eslint-plugin": "^5.48.1",
4040
"@typescript-eslint/parser": "^5.48.2",
41-
"archiver": "^5.3.1",
4241
"chai": "^4.3.7",
4342
"cross-env": "^7.0.3",
4443
"eslint": "^8.31.0",

src/commands/project/deploy/start.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ export default class DeployMetadata extends SfCommand<DeployResultJson> {
129129
min: 1,
130130
exclusive: ['async'],
131131
}),
132+
'purge-on-delete': Flags.boolean({
133+
summary: messages.getMessage('flags.purge-on-delete'),
134+
dependsOn: ['manifest'],
135+
relationships: [{ type: 'some', flags: ['pre-destructive-changes', 'post-destructive-changes'] }],
136+
}),
137+
'pre-destructive-changes': Flags.file({
138+
summary: messages.getMessage('flags.pre-destructive-changes'),
139+
dependsOn: ['manifest'],
140+
}),
141+
'post-destructive-changes': Flags.file({
142+
summary: messages.getMessage('flags.post-destructive-changes'),
143+
dependsOn: ['manifest'],
144+
}),
132145
'coverage-formatters': Flags.string({
133146
multiple: true,
134147
summary: messages.getMessage('flags.coverage-formatters'),

src/utils/deploy.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export type DeployOptions = {
4949
concise?: boolean;
5050
'single-package'?: boolean;
5151
status?: RequestStatus;
52+
53+
'pre-destructive-changes'?: string;
54+
'post-destructive-changes'?: string;
55+
56+
'purge-on-delete'?: boolean;
5257
};
5358

5459
/** Manifest is expected. You cannot pass metadata and source-dir array--use those to get a manifest */
@@ -90,6 +95,8 @@ export async function buildComponentSet(opts: Partial<DeployOptions>, stl?: Sour
9095
manifest: {
9196
manifestPath: opts.manifest,
9297
directoryPaths: await getPackageDirs(),
98+
destructiveChangesPre: opts['pre-destructive-changes'],
99+
destructiveChangesPost: opts['post-destructive-changes'],
93100
},
94101
}
95102
: {}),
@@ -110,6 +117,7 @@ export async function executeDeploy(
110117
rollbackOnError: !opts['ignore-errors'] || false,
111118
runTests: opts.tests ?? [],
112119
testLevel: opts['test-level'],
120+
purgeOnDelete: opts['purge-on-delete'] ?? false,
113121
};
114122

115123
let deploy: MetadataApiDeploy | undefined;
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import * as path from 'path';
9+
import { expect } from 'chai';
10+
import { execCmd } from '@salesforce/cli-plugins-testkit';
11+
import { SourceTestkit } from '@salesforce/source-testkit';
12+
import { AuthInfo, Connection } from '@salesforce/core';
13+
14+
const isNameObsolete = async (username: string, memberType: string, memberName: string): Promise<boolean> => {
15+
const connection = await Connection.create({
16+
authInfo: await AuthInfo.create({ username }),
17+
});
18+
19+
const res = await connection.singleRecordQuery<{ IsNameObsolete: boolean }>(
20+
`SELECT IsNameObsolete FROM SourceMember WHERE MemberType='${memberType}' AND MemberName='${memberName}'`,
21+
{ tooling: true }
22+
);
23+
24+
return res.IsNameObsolete;
25+
};
26+
27+
describe('project deploy start --destructive NUTs', () => {
28+
let testkit: SourceTestkit;
29+
30+
const createApexClass = (apexName = 'myApexClass') => {
31+
// create and deploy an ApexClass that can be deleted without dependency issues
32+
const output = path.join('force-app', 'main', 'default', 'classes');
33+
const pathToClass = path.join(testkit.projectDir, output, `${apexName}.cls`);
34+
execCmd(`force:apex:class:create --classname ${apexName} --outputdir ${output}`, { ensureExitCode: 0 });
35+
execCmd(`project:deploy:start -m ApexClass:${apexName}`, { ensureExitCode: 0 });
36+
return { apexName, output, pathToClass };
37+
};
38+
39+
const createManifest = (metadata: string, manifesttype: string) => {
40+
execCmd(`force:source:manifest:create --metadata ${metadata} --manifesttype ${manifesttype}`, {
41+
ensureExitCode: 0,
42+
});
43+
};
44+
45+
before(async () => {
46+
testkit = await SourceTestkit.create({
47+
nut: __filename,
48+
repository: 'https://github.com/trailheadapps/dreamhouse-lwc.git',
49+
});
50+
execCmd('project:deploy:start --source-dir force-app', { ensureExitCode: 0 });
51+
});
52+
53+
after(async () => {
54+
await testkit?.clean();
55+
});
56+
57+
describe('destructive changes POST', () => {
58+
it('should deploy and then delete an ApexClass ', async () => {
59+
const { apexName } = createApexClass();
60+
let deleted = await isNameObsolete(testkit.username, 'ApexClass', apexName);
61+
62+
expect(deleted).to.be.false;
63+
createManifest('ApexClass:GeocodingService', 'package');
64+
createManifest(`ApexClass:${apexName}`, 'post');
65+
66+
execCmd(
67+
'project:deploy:start --json --manifest package.xml --post-destructive-changes destructiveChangesPost.xml',
68+
{
69+
ensureExitCode: 0,
70+
}
71+
);
72+
73+
deleted = await isNameObsolete(testkit.username, 'ApexClass', apexName);
74+
expect(deleted).to.be.true;
75+
});
76+
});
77+
78+
describe('destructive changes PRE', () => {
79+
it('should delete an ApexClass and then deploy a class', async () => {
80+
const { apexName } = createApexClass();
81+
let deleted = await isNameObsolete(testkit.username, 'ApexClass', apexName);
82+
83+
expect(deleted).to.be.false;
84+
createManifest('ApexClass:GeocodingService', 'package');
85+
createManifest(`ApexClass:${apexName}`, 'pre');
86+
87+
execCmd(
88+
'project:deploy:start --json --manifest package.xml --pre-destructive-changes destructiveChangesPre.xml',
89+
{
90+
ensureExitCode: 0,
91+
}
92+
);
93+
94+
deleted = await isNameObsolete(testkit.username, 'ApexClass', apexName);
95+
expect(deleted).to.be.true;
96+
});
97+
98+
it('should delete an ApexClass and then deploy a class with --purge-on-delete', async () => {
99+
const { apexName } = createApexClass();
100+
let deleted = await isNameObsolete(testkit.username, 'ApexClass', apexName);
101+
102+
expect(deleted).to.be.false;
103+
createManifest('ApexClass:GeocodingService', 'package');
104+
createManifest(`ApexClass:${apexName}`, 'pre');
105+
106+
execCmd(
107+
'project:deploy:start --json --manifest package.xml --purge-on-delete --pre-destructive-changes destructiveChangesPre.xml',
108+
{
109+
ensureExitCode: 0,
110+
}
111+
);
112+
113+
deleted = await isNameObsolete(testkit.username, 'ApexClass', apexName);
114+
expect(deleted).to.be.true;
115+
});
116+
});
117+
118+
describe('destructive changes POST and PRE', () => {
119+
it('should delete a class, then deploy and then delete an ApexClass', async () => {
120+
const pre = createApexClass('pre').apexName;
121+
const post = createApexClass('post').apexName;
122+
let preDeleted = await isNameObsolete(testkit.username, 'ApexClass', pre);
123+
let postDeleted = await isNameObsolete(testkit.username, 'ApexClass', post);
124+
125+
expect(preDeleted).to.be.false;
126+
expect(postDeleted).to.be.false;
127+
createManifest('ApexClass:GeocodingService', 'package');
128+
createManifest(`ApexClass:${post}`, 'post');
129+
createManifest(`ApexClass:${pre}`, 'pre');
130+
131+
execCmd(
132+
'project:deploy:start --json --manifest package.xml --post-destructive-changes destructiveChangesPost.xml --pre-destructive-changes destructiveChangesPre.xml',
133+
{
134+
ensureExitCode: 0,
135+
}
136+
);
137+
138+
preDeleted = await isNameObsolete(testkit.username, 'ApexClass', pre);
139+
postDeleted = await isNameObsolete(testkit.username, 'ApexClass', post);
140+
expect(preDeleted).to.be.true;
141+
expect(postDeleted).to.be.true;
142+
});
143+
144+
it('should delete a class, then deploy and then delete an ApexClass with --purge-on-delete', async () => {
145+
const pre = createApexClass('pre').apexName;
146+
const post = createApexClass('post').apexName;
147+
let preDeleted = await isNameObsolete(testkit.username, 'ApexClass', pre);
148+
let postDeleted = await isNameObsolete(testkit.username, 'ApexClass', post);
149+
150+
expect(preDeleted).to.be.false;
151+
expect(postDeleted).to.be.false;
152+
createManifest('ApexClass:GeocodingService', 'package');
153+
createManifest(`ApexClass:${post}`, 'post');
154+
createManifest(`ApexClass:${pre}`, 'pre');
155+
156+
execCmd(
157+
'project:deploy:start --json --manifest package.xml --purge-on-delete --post-destructive-changes destructiveChangesPost.xml --pre-destructive-changes destructiveChangesPre.xml',
158+
{
159+
ensureExitCode: 0,
160+
}
161+
);
162+
163+
preDeleted = await isNameObsolete(testkit.username, 'ApexClass', pre);
164+
postDeleted = await isNameObsolete(testkit.username, 'ApexClass', post);
165+
expect(preDeleted).to.be.true;
166+
expect(postDeleted).to.be.true;
167+
});
168+
});
169+
170+
describe('errors', () => {
171+
it('should throw an error when a pre destructive flag is passed without the manifest flag', async () => {
172+
const { apexName } = createApexClass();
173+
174+
createManifest('ApexClass:GeocodingService', 'package');
175+
createManifest(`ApexClass:${apexName}`, 'pre');
176+
177+
try {
178+
execCmd(
179+
'project:deploy:start --json --source-dir force-app --pre-destructive-changes destructiveChangesPre.xml'
180+
);
181+
} catch (e) {
182+
const err = e as Error;
183+
expect(err).to.not.be.undefined;
184+
expect(err.message).to.include(
185+
'Error: --manifest= must also be provided when using --pre-destructive-changes='
186+
);
187+
}
188+
});
189+
190+
it('should throw an error when a post destructive flag is passed without the manifest flag', async () => {
191+
const { apexName } = createApexClass();
192+
193+
createManifest('ApexClass:GeocodingService', 'package');
194+
createManifest(`ApexClass:${apexName}`, 'pre');
195+
196+
try {
197+
execCmd(
198+
'project:deploy:start --json --source-dir force-app --post-destructive-changes destructiveChangesPre.xml'
199+
);
200+
} catch (e) {
201+
const err = e as Error;
202+
expect(err).to.not.be.undefined;
203+
expect(err.message).to.include(
204+
'Error: --manifest= must also be provided when using --post-destructive-changes='
205+
);
206+
}
207+
});
208+
209+
it("should throw an error when a destructive manifest is passed that doesn't exist", () => {
210+
createManifest('ApexClass:GeocodingService', 'package');
211+
212+
try {
213+
execCmd('project:deploy:start --json --manifest package.xml --pre-destructive-changes doesntexist.xml');
214+
} catch (e) {
215+
const err = e as Error;
216+
expect(err).to.not.be.undefined;
217+
expect(err.message).to.include("ENOENT: no such file or directory, open 'doesntexist.xml'");
218+
}
219+
});
220+
});
221+
});

0 commit comments

Comments
 (0)