UNPKG

@salesforce/source-deploy-retrieve

Version:

JavaScript library to run Salesforce metadata deploys and retrieves

458 lines 23.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getPackageOptions = exports.extract = void 0; exports.extractVersionNumber = extractVersionNumber; exports.versionMatchesFilter = versionMatchesFilter; exports.filterBotVersionEntries = filterBotVersionEntries; exports.filterAgentComponents = filterAgentComponents; /* * Copyright 2026, Salesforce, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const path = __importStar(require("node:path")); const ts_types_1 = require("@salesforce/ts-types"); const graceful_fs_1 = __importDefault(require("graceful-fs")); const fast_xml_parser_1 = require("fast-xml-parser"); const common_1 = require("../common"); const convert_1 = require("../convert"); const collections_1 = require("../collections"); const resolve_1 = require("../resolve"); const path_1 = require("../utils/path"); const streams_1 = require("../convert/streams"); const types_1 = require("./types"); const extract = async ({ zip, options, logger, mainComponents, }) => { const components = []; const { merge, output, registry, botVersionFilters } = options; const converter = new convert_1.MetadataConverter(registry); const tree = await resolve_1.ZipTreeContainer.create(zip); const partialDeleteFileResponses = []; const packages = [ { zipTreeLocation: 'unpackaged', outputDir: output }, ...(0, exports.getPackageOptions)(options.packageOptions).map(({ name, outputDir }) => ({ zipTreeLocation: name, outputDir, })), ]; for (const pkg of packages) { const outputConfig = merge ? { type: 'merge', mergeWith: mainComponents?.getSourceComponents() ?? [], defaultDirectory: pkg.outputDir, forceIgnoredPaths: mainComponents?.forceIgnoredPaths ?? new Set(), } : { type: 'directory', outputDirectory: pkg.outputDir, }; let retrievedComponents = collections_1.ComponentSet.fromSource({ fsPaths: [pkg.zipTreeLocation], registry, tree, }) .getSourceComponents() .toArray(); // Filter BotVersion components and GenAiPlannerBundle components right after retrieval // This is needed when rootTypesWithDependencies is used, as it will retrieve all BotVersions // and GenAiPlannerBundles regardless of what's in the manifest. // Early exit: only process if there are Bot or GenAiPlannerBundle components const hasRelevantComponents = retrievedComponents.some((comp) => comp.type.name === 'Bot' || comp.type.name === 'GenAiPlannerBundle'); if (hasRelevantComponents) { // If botVersionFilters is undefined, default to 'highest' for all Bot components let filtersToUse = botVersionFilters && Array.isArray(botVersionFilters) ? botVersionFilters : undefined; if (!filtersToUse || filtersToUse.length === 0) { // No filters specified - default to 'highest' for all Bot components const allBotNames = new Set(); for (const comp of retrievedComponents) { if (comp.type.name === 'Bot') { allBotNames.add(comp.fullName); } } if (allBotNames.size > 0) { filtersToUse = Array.from(allBotNames).map((botName) => ({ botName, versionFilter: 'highest', })); } } if (filtersToUse && filtersToUse.length > 0) { // eslint-disable-next-line no-await-in-loop retrievedComponents = await filterAgentComponents(retrievedComponents, filtersToUse); } } if (merge) { partialDeleteFileResponses.push(...handlePartialDeleteMerges({ retrievedComponents, tree, mainComponents, logger })); } // this is intentional sequential // eslint-disable-next-line no-await-in-loop const convertResult = await converter.convert(retrievedComponents, 'source', outputConfig); components.push(...(convertResult?.converted ?? [])); // additional partialDelete logic for decomposed types are handled in the transformer partialDeleteFileResponses.push(...(convertResult?.deleted ?? [])); } return { componentSet: new collections_1.ComponentSet(components, registry), partialDeleteFileResponses }; }; exports.extract = extract; const getPackageOptions = (packageOptions) => (packageOptions ?? []).map((po) => (0, ts_types_1.isString)(po) ? { name: po, outputDir: po } : { name: po.name, outputDir: po.outputDir ?? po.name }); exports.getPackageOptions = getPackageOptions; // Some bundle-like components can be partially deleted in the org, then retrieved. When this // happens, the deleted files need to be deleted on the file system and added to the FileResponses // that are returned by `RetrieveResult.getFileResponses()` for accuracy. The component types that // support this behavior are defined in the metadata registry with `"supportsPartialDelete": true`. // However, not all types can be partially deleted in the org. Currently this only applies to // DigitalExperienceBundle and ExperienceBundle. // side effect: deletes files const handlePartialDeleteMerges = ({ mainComponents, retrievedComponents, tree, logger, }) => { // Find all merge (local) components that support partial delete. const partialDeleteComponents = new Map((mainComponents?.getSourceComponents().toArray() ?? []) .filter(supportsPartialDeleteAndHasContent) .map((comp) => [comp.fullName, { contentPath: comp.content, contentList: graceful_fs_1.default.readdirSync(comp.content) }])); // Compare the contents of the retrieved components that support partial delete with the // matching merge components. If the merge components have files that the retrieved components // don't, delete the merge component and add all locally deleted files to the partial delete list // so that they are added to the `FileResponses` as deletes. return partialDeleteComponents.size === 0 ? [] // If no partial delete components were in the mergeWith ComponentSet, no need to continue. : retrievedComponents .filter(supportsPartialDeleteAndIsInMap(partialDeleteComponents)) .filter((comp) => partialDeleteComponents.get(comp.fullName)?.contentPath) .filter(supportsPartialDeleteAndHasZipContent(tree)) .flatMap((comp) => { // asserted to be defined by the filter above const matchingLocalComp = partialDeleteComponents.get(comp.fullName); const remoteContentList = new Set(tree.readDirectory(comp.content)); return matchingLocalComp.contentList .filter((fileName) => !remoteContentList.has(fileName)) .filter((fileName) => !pathOrSomeChildIsIgnored(logger)(comp)(matchingLocalComp)(fileName)) .map((fileName) => ({ fullName: comp.fullName, type: comp.type.name, state: types_1.ComponentStatus.Deleted, filePath: path.join(matchingLocalComp.contentPath, fileName), })) .map(deleteFilePath(logger)); }); }; const supportsPartialDeleteAndHasContent = (comp) => supportsPartialDelete(comp) && typeof comp.content === 'string' && graceful_fs_1.default.statSync(comp.content).isDirectory(); const supportsPartialDeleteAndHasZipContent = (tree) => (comp) => supportsPartialDelete(comp) && typeof comp.content === 'string' && tree.isDirectory(comp.content); const supportsPartialDeleteAndIsInMap = (partialDeleteComponents) => (comp) => supportsPartialDelete(comp) && partialDeleteComponents.has(comp.fullName); const supportsPartialDelete = (comp) => comp.type.supportsPartialDelete === true; // If fileName is forceignored it is not counted as a diff. If fileName is a directory // we have to read the contents to check forceignore status or we might get a false // negative with `denies()` due to how the ignore library works. const pathOrSomeChildIsIgnored = (logger) => (component) => (localComp) => (fileName) => { const fileNameFullPath = path.join(localComp.contentPath, fileName); return graceful_fs_1.default.statSync(fileNameFullPath).isDirectory() ? graceful_fs_1.default.readdirSync(fileNameFullPath).map((0, path_1.fnJoin)(fileNameFullPath)).some(isForceIgnored(logger)(component)) : isForceIgnored(logger)(component)(fileNameFullPath); }; const isForceIgnored = (logger) => (comp) => (filePath) => { const ignored = comp.getForceIgnore().denies(filePath); if (ignored) { logger.debug(`Local component has ${filePath} while remote does not, but it is forceignored so ignoring.`); } return ignored; }; const deleteFilePath = (logger) => (fr) => { if (fr.filePath) { logger.debug(`Local component (${fr.fullName}) contains ${fr.filePath} while remote component does not. This file is being removed.`); graceful_fs_1.default.rmSync(fr.filePath, { recursive: true, force: true }); } return fr; }; /** * Extracts version number from BotVersion fullName. * BotVersion fullName can be in formats like "v0", "v1", "v2" or "0", "1", "2" * * @internal Exported for testing purposes */ function extractVersionNumber(fullName) { // Match patterns like "v0", "v1", "v2" or just "0", "1", "2" const versionMatch = fullName.match(/^v?(\d+)$/); if (versionMatch) { return parseInt(versionMatch[1], 10); } return null; } /** * Determines if a version number matches the filter criteria. * Shared logic for both Bot and GenAiPlannerBundle filtering. * * @param versionNum The version number to check * @param versionFilter The filter criteria ('all', 'highest', or specific number) * @param highestVersion The highest version number (required when filter is 'highest') * @returns true if the version should be kept, false otherwise * @internal Exported for testing purposes */ function versionMatchesFilter(versionNum, versionFilter, highestVersion) { if (versionFilter === 'all') { return true; } if (versionFilter === 'highest') { return highestVersion !== undefined && versionNum === highestVersion; } // Specific version number return versionNum === versionFilter; } /** * Filters BotVersion entries from a Bot XML based on version filter criteria. * * @internal Exported for testing purposes */ function filterBotVersionEntries(botVersions, versionFilter) { if (versionFilter === 'all') { return botVersions; } // Extract version numbers and find highest if needed const versionsWithNumbers = []; let highestVersion = -1; for (let i = 0; i < botVersions.length; i++) { const version = botVersions[i]; if (version?.fullName) { const versionNum = extractVersionNumber(version.fullName); if (versionNum !== null) { versionsWithNumbers.push({ version, versionNum, index: i }); if (versionNum > highestVersion) { highestVersion = versionNum; } } } } // Filter using shared logic return versionsWithNumbers .filter(({ versionNum }) => versionMatchesFilter(versionNum, versionFilter, highestVersion)) .map(({ version }) => version); } /** * Filters Bot and GenAiPlannerBundle components based on botVersionFilters. * For Bot components: modifies XML to filter BotVersion entries. * For GenAiPlannerBundle components: removes components that don't match filter criteria. * * @param components Retrieved source components * @param botVersionFilters Version filter rules for bots * @returns Components with filtered BotVersion entries and GenAiPlannerBundle components * @internal Exported for testing purposes */ // WeakMap to store normalized Bot XML structures for components that have been filtered // This allows us to return the normalized structure when parseXml is called const normalizedBotXmlMap = new WeakMap(); async function filterAgentComponents(components, botVersionFilters) { const filterMap = new Map(); for (const filter of botVersionFilters) { const botFilter = filter; filterMap.set(botFilter.botName, botFilter); } // Pre-compute which bots need 'highest' filtering const botsNeedingHighest = new Set(); for (const filter of botVersionFilters) { const botFilter = filter; if (botFilter.versionFilter === 'highest') { botsNeedingHighest.add(botFilter.botName); } } // Single pass: pre-compute highest versions, collect Bot components for async processing, // and collect GenAiPlannerBundle components for filtering const highestVersions = new Map(); const botComponents = []; const genAiPlannerBundles = []; const filtered = []; for (const comp of components) { if (comp.type.name === 'Bot') { // Collect Bot components for async processing botComponents.push(comp); // Include in result (will be modified in place) filtered.push(comp); } else if (comp.type.name === 'GenAiPlannerBundle') { // Collect for filtering after we know highest versions genAiPlannerBundles.push(comp); // Pre-compute highest versions const nameMatch = comp.fullName.match(/^(.+)_v(\d+)$/); if (nameMatch) { const botName = nameMatch[1]; const versionNum = parseInt(nameMatch[2], 10); if (botsNeedingHighest.has(botName)) { const currentHighest = highestVersions.get(botName) ?? -1; if (versionNum > currentHighest) { highestVersions.set(botName, versionNum); } } } } else { // Not a Bot or GenAiPlannerBundle, keep it filtered.push(comp); } } // Filter GenAiPlannerBundle components now that we have final highest versions for (const comp of genAiPlannerBundles) { const nameMatch = comp.fullName.match(/^(.+)_v(\d+)$/); if (nameMatch) { const botName = nameMatch[1]; const versionNum = parseInt(nameMatch[2], 10); const matchingFilter = filterMap.get(botName); if (matchingFilter) { const highestVersion = matchingFilter.versionFilter === 'highest' ? highestVersions.get(botName) : undefined; const shouldKeep = versionMatchesFilter(versionNum, matchingFilter.versionFilter, highestVersion); if (shouldKeep) { filtered.push(comp); } } else { // No filter for this bot, keep all GenAiPlannerBundles filtered.push(comp); } } else { // Name doesn't match expected pattern, keep it filtered.push(comp); } } // Process Bot components in parallel (XML parsing is async) const botPromises = botComponents.map(async (comp) => { const matchingFilter = filterMap.get(comp.fullName); if (matchingFilter && comp.xml) { try { // Parse the Bot XML to get BotVersion entries const botXml = await comp.parseXml(); const rawBotVersions = botXml.Bot?.botVersions; // Normalize the structure: XMLParser may group multiple <fullName> elements into { fullName: ['v1', 'v2'] } // but we need [{ fullName: 'v1' }, { fullName: 'v2' }] format let normalizedBotVersions = []; if (rawBotVersions) { if (Array.isArray(rawBotVersions)) { // Already in the correct format normalizedBotVersions = rawBotVersions; } else if (typeof rawBotVersions === 'object' && 'fullName' in rawBotVersions) { // XMLParser grouped format: { fullName: ['v1', 'v2'] } const fullNameValue = rawBotVersions.fullName; if (Array.isArray(fullNameValue)) { normalizedBotVersions = fullNameValue.map((fn) => ({ fullName: fn })); } else if (typeof fullNameValue === 'string') { normalizedBotVersions = [{ fullName: fullNameValue }]; } } } if (normalizedBotVersions.length > 0) { const filteredVersions = filterBotVersionEntries(normalizedBotVersions, matchingFilter.versionFilter); // Extract fullNames and reconstruct the object in the correct format const fullNames = filteredVersions.map((v) => v.fullName).filter((f) => !!f); // Reconstruct Bot XML with filtered versions // We manually construct the botVersions section to avoid XMLParser grouping if (botXml.Bot) { // Update the component's cached XML content // Build XML string using XMLBuilder, but manually construct botVersions section // to avoid XMLParser grouping multiple <fullName> elements const builder = new fast_xml_parser_1.XMLBuilder({ format: true, indentBy: ' ', ignoreAttributes: false, }); // Build XML with botVersions structure // XMLBuilder creates multiple <fullName> elements, XMLParser groups them into { fullName: ['v1', 'v2'] } // The transformer expects [{ fullName: 'v1' }, { fullName: 'v2' }] // We need to normalize this when the XML is parsed, but we can't modify the transformer // So we store a normalized version in pathContentMap and intercept parseXml calls const botWithVersions = { Bot: { ...botXml.Bot, botVersions: fullNames.length > 0 ? { fullName: fullNames.length === 1 ? fullNames[0] : fullNames } : undefined, }, }; const builtXml = String(builder.build(botWithVersions)); const xmlContent = (0, streams_1.correctComments)(common_1.XML_DECL.concat((0, streams_1.handleSpecialEntities)(builtXml))); // Store normalized structure for later parsing // We'll intercept parseXml to return the normalized structure const normalizedBotVersionsForXml = fullNames.map((fn) => ({ fullName: fn })); const normalizedBotXml = { ...botXml, Bot: { ...botXml.Bot, botVersions: normalizedBotVersionsForXml, }, }; // Store both the XML content and the normalized structure if (comp.pathContentMap && comp.xml) { comp.pathContentMap.set(comp.xml, xmlContent); // Store normalized structure in WeakMap for this component normalizedBotXmlMap.set(comp, normalizedBotXml); // Intercept parseXml to return normalized structure for Bot components const originalParseXml = comp.parseXml.bind(comp); comp.parseXml = async (xmlFilePath) => { const xml = xmlFilePath ?? comp.xml; if (xml === comp.xml) { const normalized = normalizedBotXmlMap.get(comp); if (normalized) { // Return normalized structure for this Bot component return normalized; } } // For other cases, use original parseXml return originalParseXml(xmlFilePath); }; } if (comp.pathContentMap && comp.xml) { comp.pathContentMap.set(comp.xml, xmlContent); } } } } catch (error) { // Continue with unfiltered component if there's an error } } return comp; }); await Promise.all(botPromises); return filtered; } //# sourceMappingURL=retrieveExtract.js.map