@salesforce/source-deploy-retrieve
Version:
JavaScript library to run Salesforce metadata deploys and retrieves
222 lines • 16.9 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.posixifyPaths = exports.stringToRegex = exports.envFilter = exports.matchesFile = exports.getReplacements = exports.getContentsOfReplacementFile = exports.getReplacementMarkingStream = exports.replacementIterations = exports.getReplacementStreamForReadable = void 0;
/*
* Copyright (c) 2020, 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 promises_1 = require("node:fs/promises");
const node_stream_1 = require("node:stream");
const node_path_1 = require("node:path");
const core_1 = require("@salesforce/core");
const minimatch_1 = require("minimatch");
const kit_1 = require("@salesforce/kit");
const ts_types_1 = require("@salesforce/ts-types");
const isbinaryfile_1 = require("isbinaryfile");
;
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>"]]));
const fileContentsCache = new Map();
// First do a quick check for common text extensions
// If that fails, confirm that it is not a binary file
const textExtensions = new Set(['.cls', '.xml', '.json', '.js', '.css', '.html', '.htm', '.txt', '.md']);
const isTextFile = (path) => textExtensions.has((0, node_path_1.extname)(path)) || !(0, isbinaryfile_1.isBinaryFileSync)(path);
/** If a component has replacements, you get it piped through the replacementStream
* Otherwise, you'll get the original readable stream
* Ignore binary files, they will get corrupted in the replacement process
*/
const getReplacementStreamForReadable = (component, path) => component.replacements?.[path] && isTextFile(path)
? component.tree.stream(path).pipe(new ReplacementStream(component.replacements?.[path]))
: component.tree.stream(path);
exports.getReplacementStreamForReadable = getReplacementStreamForReadable;
/**
* A stream for replacing the contents of a single SourceComponent.
*
*/
class ReplacementStream extends node_stream_1.Transform {
replacements;
constructor(replacements) {
super({ objectMode: true });
this.replacements = replacements;
}
async _transform(chunk, encoding, callback) {
let error;
// read and do the various replacements
callback(error, Buffer.from(await (0, exports.replacementIterations)(chunk.toString(), this.replacements)));
}
}
/**
* perform an array of replacements on a string
* emits warnings when an expected replacement target isn't found
*/
const replacementIterations = async (input, replacements) => {
const lifecycleInstance = core_1.Lifecycle.getInstance();
let output = input;
for (const replacement of replacements) {
// TODO: node 16+ has String.replaceAll for non-regex scenarios
const regex = typeof replacement.toReplace === 'string' ? new RegExp(replacement.toReplace, 'g') : replacement.toReplace;
const replaced = output.replace(regex, replacement.replaceWith ?? '');
if (replaced !== output) {
output = replaced;
// eslint-disable-next-line no-await-in-loop
await lifecycleInstance.emit('replacement', {
filename: replacement.matchedFilename,
replaced: replacement.toReplace.toString(),
});
}
else if (replacement.singleFile) {
// replacements need to be done sequentially
// eslint-disable-next-line no-await-in-loop
await lifecycleInstance.emitWarning(`Your sfdx-project.json specifies that ${replacement.toReplace.toString()} should be replaced in ${replacement.matchedFilename}, but it was not found.`);
}
}
return output;
};
exports.replacementIterations = replacementIterations;
/**
* Reads the project, gets replacements, removes any that aren't applicable due to environment conditionals, and returns an instance of the ReplacementMarkingStream
*/
const getReplacementMarkingStream = async (projectDir) => {
// remove any that don't agree with current env
const filteredReplacements = (await readReplacementsFromProject(projectDir)).filter(exports.envFilter);
return filteredReplacements.length ? new ReplacementMarkingStream(filteredReplacements) : undefined;
};
exports.getReplacementMarkingStream = getReplacementMarkingStream;
/**
* Stream for marking replacements on a component.
* Returns a mutated component with a `replacements` property if any replacements are found.
* Throws if any replacements reference a file or env that does not exist
*/
class ReplacementMarkingStream extends node_stream_1.Transform {
replacementConfigs;
constructor(replacementConfigs) {
super({ objectMode: true });
this.replacementConfigs = replacementConfigs;
}
async _transform(chunk, encoding, callback) {
let err;
// if deleting, or no configs, just pass through
if (!chunk.isMarkedForDelete() && this.replacementConfigs?.length) {
try {
chunk.replacements = await (0, exports.getReplacements)(chunk, this.replacementConfigs);
if (chunk.replacements && chunk.parent?.type.strategies?.transformer === 'nonDecomposed') {
// Set replacements on the parent of a nonDecomposed CustomLabel as well so that recomposing
// doesn't use the non-replaced content from parent cache.
// See RecompositionFinalizer.recompose() in convertContext.ts
chunk.parent.replacements = chunk.replacements;
}
}
catch (e) {
if (!(e instanceof Error)) {
throw e;
}
err = e;
}
}
callback(err, chunk);
}
}
const getContentsOfReplacementFile = async (path) => {
if (!fileContentsCache.has(path)) {
try {
fileContentsCache.set(path, (await (0, promises_1.readFile)(path, 'utf8')).trim());
}
catch (e) {
throw messages.createError('replacementsFileNotRead', [path]);
}
}
const output = fileContentsCache.get(path);
if (!output) {
throw messages.createError('replacementsFileNotRead', [path]);
}
return output;
};
exports.getContentsOfReplacementFile = getContentsOfReplacementFile;
/**
* Build the replacements property for a sourceComponent
*/
const getReplacements = async (cmp, replacementConfigs = []) => {
// all possible filenames for this component
const filenames = [cmp.xml, ...cmp.walkContent()].filter(ts_types_1.isString);
const replacementsForComponent = (await Promise.all(
// build a nested array that can be run through Object.fromEntries
// one MarkedReplacement[] for each file in the component
filenames.map(async (f) => [
f,
await Promise.all(replacementConfigs
// filter out any that don't match the current file
.filter((0, exports.matchesFile)(f))
.map(async (r) => ({
matchedFilename: f,
// used during replacement stream to limit warnings to explicit filenames, not globs
singleFile: Boolean(r.filename),
// Config is json which might use the regex. If so, turn it into an actual regex
toReplace: typeof r.stringToReplace === 'string'
? (0, exports.stringToRegex)(r.stringToReplace)
: new RegExp(r.regexToReplace, 'g'),
// get the literal replacement (either from env or file contents)
replaceWith: typeof r.replaceWithEnv === 'string'
? getEnvValue(r.replaceWithEnv, r.allowUnsetEnvVariable)
: await (0, exports.getContentsOfReplacementFile)(r.replaceWithFile),
}))),
])))
// filter out any that don't have any replacements
.filter(([, replacements]) => replacements.length > 0);
// turn into a Dictionary-style object so it's easier to lookup by filename
return replacementsForComponent.length ? Object.fromEntries(replacementsForComponent) : undefined;
};
exports.getReplacements = getReplacements;
const matchesFile = (filename) => (r) =>
// filenames will be absolute. We don't have convenient access to the pkgDirs,
// so we need to be more open than an exact match
(typeof r.filename === 'string' && (0, exports.posixifyPaths)(filename).endsWith(r.filename)) ||
(typeof r.glob === 'string' && (0, minimatch_1.minimatch)(filename, `**/${r.glob}`));
exports.matchesFile = matchesFile;
/**
* Regardless of any components, return the ReplacementConfig that are valid with the current env.
* These can be checked globally and don't need to be checked per component.
*/
const envFilter = (replacement) => !replacement.replaceWhenEnv ||
replacement.replaceWhenEnv.every((envConditional) => process.env[envConditional.env] === envConditional.value.toString());
exports.envFilter = envFilter;
/** A "getter" for envs to throw an error when an expected env is not present */
const getEnvValue = (env, allowUnset = false) => allowUnset
? new kit_1.Env().getString(env, '')
: (0, ts_types_1.ensureString)(new kit_1.Env().getString(env), `"${env}" is in sfdx-project.json as a value for "replaceWithEnv" property, but it's not set in your environment.`);
/**
* Read the `replacement` property from sfdx-project.json
*/
const readReplacementsFromProject = async (projectDir) => {
try {
const proj = await core_1.SfProject.resolve(projectDir);
const projJson = (await proj.resolveProjectConfig());
const definiteProjectDir = proj.getPath();
return (projJson.replacements ?? []).map(makeAbsolute(definiteProjectDir));
}
catch (e) {
if (e instanceof core_1.SfError && e.name === 'InvalidProjectWorkspaceError') {
return [];
}
throw e;
}
};
/** escape any special characters used in the string so it can be used as a regex */
const stringToRegex = (input) =>
// being overly conservative
// eslint-disable-next-line no-useless-escape
new RegExp(input.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g');
exports.stringToRegex = stringToRegex;
const posixifyPaths = (f) => f.split(node_path_1.sep).join(node_path_1.posix.sep);
exports.posixifyPaths = posixifyPaths;
/** if replaceWithFile is present, resolve it to an absolute path relative to the projectdir */
const makeAbsolute = (projectDir) => (replacementConfig) => replacementConfig.replaceWithFile
? {
...replacementConfig,
// it could already be absolute?
replaceWithFile: (0, node_path_1.isAbsolute)(replacementConfig.replaceWithFile)
? replacementConfig.replaceWithFile
: (0, node_path_1.join)(projectDir, replacementConfig.replaceWithFile),
}
: replacementConfig;
//# sourceMappingURL=replacements.js.map
;