@salesforce/source-deploy-retrieve
Version:
JavaScript library to run Salesforce metadata deploys and retrieves
406 lines • 26.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MetadataApiDeploy = exports.DeployResult = void 0;
/*
* Copyright (c) 2021, 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
*/
const node_path_1 = require("node:path");
const node_util_1 = require("node:util");
const ts_types_1 = require("@salesforce/ts-types");
const jszip_1 = __importDefault(require("jszip"));
const graceful_fs_1 = __importDefault(require("graceful-fs"));
const core_1 = require("@salesforce/core");
const kit_1 = require("@salesforce/kit");
const registryAccess_1 = require("../registry/registryAccess");
const metadataConverter_1 = require("../convert/metadataConverter");
const metadataTransfer_1 = require("./metadataTransfer");
const types_1 = require("./types");
const deployMessages_1 = require("./deployMessages");
;
const messages = new core_1.Messages('@salesforce/source-deploy-retrieve', 'sdr', new Map([["md_request_fail", "Metadata API request failed: %s"], ["error_convert_invalid_format", "Invalid conversion format '%s'"], ["error_could_not_infer_type", "%s: Could not infer a metadata type"], ["error_unexpected_child_type", "Unexpected child metadata [%s] found for parent type [%s]"], ["noParent", "Could not find parent type for %s (%s)"], ["error_expected_source_files", "%s: Expected source files for type '%s'"], ["error_failed_convert", "Component conversion failed: %s"], ["error_merge_metadata_target_unsupported", "Merge convert for metadata target format currently unsupported"], ["error_missing_adapter", "Missing adapter '%s' for metadata type '%s'"], ["error_missing_transformer", "Missing transformer '%s' for metadata type '%s'"], ["error_missing_type_definition", "Missing metadata type definition in registry for id '%s'."], ["error_missing_child_type_definition", "Type %s does not have a child type definition %s."], ["noChildTypes", "No child types found in registry for %s (reading %s at %s)"], ["error_no_metadata_xml_ignore", "Metadata xml file %s is forceignored but is required for %s."], ["noSourceIgnore", "%s metadata types require source files, but %s is forceignored."], ["noSourceIgnore.actions", "- Metadata types with content are composed of two files: a content file (ie MyApexClass.cls) and a -meta.xml file (i.e MyApexClass.cls-meta.xml). You must include both files in your .forceignore file. Or try appending \u201C\\*\u201D to your existing .forceignore entry.\n\nSee <https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm> for examples"], ["error_path_not_found", "%s: File or folder not found"], ["noContentFound", "SourceComponent %s (metadata type = %s) is missing its content file."], ["noContentFound.actions", ["Ensure the content file exists in the expected location.", "If the content file is in your .forceignore file, ensure the meta-xml file is also ignored to completely exclude it."]], ["error_parsing_xml", "SourceComponent %s (metadata type = %s) does not have an associated metadata xml to parse"], ["error_expected_file_path", "%s: path is to a directory, expected a file"], ["error_expected_directory_path", "%s: path is to a file, expected a directory"], ["error_directory_not_found_or_not_directory", "%s: path is not a directory"], ["error_no_directory_stream", "%s doesn't support readable streams on directories."], ["error_no_source_to_deploy", "No source-backed components present in the package."], ["error_no_components_to_retrieve", "No components in the package to retrieve."], ["error_static_resource_expected_archive_type", "A StaticResource directory must have a content type of application/zip or application/jar - found %s for %s."], ["error_static_resource_missing_resource_file", "A StaticResource must have an associated .resource file, missing %s.resource-meta.xml"], ["error_no_job_id", "The %s operation is missing a job ID. Initialize an operation with an ID, or start a new job."], ["missingApiVersion", "Could not determine an API version to use for the generated manifest. Tried looking for sourceApiVersion in sfdx-project.json, apiVersion from config vars, and the highest apiVersion from the APEX REST endpoint. Using API version 58.0 as a last resort."], ["invalid_xml_parsing", "error parsing %s due to:\\n message: %s\\n line: %s\\n code: %s"], ["zipBufferError", "Zip buffer was not created during conversion"], ["undefinedComponentSet", "Unable to construct a componentSet. Check the logs for more information."], ["replacementsFileNotRead", "The file \"%s\" specified in the \"replacements\" property of sfdx-project.json could not be read."], ["unsupportedBundleType", "Unsupported Bundle Type: %s"], ["filePathGeneratorNoTypeSupport", "Type not supported for filepath generation: %s"], ["missingFolderType", "The registry has %s as is inFolder but it does not have a folderType"], ["tooManyFiles", "Multiple files found for path: %s."], ["cantGetName", "Unable to calculate fullName from path: %s (%s)"], ["missingMetaFileSuffix", "The metadata registry is configured incorrectly for %s. Expected a metaFileSuffix."], ["uniqueIdElementNotInRegistry", "No uniqueIdElement found in registry for %s (reading %s at %s)."], ["uniqueIdElementNotInChild", "The uniqueIdElement %s was not found the child (reading %s at %s)."], ["suggest_type_header", "A metadata type lookup for \"%s\" found the following close matches:"], ["suggest_type_did_you_mean", "-- Did you mean \".%s%s\" instead for the \"%s\" metadata type?"], ["suggest_type_more_suggestions", "Additional suggestions:\nConfirm the file name, extension, and directory names are correct. Validate against the registry at:\n<https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json>\n\nIf the type is not listed in the registry, check that it has Metadata API support via the Metadata Coverage Report:\n<https://developer.salesforce.com/docs/metadata-coverage>\n\nIf the type is available via Metadata API but not in the registry\n\n- Open an issue <https://github.com/forcedotcom/cli/issues>\n- Add the type via PR. Instructions: <https://github.com/forcedotcom/source-deploy-retrieve/blob/main/contributing/metadata.md>"], ["type_name_suggestions", "Confirm the metadata type name is correct. Validate against the registry at:\n<https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json>\n\nIf the type is not listed in the registry, check that it has Metadata API support via the Metadata Coverage Report:\n<https://developer.salesforce.com/docs/metadata-coverage>\n\nIf the type is available via Metadata API but not in the registry\n\n- Open an issue <https://github.com/forcedotcom/cli/issues>\n- Add the type via PR. Instructions: <https://github.com/forcedotcom/source-deploy-retrieve/blob/main/contributing/metadata.md>"]]));
// TODO: (NEXT MAJOR) this should just be a readonly object and not a class.
class DeployResult {
response;
components;
replacements;
zipMeta;
fileResponses;
constructor(response, components, replacements = new Map(), zipMeta) {
this.response = response;
this.components = components;
this.replacements = replacements;
this.zipMeta = zipMeta;
}
getFileResponses() {
// this involves FS operations, so only perform once!
if (!this.fileResponses) {
this.fileResponses = [
// removes duplicates from the file responses by parsing the object into a string, used as the key of the map
...new Map((this.components
? buildFileResponsesFromComponentSet(this.components)(this.response)
: buildFileResponses(this.response)).map((v) => [JSON.stringify(v), v])).values(),
];
}
return this.fileResponses;
}
}
exports.DeployResult = DeployResult;
class MetadataApiDeploy extends metadataTransfer_1.MetadataTransfer {
static DEFAULT_OPTIONS = {
apiOptions: {
rollbackOnError: true,
ignoreWarnings: false,
checkOnly: false,
singlePackage: true,
rest: false,
},
};
options;
replacements = new Map();
orgId;
// Keep track of rest deploys separately since Connection.deploy() removes it
// from the apiOptions and we need it for telemetry.
isRestDeploy;
registry;
zipSize;
zipFileCount;
constructor(options) {
super(options);
options.apiOptions = { ...MetadataApiDeploy.DEFAULT_OPTIONS.apiOptions, ...options.apiOptions };
this.options = Object.assign({}, options);
this.isRestDeploy = !!options.apiOptions?.rest;
this.registry = options.registry ?? new registryAccess_1.RegistryAccess();
if (this.mdapiTempDir) {
this.mdapiTempDir = (0, node_path_1.join)(this.mdapiTempDir, `${new Date().toISOString()}_deploy`);
}
}
/**
* Deploy recently validated components without running Apex tests. Requires the operation to have been
* created with the `{ checkOnly: true }` API option.
*
* Ensure that the following requirements are met before deploying a recent validation:
* - The components have been validated successfully for the target environment within the last 10 days.
* - As part of the validation, Apex tests in the target org have passed.
* - Code coverage requirements are met.
* - If all tests in the org or all local tests are run, overall code coverage is at least 75%, and Apex triggers have some coverage.
* - If specific tests are run with the RunSpecifiedTests test level, each class and trigger that was deployed is covered by at least 75% individually.
*
* See [deployRecentValidation()](https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_deployRecentValidation.htm)
*
* @param rest - Set to `true` to use the REST API, otherwise defaults to using SOAP
* @returns The ID of the quick deployment
*/
async deployRecentValidation(rest = false) {
if (!this.id) {
throw new core_1.SfError(messages.getMessage('error_no_job_id', ['deploy']), 'MissingJobIdError');
}
const conn = await this.getConnection();
const response = (await conn.metadata.deployRecentValidation({
id: this.id,
rest,
}));
return (0, ts_types_1.isString)(response) ? response : response.id;
}
/**
* Check the status of the deploy operation.
*
* @returns Status of the deploy
*/
async checkStatus() {
if (!this.id) {
throw new core_1.SfError(messages.getMessage('error_no_job_id', ['deploy']), 'MissingJobIdError');
}
const connection = await this.getConnection();
// Recasting to use the project's version of the type
return connection.metadata.checkDeployStatus(this.id, true, this.isRestDeploy);
}
/**
* Cancel the deploy operation.
*
* Deploys are asynchronously canceled. Once the cancel request is made to the org,
* check the status of the cancellation with `checkStatus`.
*/
async cancel() {
if (!this.id) {
throw new core_1.SfError(messages.getMessage('error_no_job_id', ['deploy']), 'MissingJobIdError');
}
const connection = await this.getConnection();
await connection.metadata.cancelDeploy(this.id);
}
async pre() {
const LifecycleInstance = core_1.Lifecycle.getInstance();
const connection = await this.getConnection();
const apiVersion = connection.getApiVersion();
// store for use in the scopedPostDeploy event
this.orgId = connection.getAuthInfoFields().orgId;
// If we have a ComponentSet but no version info, use the apiVersion from the Connection.
if (this.components) {
// this is the SOAP/REST API version of the connection
this.components.apiVersion ??= apiVersion;
// this is used as the version in the manifest (package.xml).
this.components.sourceApiVersion ??= apiVersion;
}
// only do event hooks if source, (NOT a metadata format) deploy
if (this.options.components) {
await LifecycleInstance.emit('scopedPreDeploy', {
componentSet: this.options.components,
orgId: this.orgId,
});
}
LifecycleInstance.on('replacement', async (replacement) =>
// lifecycle have to be async, so wrapped in a promise
new Promise((resolve) => {
if (!this.replacements.has(replacement.filename)) {
this.replacements.set(replacement.filename, new Set([replacement.replaced]));
}
else {
this.replacements.get(replacement.filename)?.add(replacement.replaced);
}
resolve();
}));
const [{ zipBuffer, zipFileCount }] = await Promise.all([
this.getZipBuffer(),
this.maybeSaveTempDirectory('metadata'),
]);
// SDR modifies what the mdapi expects by adding a rest param
const { rest, ...optionsWithoutRest } = this.options.apiOptions ?? {};
// Event and Debug output for API version and source API version used for deploy
const manifestVersion = this.components?.sourceApiVersion;
const webService = rest ? 'REST' : 'SOAP';
const manifestMsg = manifestVersion ? ` in v${manifestVersion} shape` : '';
const debugMsg = (0, node_util_1.format)(`Deploying metadata source%s using ${webService} v${apiVersion}`, manifestMsg);
this.logger.debug(debugMsg);
// Event and Debug output for the zip file used for deploy
this.zipSize = zipBuffer.byteLength;
let zipMessage = `Deployment zip file size = ${this.zipSize} Bytes`;
if (zipFileCount) {
this.zipFileCount = zipFileCount;
zipMessage += ` containing ${zipFileCount} files`;
}
this.logger.debug(zipMessage);
await LifecycleInstance.emit('apiVersionDeploy', { webService, manifestVersion, apiVersion });
await LifecycleInstance.emit('deployZipData', { zipSize: this.zipSize, zipFileCount });
await this.warnIfDeployThresholdExceeded(this.zipSize, zipFileCount);
return this.isRestDeploy
? connection.metadata.deployRest(zipBuffer, optionsWithoutRest)
: connection.metadata.deploy(zipBuffer, optionsWithoutRest);
}
async post(result) {
const lifecycle = core_1.Lifecycle.getInstance();
const connection = await this.getConnection();
try {
const apiVersion = connection.getApiVersion();
// Creates an array of unique metadata types that were deployed, uses Set to avoid duplicates.
let listOfMetadataTypesDeployed;
if (this.options.components) {
listOfMetadataTypesDeployed = Array.from(new Set(this.options.components.map((c) => c.type.name)));
}
else {
// mdapi deploys don't have a ComponentSet, so using the result
const types = new Set();
const successes = (0, kit_1.ensureArray)(result.details?.componentSuccesses);
const failures = (0, kit_1.ensureArray)(result.details?.componentFailures);
[...successes, ...failures].forEach((c) => c.componentType && types.add(c.componentType));
listOfMetadataTypesDeployed = Array.from(types);
}
void lifecycle.emitTelemetry({
eventName: 'metadata_api_deploy_result',
library: 'SDR',
status: result.status,
apiVersion,
sourceApiVersion: this.components?.sourceApiVersion,
createdDate: result.createdDate,
startDate: result.startDate,
completedDate: result.completedDate,
rollbackOnError: result.rollbackOnError,
runTestsEnabled: result.runTestsEnabled,
isRestDeploy: this.isRestDeploy,
checkOnly: result.checkOnly,
done: result.done,
ignoreWarnings: result.ignoreWarnings,
metadataTypesDeployed: listOfMetadataTypesDeployed.toString(),
numberComponentErrors: result.numberComponentErrors,
numberComponentsDeployed: result.numberComponentsDeployed,
numberComponentsTotal: result.numberComponentsTotal,
numberTestErrors: result.numberTestErrors,
numberTestsCompleted: result.numberTestsCompleted,
numberTestsTotal: result.numberTestsTotal,
testsTotalTime: result.details?.runTestResult?.totalTime,
filesWithReplacementsQuantity: this.replacements.size ?? 0,
zipSize: this.zipSize ?? 0,
zipFileCount: this.zipFileCount ?? 0,
});
}
catch (err) {
const error = err;
this.logger.debug(`Error trying to compile/send deploy telemetry data for deploy ID: ${this.id ?? '<not provided>'}\nError: ${error.message}`);
}
const deployResult = new DeployResult(result, this.components, new Map(Array.from(this.replacements).map(([k, v]) => [k, Array.from(v)])), { zipSize: this.zipSize ?? 0, zipFileCount: this.zipFileCount });
// only do event hooks if source, (NOT a metadata format) deploy
if (this.options.components) {
// this may not be set if you resume a deploy so that `pre` is skipped.
this.orgId ??= connection.getAuthInfoFields().orgId;
// previous step ensures string exists
if (this.orgId) {
await lifecycle.emit('scopedPostDeploy', { deployResult, orgId: this.orgId });
}
}
return deployResult;
}
// By default, an 80% deploy size threshold is used to warn users when their deploy size
// is approaching the limit enforced by the Metadata API. This includes the number of files
// being deployed as well as the byte size of the deployment. The threshold can be overridden
// to be a different percentage using the SF_DEPLOY_SIZE_THRESHOLD env var. An env var value
// of 100 would disable the client side warning. An env var value of 0 would always warn.
async warnIfDeployThresholdExceeded(zipSize, zipFileCount) {
const thresholdPercentage = Math.abs(core_1.envVars.getNumber('SF_DEPLOY_SIZE_THRESHOLD', 80));
if (thresholdPercentage >= 100) {
this.logger.debug(`Deploy size warning is disabled since SF_DEPLOY_SIZE_THRESHOLD is overridden to: ${thresholdPercentage}`);
return;
}
if (thresholdPercentage !== 80) {
this.logger.debug(`Deploy size warning threshold has been overridden by SF_DEPLOY_SIZE_THRESHOLD to: ${thresholdPercentage}`);
}
// 39_000_000 is 39 MB in decimal format, which is the format used in buffer.byteLength
const fileSizeThreshold = Math.round(39_000_000 * (thresholdPercentage / 100));
const fileCountThreshold = Math.round(10_000 * (thresholdPercentage / 100));
if (zipSize > fileSizeThreshold) {
await core_1.Lifecycle.getInstance().emitWarning(`Deployment zip file size is approaching the Metadata API limit (~39MB). Warning threshold is ${thresholdPercentage}% and size ${zipSize} > ${fileSizeThreshold}`);
}
if (zipFileCount && zipFileCount > fileCountThreshold) {
await core_1.Lifecycle.getInstance().emitWarning(`Deployment zip file count is approaching the Metadata API limit (10,000). Warning threshold is ${thresholdPercentage}% and count ${zipFileCount} > ${fileCountThreshold}`);
}
}
async getZipBuffer() {
const mdapiPath = this.options.mdapiPath;
// Zip a directory of metadata format source
if (mdapiPath) {
if (!graceful_fs_1.default.existsSync(mdapiPath) || !graceful_fs_1.default.lstatSync(mdapiPath).isDirectory()) {
throw messages.createError('error_directory_not_found_or_not_directory', [mdapiPath]);
}
const zip = (0, jszip_1.default)();
let zipFileCount = 0;
const zipDirRecursive = (dir) => {
const dirents = graceful_fs_1.default.readdirSync(dir, { withFileTypes: true });
for (const dirent of dirents) {
const fullPath = (0, node_path_1.resolve)(dir, dirent.name);
if (dirent.isDirectory()) {
zipDirRecursive(fullPath);
}
else {
// Add relative file paths to a root of "zip" for MDAPI.
const relPath = (0, node_path_1.join)('zip', (0, node_path_1.relative)(mdapiPath, fullPath));
// Ensure only posix paths are added to zip files
const relPosixPath = relPath.replace(/\\/g, '/');
zip.file(relPosixPath, graceful_fs_1.default.createReadStream(fullPath));
zipFileCount++;
}
}
};
this.logger.debug(`Zipping directory for metadata deploy: ${mdapiPath}`);
zipDirRecursive(mdapiPath);
return {
zipBuffer: await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 9 },
}),
zipFileCount,
};
}
// Read a zip of metadata format source into a buffer
if (this.options.zipPath) {
if (!graceful_fs_1.default.existsSync(this.options.zipPath)) {
throw new core_1.SfError(messages.getMessage('error_path_not_found', [this.options.zipPath]));
}
// does encoding matter for zip files? I don't know
return { zipBuffer: await graceful_fs_1.default.promises.readFile(this.options.zipPath) };
}
// Convert a ComponentSet of metadata in source format and zip
if (this.options.components && this.components) {
const converter = new metadataConverter_1.MetadataConverter(this.registry);
const { zipBuffer, zipFileCount } = await converter.convert(this.components, 'metadata', { type: 'zip' });
if (!zipBuffer) {
throw new core_1.SfError(messages.getMessage('zipBufferError'));
}
return { zipBuffer, zipFileCount };
}
throw new Error('Options should include components, zipPath, or mdapiPath');
}
}
exports.MetadataApiDeploy = MetadataApiDeploy;
/**
* If a component fails to delete because it doesn't exist in the org, you get a message like
* key: 'ApexClass#destructiveChanges.xml'
* value:[{
* fullName: 'destructiveChanges.xml',
* fileName: 'destructiveChanges.xml',
* componentType: 'ApexClass',
* problem: 'No ApexClass named: test1 found',
* problemType: 'Warning'
* }]
*/
const deleteNotFoundToFileResponses = (cs) => (messageMap) => Array.from(messageMap)
.filter(([key]) => key.includes('destructiveChanges') && key.endsWith('.xml'))
.flatMap(([, messageArray]) => messageArray.filter(deployMessages_1.isComponentNotFoundWarningMessage))
.flatMap((message) => {
const fullName = message.problem.replace(`No ${message.componentType} named: `, '').replace(' found', '');
return cs
? cs.getComponentFilenamesByNameAndType({ fullName, type: message.componentType }).map((fileName) => ({
fullName,
type: message.componentType,
filePath: fileName,
state: types_1.ComponentStatus.Deleted,
}))
: [];
});
const warnIfUnmatchedServerResult = (fr) => (messageMap) =>
// keep the parents and children separated for MPD scenarios where we have a parent in one, children in another package
[...messageMap.keys()].flatMap((key) => {
const [type, fullName] = key.split('#');
if (!fr.find((c) => c.type === type && c.fullName === fullName) &&
!['package.xml', 'destructiveChanges.xml', 'destructiveChangesPost.xml', 'destructiveChangesPre.xml'].includes(fullName)) {
const deployMessage = messageMap.get(key).at(0);
// warn that this component is found in server response, but not in component set
void core_1.Lifecycle.getInstance().emitWarning(`${deployMessage.componentType ?? '<no component type in deploy message>'}, ${deployMessage.fullName}, returned from org, but not found in the local project`);
}
});
const buildFileResponses = (response) => (0, kit_1.ensureArray)(response.details?.componentSuccesses)
.concat((0, kit_1.ensureArray)(response.details?.componentFailures))
.filter((c) => c.fullName !== 'package.xml')
.map((c) => ({
...((0, deployMessages_1.getState)(c) === types_1.ComponentStatus.Failed
? {
error: c.problem,
problemType: c.problemType,
columnNumber: c.columnNumber ? parseInt(c.columnNumber, 10) : undefined,
lineNumber: c.lineNumber ? parseInt(c.lineNumber, 10) : undefined,
}
: {}),
fullName: c.fullName,
type: c.componentType,
state: (0, deployMessages_1.getState)(c),
filePath: c.fileName.replace(`zip${node_path_1.sep}`, ''),
}));
const buildFileResponsesFromComponentSet = (cs) => (response) => {
const responseMessages = (0, deployMessages_1.getDeployMessages)(response);
const fileResponses = (cs.getSourceComponents().toArray() ?? [])
.flatMap((deployedComponent) => (0, deployMessages_1.createResponses)(deployedComponent, responseMessages.get((0, deployMessages_1.toKey)(deployedComponent)) ?? []).concat(deployedComponent.type.children
? deployedComponent.getChildren().flatMap((child) => {
const childMessages = responseMessages.get((0, deployMessages_1.toKey)(child));
return childMessages ? (0, deployMessages_1.createResponses)(child, childMessages) : [];
})
: []))
.concat(deleteNotFoundToFileResponses(cs)(responseMessages));
if (cs.size) {
warnIfUnmatchedServerResult(fileResponses)(responseMessages);
}
return fileResponses;
};
//# sourceMappingURL=metadataApiDeploy.js.map