Skip to content

Commit f54affd

Browse files
authored
Merge pull request #1247 from salesforcecli/sh/enhanced-gen-manifest
feat: support an include or exclude list of metadata when building a manifest from an org
2 parents b02ef9a + e1d059c commit f54affd

File tree

9 files changed

+181
-53
lines changed

9 files changed

+181
-53
lines changed

LICENSE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Copyright (c) 2024, Salesforce.com, Inc.
1+
Copyright (c) 2025, Salesforce.com, Inc.
22
All rights reserved.
33

44
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,8 +1282,9 @@ Create a project manifest that lists the metadata components you want to deploy
12821282

12831283
```
12841284
USAGE
1285-
$ sf project generate manifest [--json] [--flags-dir <value>] [--api-version <value>] [-m <value>...] [-p <value>...] [-n
1286-
<value> | -t pre|post|destroy|package] [-c managed|unlocked... --from-org <value>] [-d <value>]
1285+
$ sf project generate manifest [--json] [--flags-dir <value>] [--api-version <value>] [-m <value>... | -p <value>...] [-n
1286+
<value> | -t pre|post|destroy|package] [-c managed|unlocked... --from-org <value>] [--excluded-metadata <value>... ]
1287+
[-d <value>]
12871288
12881289
FLAGS
12891290
-c, --include-packages=<option>... Package types (managed, unlocked) whose metadata is included in the manifest; by
@@ -1297,6 +1298,8 @@ FLAGS
12971298
-t, --type=<option> Type of manifest to create; the type determines the name of the created file.
12981299
<options: pre|post|destroy|package>
12991300
--api-version=<value> Override the api version used for api requests made by this command
1301+
--excluded-metadata=<value>... Metadata types to exclude when building a manifest from an org. Specify the name
1302+
of the type, not the name of a specific component.
13001303
--from-org=<value> Username or alias of the org that contains the metadata components from which to
13011304
build a manifest.
13021305
@@ -1329,6 +1332,14 @@ DESCRIPTION
13291332
multiple names separated by spaces. Enclose names that contain spaces in one set of double quotes. The same syntax
13301333
applies to --include-packages and --source-dir.
13311334
1335+
To build a manifest from the metadata in an org, use the --from-org flag. You can combine --from-org with the
1336+
--metadata flag to include only certain metadata types, or with the --excluded-metadata flag to exclude certain
1337+
metadata types. When building a manifest from an org, the command makes many concurrent API calls to discover the
1338+
metadata that exists in the org. To limit the number of concurrent requests, use the SF_LIST_METADATA_BATCH_SIZE
1339+
environment variable and set it to a size that works best for your org and environment. If you experience timeouts or
1340+
inconsistent manifest contents, then setting this environment variable can improve accuracy. However, the command
1341+
takes longer to run because it sends fewer requests at a time.
1342+
13321343
ALIASES
13331344
$ sf force source manifest create
13341345
@@ -1349,6 +1360,14 @@ EXAMPLES
13491360
Create a manifest from the metadata components in the specified org and include metadata in any unlocked packages:
13501361
13511362
$ sf project generate manifest --from-org [email protected] --include-packages unlocked
1363+
1364+
Create a manifest from specific metadata types in an org:
1365+
1366+
$ sf project generate manifest --from-org [email protected] --metadata ApexClass,CustomObject,CustomLabels
1367+
1368+
Create a manifest from all metadata components in an org excluding specific metadata types:
1369+
1370+
$ sf project generate manifest --from-org [email protected] --excluded-metadata StandardValueSet
13521371
```
13531372

13541373
_See code: [src/commands/project/generate/manifest.ts](https://github.com/salesforcecli/plugin-deploy-retrieve/blob/3.16.6/src/commands/project/generate/manifest.ts)_

command-snapshot.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@
239239
"flagChars": ["c", "d", "m", "n", "p", "t"],
240240
"flags": [
241241
"api-version",
242+
"excluded-metadata",
242243
"flags-dir",
243244
"from-org",
244245
"include-packages",

messages/manifest.generate.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Use --name to specify a custom name for the generated manifest if the pre-define
1919

2020
To include multiple metadata components, either set multiple --metadata <name> flags or a single --metadata flag with multiple names separated by spaces. Enclose names that contain spaces in one set of double quotes. The same syntax applies to --include-packages and --source-dir.
2121

22+
To build a manifest from the metadata in an org, use the --from-org flag. You can combine --from-org with the --metadata flag to include only certain metadata types, or with the --excluded-metadata flag to exclude certain metadata types. When building a manifest from an org, the command makes many concurrent API calls to discover the metadata that exists in the org. To limit the number of concurrent requests, use the SF_LIST_METADATA_BATCH_SIZE environment variable and set it to a size that works best for your org and environment. If you experience timeouts or inconsistent manifest contents, then setting this environment variable can improve accuracy. However, the command takes longer to run because it sends fewer requests at a time.
23+
2224
# examples
2325

2426
- Create a manifest for deploying or retrieving all Apex classes and custom objects:
@@ -37,10 +39,22 @@ To include multiple metadata components, either set multiple --metadata <name> f
3739

3840
$ <%= config.bin %> <%= command.id %> --from-org [email protected] --include-packages unlocked
3941

42+
- Create a manifest from specific metadata types in an org:
43+
44+
$ <%= config.bin %> <%= command.id %> --from-org [email protected] --metadata ApexClass,CustomObject,CustomLabels
45+
46+
- Create a manifest from all metadata components in an org excluding specific metadata types:
47+
48+
$ <%= config.bin %> <%= command.id %> --from-org [email protected] --excluded-metadata StandardValueSet
49+
4050
# flags.include-packages.summary
4151

4252
Package types (managed, unlocked) whose metadata is included in the manifest; by default, metadata in managed and unlocked packages is excluded. Metadata in unmanaged packages is always included.
4353

54+
# flags.excluded-metadata.summary
55+
56+
Metadata types to exclude when building a manifest from an org. Specify the name of the type, not the name of a specific component.
57+
4458
# flags.from-org.summary
4559

4660
Username or alias of the org that contains the metadata components from which to build a manifest.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
"@salesforce/kit": "^3.2.3",
1313
"@salesforce/plugin-info": "^3.4.23",
1414
"@salesforce/sf-plugins-core": "^12.1.1",
15-
"@salesforce/source-deploy-retrieve": "^12.10.3",
16-
"@salesforce/source-tracking": "^7.3.4",
15+
"@salesforce/source-deploy-retrieve": "^12.11.2",
16+
"@salesforce/source-tracking": "^7.3.6",
1717
"@salesforce/ts-types": "^2.0.12",
1818
"ansis": "^3.4.0",
1919
"terminal-link": "^3.0.0"

src/commands/project/delete/source.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,7 @@ export class Source extends SfCommand<DeleteSourceJson> {
404404
.filter(sourceComponentIsNotInMixedDeployDelete(this.mixedDeployDelete))
405405
.flatMap((c) =>
406406
// for custom labels, print each custom label to be deleted, not the whole file
407-
isNonDecomposedCustomLabelsOrCustomLabel(c)
408-
? [`${c.type.name}:${c.fullName}`]
409-
: [c.xml, ...c.walkContent()] ?? []
407+
isNonDecomposedCustomLabelsOrCustomLabel(c) ? [`${c.type.name}:${c.fullName}`] : [c.xml, ...c.walkContent()]
410408
)
411409
.concat(this.mixedDeployDelete.delete.map((fr) => `${fr.fullName} (${fr.filePath})`));
412410

src/commands/project/generate/manifest.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export type ManifestGenerateCommandResult = {
3838
path: string;
3939
};
4040

41-
const xorFlags = ['metadata', 'source-dir', 'from-org'];
41+
const atLeastOneOfFlags = ['metadata', 'source-dir', 'from-org'];
4242

4343
export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
4444
public static readonly summary = messages.getMessage('summary');
@@ -53,14 +53,14 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
5353
metadata: arrayWithDeprecation({
5454
char: 'm',
5555
summary: messages.getMessage('flags.metadata.summary'),
56-
exactlyOne: xorFlags,
56+
exclusive: ['source-dir'],
5757
}),
5858
'source-dir': arrayWithDeprecation({
5959
char: 'p',
6060
aliases: ['sourcepath'],
6161
deprecateAliases: true,
6262
summary: messages.getMessage('flags.source-dir.summary'),
63-
exactlyOne: xorFlags,
63+
exclusive: ['metadata'],
6464
}),
6565
name: Flags.string({
6666
char: 'n',
@@ -85,11 +85,18 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
8585
char: 'c',
8686
dependsOn: ['from-org'],
8787
}),
88+
'excluded-metadata': Flags.string({
89+
multiple: true,
90+
delimiter: ',',
91+
summary: messages.getMessage('flags.excluded-metadata.summary'),
92+
dependsOn: ['from-org'],
93+
exclusive: ['metadata'],
94+
}),
8895
'from-org': Flags.custom({
8996
summary: messages.getMessage('flags.from-org.summary'),
90-
exactlyOne: xorFlags,
9197
aliases: ['fromorg'],
9298
deprecateAliases: true,
99+
exclusive: ['source-dir'],
93100
parse: async (input: string | undefined) => (input ? Org.create({ aliasOrUsername: input }) : undefined),
94101
})(),
95102
'output-dir': Flags.string({
@@ -102,6 +109,12 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
102109

103110
public async run(): Promise<ManifestGenerateCommandResult> {
104111
const { flags } = await this.parse(ManifestGenerate);
112+
113+
// We need at least one of these flags (but could be more than 1): 'metadata', 'source-dir', 'from-org'
114+
if (!Object.keys(flags).some((f) => atLeastOneOfFlags.includes(f))) {
115+
throw Error(`provided flags must include at least one of: ${atLeastOneOfFlags.toString()}`);
116+
}
117+
105118
// convert the manifesttype into one of the "official" manifest names
106119
// if no manifesttype flag passed, use the manifestname?flag
107120
// if no manifestname flag, default to 'package.xml'
@@ -114,12 +127,14 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
114127
const componentSet = await ComponentSetBuilder.build({
115128
apiversion: flags['api-version'] ?? (await getSourceApiVersion()),
116129
sourcepath: flags['source-dir'],
117-
metadata: flags.metadata
118-
? {
119-
metadataEntries: flags.metadata,
120-
directoryPaths: await getPackageDirs(),
121-
}
122-
: undefined,
130+
metadata:
131+
flags.metadata ?? flags['excluded-metadata']
132+
? {
133+
metadataEntries: flags.metadata ?? [],
134+
directoryPaths: await getPackageDirs(),
135+
excludedEntries: flags['excluded-metadata'],
136+
}
137+
: undefined,
123138
org: flags['from-org']
124139
? {
125140
username: flags['from-org'].getUsername() as string,

test/nuts/manifest/manifestCreate.nut.ts

Lines changed: 92 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('project generate manifest', () => {
4040
});
4141

4242
it('should produce a manifest (package.xml) for ApexClass', () => {
43-
const result = execCmd<Dictionary>('force:source:manifest:create --metadata ApexClass --json', {
43+
const result = execCmd<Dictionary>('project generate manifest --metadata ApexClass --json', {
4444
ensureExitCode: 0,
4545
}).jsonOutput?.result;
4646
expect(result).to.be.ok;
@@ -70,7 +70,7 @@ describe('project generate manifest', () => {
7070
const output = join('abc', 'def');
7171
const outputFile = join(output, 'destructiveChanges.xml');
7272
const result = execCmd<Dictionary>(
73-
`force:source:manifest:create --metadata ApexClass --manifesttype destroy --outputdir ${output} --apiversion=51.0 --json`,
73+
`project generate manifest --metadata ApexClass --manifesttype destroy --outputdir ${output} --apiversion=51.0 --json`,
7474
{
7575
ensureExitCode: 0,
7676
}
@@ -85,7 +85,7 @@ describe('project generate manifest', () => {
8585
const output = join('abc', 'def');
8686
const outputFile = join(output, 'myNewManifest.xml');
8787
const result = execCmd<Dictionary>(
88-
`force:source:manifest:create --metadata ApexClass --manifestname myNewManifest --outputdir ${output} --json`,
88+
`project generate manifest --metadata ApexClass --manifestname myNewManifest --outputdir ${output} --json`,
8989
{
9090
ensureExitCode: 0,
9191
}
@@ -96,50 +96,112 @@ describe('project generate manifest', () => {
9696

9797
it('should produce a manifest in a directory with stdout output', () => {
9898
const output = join('abc', 'def');
99-
const result = execCmd<Dictionary>(`force:source:manifest:create --metadata ApexClass --outputdir ${output}`, {
99+
const result = execCmd<Dictionary>(`project generate manifest --metadata ApexClass --outputdir ${output}`, {
100100
ensureExitCode: 0,
101101
}).shellOutput;
102102
expect(result).to.include(`successfully wrote package.xml to ${output}`);
103103
});
104104

105105
it('should produce a manifest with stdout output', () => {
106-
const result = execCmd<Dictionary>('force:source:manifest:create --metadata ApexClass', {
106+
const result = execCmd<Dictionary>('project generate manifest --metadata ApexClass', {
107107
ensureExitCode: 0,
108108
}).shellOutput;
109109
expect(result).to.include('successfully wrote package.xml');
110110
});
111111

112-
it('should produce a manifest from metadata in an org', async () => {
113-
const manifestName = 'org-metadata.xml';
114-
const result = execCmd<Dictionary>(`force:source:manifest:create --fromorg ${orgAlias} -n ${manifestName} --json`, {
115-
ensureExitCode: 0,
116-
}).jsonOutput?.result;
117-
expect(result).to.be.ok;
118-
expect(result).to.include({ path: manifestName, name: manifestName });
119-
const stats = fs.statSync(join(session.project.dir, manifestName));
120-
expect(stats.isFile()).to.be.true;
121-
expect(stats.size).to.be.greaterThan(100);
122-
});
123-
124-
it('should produce the same manifest from an org every time', async () => {
125-
config.truncateThreshold = 0;
112+
describe('from org', () => {
113+
before(async () => {
114+
// Deploy all source in the project to the org so there's some metadata in it.
115+
execCmd<Dictionary>('project deploy start', { ensureExitCode: 0 });
116+
});
126117

127-
execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-1.xml`, {
128-
ensureExitCode: 0,
118+
it('should produce a manifest from metadata in an org', async () => {
119+
const manifestName = 'org-metadata.xml';
120+
const result = execCmd<Dictionary>(`project generate manifest --fromorg ${orgAlias} -n ${manifestName} --json`, {
121+
ensureExitCode: 0,
122+
}).jsonOutput?.result;
123+
expect(result).to.be.ok;
124+
expect(result).to.include({ path: manifestName, name: manifestName });
125+
const stats = fs.statSync(join(session.project.dir, manifestName));
126+
expect(stats.isFile()).to.be.true;
127+
expect(stats.size).to.be.greaterThan(100);
129128
});
130-
const manifest1 = fs.readFileSync(join(session.project.dir, 'org-metadata-1.xml'), 'utf-8');
131129

132-
execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-2.xml`, {
133-
ensureExitCode: 0,
130+
it('should produce a manifest from an include list of metadata in an org', async () => {
131+
const manifestName = 'org-metadata.xml';
132+
const includeList = 'ApexClass:FileUtil*,PermissionSet,Flow';
133+
execCmd<Dictionary>(
134+
`project generate manifest --fromorg ${orgAlias} -n ${manifestName} --metadata ${includeList} --json`,
135+
{
136+
ensureExitCode: 0,
137+
}
138+
);
139+
const manifestContents = fs.readFileSync(join(session.project.dir, manifestName), 'utf-8');
140+
141+
const expectedManifestContents =
142+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
143+
'<Package xmlns="http://soap.sforce.com/2006/04/metadata">\n' +
144+
' <types>\n' +
145+
' <members>FileUtilities</members>\n' +
146+
' <members>FileUtilitiesTest</members>\n' +
147+
' <name>ApexClass</name>\n' +
148+
' </types>\n' +
149+
' <types>\n' +
150+
' <members>Create_property</members>\n' +
151+
' <name>Flow</name>\n' +
152+
' </types>\n' +
153+
' <types>\n' +
154+
' <members>dreamhouse</members>\n' +
155+
' <members>sfdcInternalInt__sfdc_scrt2</members>\n' +
156+
' <name>PermissionSet</name>\n' +
157+
' </types>\n' +
158+
' <version>61.0</version>\n' +
159+
'</Package>\n';
160+
expect(manifestContents).to.equal(expectedManifestContents);
134161
});
135-
const manifest2 = fs.readFileSync(join(session.project.dir, 'org-metadata-2.xml'), 'utf-8');
136162

137-
execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-3.xml`, {
138-
ensureExitCode: 0,
163+
it('should produce a manifest from an excluded list of metadata in an org', async () => {
164+
const manifestName = 'org-metadata.xml';
165+
const excludedList = 'ApexClass,CustomObject,StandardValueSet';
166+
execCmd<Dictionary>(
167+
`project generate manifest --fromorg ${orgAlias} -n ${manifestName} --excluded-metadata ${excludedList} --json`,
168+
{
169+
ensureExitCode: 0,
170+
}
171+
);
172+
const manifestContents = fs.readFileSync(join(session.project.dir, manifestName), 'utf-8');
173+
174+
// should NOT have these entries
175+
expect(manifestContents).to.not.contain('<name>ApexClass</name>');
176+
expect(manifestContents).to.not.contain('<name>CustomObject</name>');
177+
expect(manifestContents).to.not.contain('<name>StandardValueSet</name>');
178+
179+
// should have these entries
180+
expect(manifestContents).to.contain('<name>Layout</name>');
181+
expect(manifestContents).to.contain('<name>CustomLabels</name>');
182+
expect(manifestContents).to.contain('<name>Profile</name>');
139183
});
140-
const manifest3 = fs.readFileSync(join(session.project.dir, 'org-metadata-3.xml'), 'utf-8');
141184

142-
expect(manifest1).to.equal(manifest2);
143-
expect(manifest2).to.equal(manifest3);
185+
it('should produce the same manifest from an org every time', async () => {
186+
config.truncateThreshold = 0;
187+
188+
execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-1.xml`, {
189+
ensureExitCode: 0,
190+
});
191+
const manifest1 = fs.readFileSync(join(session.project.dir, 'org-metadata-1.xml'), 'utf-8');
192+
193+
execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-2.xml`, {
194+
ensureExitCode: 0,
195+
});
196+
const manifest2 = fs.readFileSync(join(session.project.dir, 'org-metadata-2.xml'), 'utf-8');
197+
198+
execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-3.xml`, {
199+
ensureExitCode: 0,
200+
});
201+
const manifest3 = fs.readFileSync(join(session.project.dir, 'org-metadata-3.xml'), 'utf-8');
202+
203+
expect(manifest1).to.equal(manifest2);
204+
expect(manifest2).to.equal(manifest3);
205+
});
144206
});
145207
});

0 commit comments

Comments
 (0)