Skip to content

Commit

Permalink
Merge pull request #1247 from salesforcecli/sh/enhanced-gen-manifest
Browse files Browse the repository at this point in the history
feat: support an include or exclude list of metadata when building a manifest from an org
  • Loading branch information
shetzel authored Jan 7, 2025
2 parents b02ef9a + e1d059c commit f54affd
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 53 deletions.
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2024, Salesforce.com, Inc.
Copyright (c) 2025, Salesforce.com, Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1282,8 +1282,9 @@ Create a project manifest that lists the metadata components you want to deploy

```
USAGE
$ sf project generate manifest [--json] [--flags-dir <value>] [--api-version <value>] [-m <value>...] [-p <value>...] [-n
<value> | -t pre|post|destroy|package] [-c managed|unlocked... --from-org <value>] [-d <value>]
$ sf project generate manifest [--json] [--flags-dir <value>] [--api-version <value>] [-m <value>... | -p <value>...] [-n
<value> | -t pre|post|destroy|package] [-c managed|unlocked... --from-org <value>] [--excluded-metadata <value>... ]
[-d <value>]
FLAGS
-c, --include-packages=<option>... Package types (managed, unlocked) whose metadata is included in the manifest; by
Expand All @@ -1297,6 +1298,8 @@ FLAGS
-t, --type=<option> Type of manifest to create; the type determines the name of the created file.
<options: pre|post|destroy|package>
--api-version=<value> Override the api version used for api requests made by this command
--excluded-metadata=<value>... Metadata types to exclude when building a manifest from an org. Specify the name
of the type, not the name of a specific component.
--from-org=<value> Username or alias of the org that contains the metadata components from which to
build a manifest.
Expand Down Expand Up @@ -1329,6 +1332,14 @@ DESCRIPTION
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.
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.
ALIASES
$ sf force source manifest create
Expand All @@ -1349,6 +1360,14 @@ EXAMPLES
Create a manifest from the metadata components in the specified org and include metadata in any unlocked packages:
$ sf project generate manifest --from-org [email protected] --include-packages unlocked
Create a manifest from specific metadata types in an org:
$ sf project generate manifest --from-org [email protected] --metadata ApexClass,CustomObject,CustomLabels
Create a manifest from all metadata components in an org excluding specific metadata types:
$ sf project generate manifest --from-org [email protected] --excluded-metadata StandardValueSet
```

_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)_
Expand Down
1 change: 1 addition & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@
"flagChars": ["c", "d", "m", "n", "p", "t"],
"flags": [
"api-version",
"excluded-metadata",
"flags-dir",
"from-org",
"include-packages",
Expand Down
14 changes: 14 additions & 0 deletions messages/manifest.generate.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Use --name to specify a custom name for the generated manifest if the pre-define

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.

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.

# examples

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

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

- Create a manifest from specific metadata types in an org:

$ <%= config.bin %> <%= command.id %> --from-org [email protected] --metadata ApexClass,CustomObject,CustomLabels

- Create a manifest from all metadata components in an org excluding specific metadata types:

$ <%= config.bin %> <%= command.id %> --from-org [email protected] --excluded-metadata StandardValueSet

# flags.include-packages.summary

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.

# flags.excluded-metadata.summary

Metadata types to exclude when building a manifest from an org. Specify the name of the type, not the name of a specific component.

# flags.from-org.summary

Username or alias of the org that contains the metadata components from which to build a manifest.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"@salesforce/kit": "^3.2.3",
"@salesforce/plugin-info": "^3.4.23",
"@salesforce/sf-plugins-core": "^12.1.1",
"@salesforce/source-deploy-retrieve": "^12.10.3",
"@salesforce/source-tracking": "^7.3.4",
"@salesforce/source-deploy-retrieve": "^12.11.2",
"@salesforce/source-tracking": "^7.3.6",
"@salesforce/ts-types": "^2.0.12",
"ansis": "^3.4.0",
"terminal-link": "^3.0.0"
Expand Down
4 changes: 1 addition & 3 deletions src/commands/project/delete/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,7 @@ export class Source extends SfCommand<DeleteSourceJson> {
.filter(sourceComponentIsNotInMixedDeployDelete(this.mixedDeployDelete))
.flatMap((c) =>
// for custom labels, print each custom label to be deleted, not the whole file
isNonDecomposedCustomLabelsOrCustomLabel(c)
? [`${c.type.name}:${c.fullName}`]
: [c.xml, ...c.walkContent()] ?? []
isNonDecomposedCustomLabelsOrCustomLabel(c) ? [`${c.type.name}:${c.fullName}`] : [c.xml, ...c.walkContent()]
)
.concat(this.mixedDeployDelete.delete.map((fr) => `${fr.fullName} (${fr.filePath})`));

Expand Down
35 changes: 25 additions & 10 deletions src/commands/project/generate/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type ManifestGenerateCommandResult = {
path: string;
};

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

export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
public static readonly summary = messages.getMessage('summary');
Expand All @@ -53,14 +53,14 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
metadata: arrayWithDeprecation({
char: 'm',
summary: messages.getMessage('flags.metadata.summary'),
exactlyOne: xorFlags,
exclusive: ['source-dir'],
}),
'source-dir': arrayWithDeprecation({
char: 'p',
aliases: ['sourcepath'],
deprecateAliases: true,
summary: messages.getMessage('flags.source-dir.summary'),
exactlyOne: xorFlags,
exclusive: ['metadata'],
}),
name: Flags.string({
char: 'n',
Expand All @@ -85,11 +85,18 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
char: 'c',
dependsOn: ['from-org'],
}),
'excluded-metadata': Flags.string({
multiple: true,
delimiter: ',',
summary: messages.getMessage('flags.excluded-metadata.summary'),
dependsOn: ['from-org'],
exclusive: ['metadata'],
}),
'from-org': Flags.custom({
summary: messages.getMessage('flags.from-org.summary'),
exactlyOne: xorFlags,
aliases: ['fromorg'],
deprecateAliases: true,
exclusive: ['source-dir'],
parse: async (input: string | undefined) => (input ? Org.create({ aliasOrUsername: input }) : undefined),
})(),
'output-dir': Flags.string({
Expand All @@ -102,6 +109,12 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {

public async run(): Promise<ManifestGenerateCommandResult> {
const { flags } = await this.parse(ManifestGenerate);

// We need at least one of these flags (but could be more than 1): 'metadata', 'source-dir', 'from-org'
if (!Object.keys(flags).some((f) => atLeastOneOfFlags.includes(f))) {
throw Error(`provided flags must include at least one of: ${atLeastOneOfFlags.toString()}`);
}

// convert the manifesttype into one of the "official" manifest names
// if no manifesttype flag passed, use the manifestname?flag
// if no manifestname flag, default to 'package.xml'
Expand All @@ -114,12 +127,14 @@ export class ManifestGenerate extends SfCommand<ManifestGenerateCommandResult> {
const componentSet = await ComponentSetBuilder.build({
apiversion: flags['api-version'] ?? (await getSourceApiVersion()),
sourcepath: flags['source-dir'],
metadata: flags.metadata
? {
metadataEntries: flags.metadata,
directoryPaths: await getPackageDirs(),
}
: undefined,
metadata:
flags.metadata ?? flags['excluded-metadata']
? {
metadataEntries: flags.metadata ?? [],
directoryPaths: await getPackageDirs(),
excludedEntries: flags['excluded-metadata'],
}
: undefined,
org: flags['from-org']
? {
username: flags['from-org'].getUsername() as string,
Expand Down
122 changes: 92 additions & 30 deletions test/nuts/manifest/manifestCreate.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('project generate manifest', () => {
});

it('should produce a manifest (package.xml) for ApexClass', () => {
const result = execCmd<Dictionary>('force:source:manifest:create --metadata ApexClass --json', {
const result = execCmd<Dictionary>('project generate manifest --metadata ApexClass --json', {
ensureExitCode: 0,
}).jsonOutput?.result;
expect(result).to.be.ok;
Expand Down Expand Up @@ -70,7 +70,7 @@ describe('project generate manifest', () => {
const output = join('abc', 'def');
const outputFile = join(output, 'destructiveChanges.xml');
const result = execCmd<Dictionary>(
`force:source:manifest:create --metadata ApexClass --manifesttype destroy --outputdir ${output} --apiversion=51.0 --json`,
`project generate manifest --metadata ApexClass --manifesttype destroy --outputdir ${output} --apiversion=51.0 --json`,
{
ensureExitCode: 0,
}
Expand All @@ -85,7 +85,7 @@ describe('project generate manifest', () => {
const output = join('abc', 'def');
const outputFile = join(output, 'myNewManifest.xml');
const result = execCmd<Dictionary>(
`force:source:manifest:create --metadata ApexClass --manifestname myNewManifest --outputdir ${output} --json`,
`project generate manifest --metadata ApexClass --manifestname myNewManifest --outputdir ${output} --json`,
{
ensureExitCode: 0,
}
Expand All @@ -96,50 +96,112 @@ describe('project generate manifest', () => {

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

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

it('should produce a manifest from metadata in an org', async () => {
const manifestName = 'org-metadata.xml';
const result = execCmd<Dictionary>(`force:source:manifest:create --fromorg ${orgAlias} -n ${manifestName} --json`, {
ensureExitCode: 0,
}).jsonOutput?.result;
expect(result).to.be.ok;
expect(result).to.include({ path: manifestName, name: manifestName });
const stats = fs.statSync(join(session.project.dir, manifestName));
expect(stats.isFile()).to.be.true;
expect(stats.size).to.be.greaterThan(100);
});

it('should produce the same manifest from an org every time', async () => {
config.truncateThreshold = 0;
describe('from org', () => {
before(async () => {
// Deploy all source in the project to the org so there's some metadata in it.
execCmd<Dictionary>('project deploy start', { ensureExitCode: 0 });
});

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

execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-2.xml`, {
ensureExitCode: 0,
it('should produce a manifest from an include list of metadata in an org', async () => {
const manifestName = 'org-metadata.xml';
const includeList = 'ApexClass:FileUtil*,PermissionSet,Flow';
execCmd<Dictionary>(
`project generate manifest --fromorg ${orgAlias} -n ${manifestName} --metadata ${includeList} --json`,
{
ensureExitCode: 0,
}
);
const manifestContents = fs.readFileSync(join(session.project.dir, manifestName), 'utf-8');

const expectedManifestContents =
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<Package xmlns="http://soap.sforce.com/2006/04/metadata">\n' +
' <types>\n' +
' <members>FileUtilities</members>\n' +
' <members>FileUtilitiesTest</members>\n' +
' <name>ApexClass</name>\n' +
' </types>\n' +
' <types>\n' +
' <members>Create_property</members>\n' +
' <name>Flow</name>\n' +
' </types>\n' +
' <types>\n' +
' <members>dreamhouse</members>\n' +
' <members>sfdcInternalInt__sfdc_scrt2</members>\n' +
' <name>PermissionSet</name>\n' +
' </types>\n' +
' <version>61.0</version>\n' +
'</Package>\n';
expect(manifestContents).to.equal(expectedManifestContents);
});
const manifest2 = fs.readFileSync(join(session.project.dir, 'org-metadata-2.xml'), 'utf-8');

execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-3.xml`, {
ensureExitCode: 0,
it('should produce a manifest from an excluded list of metadata in an org', async () => {
const manifestName = 'org-metadata.xml';
const excludedList = 'ApexClass,CustomObject,StandardValueSet';
execCmd<Dictionary>(
`project generate manifest --fromorg ${orgAlias} -n ${manifestName} --excluded-metadata ${excludedList} --json`,
{
ensureExitCode: 0,
}
);
const manifestContents = fs.readFileSync(join(session.project.dir, manifestName), 'utf-8');

// should NOT have these entries
expect(manifestContents).to.not.contain('<name>ApexClass</name>');
expect(manifestContents).to.not.contain('<name>CustomObject</name>');
expect(manifestContents).to.not.contain('<name>StandardValueSet</name>');

// should have these entries
expect(manifestContents).to.contain('<name>Layout</name>');
expect(manifestContents).to.contain('<name>CustomLabels</name>');
expect(manifestContents).to.contain('<name>Profile</name>');
});
const manifest3 = fs.readFileSync(join(session.project.dir, 'org-metadata-3.xml'), 'utf-8');

expect(manifest1).to.equal(manifest2);
expect(manifest2).to.equal(manifest3);
it('should produce the same manifest from an org every time', async () => {
config.truncateThreshold = 0;

execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-1.xml`, {
ensureExitCode: 0,
});
const manifest1 = fs.readFileSync(join(session.project.dir, 'org-metadata-1.xml'), 'utf-8');

execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-2.xml`, {
ensureExitCode: 0,
});
const manifest2 = fs.readFileSync(join(session.project.dir, 'org-metadata-2.xml'), 'utf-8');

execCmd<Dictionary>(`project generate manifest --from-org ${orgAlias} -n org-metadata-3.xml`, {
ensureExitCode: 0,
});
const manifest3 = fs.readFileSync(join(session.project.dir, 'org-metadata-3.xml'), 'utf-8');

expect(manifest1).to.equal(manifest2);
expect(manifest2).to.equal(manifest3);
});
});
});
Loading

0 comments on commit f54affd

Please sign in to comment.