UNPKG

@salesforce/source-deploy-retrieve

Version:

JavaScript library to run Salesforce metadata deploys and retrieves

406 lines 26.8 kB
"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