UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

340 lines 17.5 kB
import fs from 'fs-extra'; import * as path from 'path'; import c from 'chalk'; import { glob } from 'glob'; import { execCommand, createTempDir, uxLog } from '../index.js'; import { writeXmlFile } from '../xmlUtils.js'; import { getApiVersion } from '../../../config/index.js'; import { prompts } from '../prompts.js'; import { GLOB_IGNORE_PATTERNS } from '../projectUtils.js'; export function generateConnectedAppPackageXml(connectedApps) { return { Package: { $: { xmlns: 'http://soap.sforce.com/2006/04/metadata' }, types: [ { members: connectedApps.map(app => app.fullName), name: ['ConnectedApp'] } ], version: [getApiVersion()] } }; } export function generateEmptyPackageXml() { return { Package: { $: { xmlns: 'http://soap.sforce.com/2006/04/metadata' }, version: [getApiVersion()] } }; } export async function createConnectedAppManifest(connectedApps, command) { // Create a temporary directory for the manifest const tmpDir = await createTempDir(); const manifestPath = path.join(tmpDir, 'connected-apps-manifest.xml'); // Generate and write the package.xml content const packageXml = generateConnectedAppPackageXml(connectedApps); await writeXmlFile(manifestPath, packageXml); // Display the XML content for the manifest const manifestContent = await fs.readFile(manifestPath, 'utf8'); uxLog("log", command, c.cyan(`package.xml manifest for ${connectedApps.length} connected app(s):\n${manifestContent}`)); return { manifestPath, tmpDir }; } export async function withConnectedAppIgnoreHandling(operationFn, command) { // Temporarily modify .forceignore to allow Connected App operations const backupInfo = await disableConnectedAppIgnore(command); try { // Perform the operation return await operationFn(backupInfo); } finally { // Always restore .forceignore await restoreConnectedAppIgnore(backupInfo, command); } } export async function createDestructiveChangesManifest(connectedApps, command) { // Create a temporary directory for the manifest const tmpDir = await createTempDir(); const destructiveChangesPath = path.join(tmpDir, 'destructiveChanges.xml'); const packageXmlPath = path.join(tmpDir, 'package.xml'); // Generate destructiveChanges.xml using the Connected App Package XML generator const destructiveChangesXml = generateConnectedAppPackageXml(connectedApps); // Generate empty package.xml required for deployment const packageXml = generateEmptyPackageXml(); await writeXmlFile(destructiveChangesPath, destructiveChangesXml); await writeXmlFile(packageXmlPath, packageXml); // Display the XML content for destructive changes const destructiveXmlContent = await fs.readFile(destructiveChangesPath, 'utf8'); uxLog("log", command, c.cyan(`Destructive changes XML for deleting ${connectedApps.length} connected app(s):\n${destructiveXmlContent}`)); return { destructiveChangesPath, packageXmlPath, tmpDir }; } export async function deleteConnectedApps(orgUsername, connectedApps, command, saveProjectPath) { await withConnectedAppValidation(orgUsername, connectedApps, command, 'delete', async () => { if (!orgUsername) return; // This should never happen due to validation, but TypeScript needs it // Use withConnectedAppIgnoreHandling to handle .forceignore modifications await withConnectedAppIgnoreHandling(async () => { // Create destructive changes manifests const { destructiveChangesPath, packageXmlPath, tmpDir } = await createDestructiveChangesManifest(connectedApps, command); // Deploy the destructive changes uxLog("log", command, c.grey(`Deploying destructive changes to delete ${connectedApps.length} Connected App(s) from org...`)); try { await execCommand(`sf project deploy start --manifest ${packageXmlPath} --post-destructive-changes ${destructiveChangesPath} --target-org ${orgUsername} --ignore-warnings --ignore-conflicts --json`, command, { output: true, fail: true, cwd: saveProjectPath }); } catch (deleteError) { throw new Error(`Failed to delete Connected Apps: ${deleteError.message || String(deleteError)}`); } // Clean up await fs.remove(tmpDir); uxLog("log", command, c.grey('Removed temporary deployment files.')); }, command); }); } export async function disableConnectedAppIgnore(command) { const forceignorePath = path.join(process.cwd(), '.forceignore'); // Check if .forceignore exists if (!await fs.pathExists(forceignorePath)) { uxLog("log", command, c.grey('No .forceignore file found; no modification needed.')); return null; } // Create backup const tempBackupPath = path.join(process.cwd(), '.forceignore.backup'); const originalContent = await fs.readFile(forceignorePath, 'utf8'); await fs.writeFile(tempBackupPath, originalContent); // Read content and remove lines that would ignore Connected Apps const lines = originalContent.split('\n'); const filteredLines = lines.filter(line => { const trimmedLine = line.trim(); return !(trimmedLine.includes('connectedApp') || trimmedLine.includes('ConnectedApp') || trimmedLine.includes('connectedApps')); }); // Check if any lines were filtered out if (lines.length === filteredLines.length) { uxLog("log", command, c.grey('No Connected App ignore patterns found in .forceignore.')); return { forceignorePath, originalContent, tempBackupPath }; } // Write modified .forceignore await fs.writeFile(forceignorePath, filteredLines.join('\n')); uxLog("warning", command, c.cyan('Temporarily modified .forceignore to allow Connected App metadata operations.')); return { forceignorePath, originalContent, tempBackupPath }; } export async function restoreConnectedAppIgnore(backupInfo, command) { if (!backupInfo) return; try { // Restore original .forceignore if backup exists if (await fs.pathExists(backupInfo.tempBackupPath)) { await fs.writeFile(backupInfo.forceignorePath, backupInfo.originalContent); await fs.remove(backupInfo.tempBackupPath); uxLog("log", command, c.grey('Restored original .forceignore file.')); } } catch (error) { uxLog("warning", command, c.yellow(`Error restoring .forceignore: ${error}`)); } } export async function retrieveConnectedApps(orgUsername, connectedApps, command, saveProjectPath) { await withConnectedAppValidation(orgUsername, connectedApps, command, 'retrieve', async () => { if (!orgUsername) return; // This should never happen due to validation, but TypeScript needs it await performConnectedAppOperationWithManifest(orgUsername, connectedApps, command, 'retrieve', async (manifestPath, orgUsername, command) => { await execCommand(`sf project retrieve start --manifest ${manifestPath} --target-org ${orgUsername} --ignore-conflicts --json`, command, { output: true, fail: true, cwd: saveProjectPath }); }); }); } export async function deployConnectedApps(orgUsername, connectedApps, command, saveProjectPath) { await withConnectedAppValidation(orgUsername, connectedApps, command, 'deploy', async () => { if (!orgUsername) return; // This should never happen due to validation, but TypeScript needs it await performConnectedAppOperationWithManifest(orgUsername, connectedApps, command, 'deploy', async (manifestPath, orgUsername, command) => { await execCommand(`sf project deploy start --manifest ${manifestPath} --target-org ${orgUsername} --ignore-warnings --json`, command, { output: true, fail: true, cwd: saveProjectPath }); }); }); } export function toConnectedAppFormat(apps) { return apps.map(app => { return { fullName: app.fullName, fileName: app.fileName || app.fullName || (app.filePath ? path.basename(app.filePath, '.connectedApp-meta.xml') : app.fullName), type: 'ConnectedApp' }; }); } export function validateConnectedApps(requestedApps, availableApps, command, context) { // Case-insensitive matching for app names const missingApps = requestedApps.filter(name => !availableApps.some(availableName => availableName.toLowerCase() === name.toLowerCase())); if (missingApps.length > 0) { const errorMsg = `The following Connected App(s) could not be found in the ${context}: ${missingApps.join(', ')}`; uxLog("error", command, c.red(errorMsg)); if (availableApps.length > 0) { uxLog("warning", command, c.yellow(`Available connected apps in the ${context}:`)); availableApps.forEach(name => { uxLog("log", command, c.grey(` - ${name}`)); }); // Suggest similar names to help the user missingApps.forEach(missingApp => { const similarNames = availableApps .filter(name => name.toLowerCase().includes(missingApp.toLowerCase()) || missingApp.toLowerCase().includes(name.toLowerCase())) .slice(0, 3); if (similarNames.length > 0) { uxLog("warning", command, c.yellow(`Did you mean one of these instead of "${missingApp}"?`)); similarNames.forEach(name => { uxLog("log", command, c.grey(` - ${name}`)); }); } }); } else { uxLog("warning", command, c.yellow(`No Connected Apps were found in the ${context}.`)); } uxLog("warning", command, c.yellow('Please check the app name(s) and try again.')); throw new Error(errorMsg); } // Return the list of valid apps const validApps = requestedApps.filter(name => availableApps.some(availableName => availableName.toLowerCase() === name.toLowerCase())); return { missingApps, validApps }; } export function validateConnectedAppParams(orgUsername, connectedApps) { if (!orgUsername) { throw new Error('Organization username is required'); } if (!connectedApps || connectedApps.length === 0) { throw new Error('No Connected Apps specified'); } } export async function promptForConnectedAppSelection(connectedApps, initialSelection = [], promptMessage) { // Create choices for the prompt const choices = connectedApps.map(app => { return { title: app.fullName, value: app.fullName }; }); // Prompt user for selection const promptResponse = await prompts({ type: 'multiselect', name: 'selectedApps', message: promptMessage, description: 'Select Connected Apps to process.', choices: choices, initial: initialSelection, }); if (!promptResponse.selectedApps || promptResponse.selectedApps.length === 0) { return []; } // Filter apps based on selection const selectedApps = connectedApps.filter(app => promptResponse.selectedApps.includes(app.fullName)); return selectedApps; } export async function findConnectedAppFile(appName, command, saveProjectPath) { uxLog("other", command, c.cyan(`Searching for Connected App: ${appName}.`)); try { // First, try an exact case-sensitive match const exactPattern = `**/${appName}.connectedApp-meta.xml`; const exactMatches = await glob(exactPattern, { ignore: GLOB_IGNORE_PATTERNS, cwd: saveProjectPath }); if (exactMatches.length > 0) { uxLog("success", command, c.green(`✓ Found connected app: ${exactMatches[0]}`)); return path.join(saveProjectPath, exactMatches[0]); } // Try standard locations with possible name variations const possiblePaths = [ path.join(saveProjectPath, `force-app/main/default/connectedApps/${appName}.connectedApp-meta.xml`), path.join(saveProjectPath, `force-app/main/default/connectedApps/${appName.replace(/\s/g, '_')}.connectedApp-meta.xml`), path.join(saveProjectPath, `force-app/main/default/connectedApps/${appName.replace(/\s/g, '')}.connectedApp-meta.xml`) ]; for (const potentialPath of possiblePaths) { if (fs.existsSync(potentialPath)) { uxLog("success", command, c.green(`✓ Found connected app at standard path: ${potentialPath}`)); return potentialPath; } } // If no exact match, try case-insensitive search by getting all ConnectedApp files uxLog("warning", command, c.yellow(`No exact match found; trying case-insensitive search...`)); const allConnectedAppFiles = await glob('**/*.connectedApp-meta.xml', { ignore: GLOB_IGNORE_PATTERNS, cwd: saveProjectPath }); if (allConnectedAppFiles.length === 0) { uxLog("error", command, c.red(`No connected app files found in the project.`)); return null; } // Find a case-insensitive match const caseInsensitiveMatch = allConnectedAppFiles.find(file => { const baseName = path.basename(file, '.connectedApp-meta.xml'); return baseName.toLowerCase() === appName.toLowerCase() || baseName.toLowerCase() === appName.toLowerCase().replace(/\s/g, '_') || baseName.toLowerCase() === appName.toLowerCase().replace(/\s/g, ''); }); if (caseInsensitiveMatch) { uxLog("success", command, c.green(`✓ Found case-insensitive match: ${caseInsensitiveMatch}`)); return path.join(saveProjectPath, caseInsensitiveMatch); } // If still not found, list available Connected Apps uxLog("error", command, c.red(`✗ Could not find connected app "${appName}".`)); const allConnectedAppNames = allConnectedAppFiles.map(file => "- " + path.basename(file, '.connectedApp-meta.xml')).join('\n'); uxLog("warning", command, c.yellow(`Available connected apps:\n${allConnectedAppNames}`)); return null; } catch (error) { uxLog("error", command, c.red(`Error searching for connected app: ${error}.`)); return null; } } export async function selectConnectedAppsForProcessing(connectedApps, initialSelection = [], processAll, nameFilter, promptMessage, command) { // If all flag or name is provided, use all connected apps from the list without prompting if (processAll || nameFilter) { const selectionReason = processAll ? 'all flag' : 'name filter'; uxLog("action", command, c.cyan(`Processing ${connectedApps.length} connected app(s) based on ${selectionReason}.`)); return connectedApps; } // Otherwise, prompt for selection return await promptForConnectedAppSelection(connectedApps, initialSelection, promptMessage); } export async function withConnectedAppValidation(orgUsername, connectedApps, command, operationName, operationFn) { try { validateConnectedAppParams(orgUsername, connectedApps); } catch (error) { uxLog("log", command, c.yellow(`Skipping ${operationName} operation: ${error.message}`)); return; } await operationFn(); } export async function performConnectedAppOperationWithManifest(orgUsername, connectedApps, command, operationName, commandFn) { // Use withConnectedAppIgnoreHandling to handle .forceignore modifications await withConnectedAppIgnoreHandling(async () => { // Create a manifest for the Connected Apps const { manifestPath, tmpDir } = await createConnectedAppManifest(connectedApps, command); // Execute the operation using the manifest uxLog("log", command, c.cyan(`${operationName === 'retrieve' ? 'Retrieving' : 'Deploying'} ${connectedApps.length} connected app(s) ${operationName === 'retrieve' ? 'from' : 'to'} org...`)); try { await commandFn(manifestPath, orgUsername, command); // Wait a moment to ensure files are written to disk (especially for retrieve operations) if (operationName === 'retrieve') { uxLog("log", command, c.grey('Waiting for files to be written to disk...')); await new Promise(resolve => setTimeout(resolve, 1000)); } } catch (error) { throw new Error(`Failed to ${operationName} Connected Apps: ${error.message || String(error)}`); } // Clean up await fs.remove(tmpDir); uxLog("log", command, c.grey('Removed temporary manifest file.')); }, command); } export function createConnectedAppSuccessResponse(message, processedApps, additionalData = {}) { return { success: true, message, connectedAppsProcessed: processedApps, ...additionalData }; } export function handleConnectedAppError(error, command) { const errorMessage = error.message || JSON.stringify(error); uxLog("error", command, c.red(`Error: ${errorMessage}`)); return { success: false, error: errorMessage }; } //# sourceMappingURL=connectedAppUtils.js.map