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
707 lines (699 loc) • 40.7 kB
JavaScript
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import * as path from 'path';
import c from 'chalk';
import fs from 'fs-extra';
import { glob } from 'glob';
import { execSfdxJson, uxLog } from '../../../../common/utils/index.js';
import { parsePackageXmlFile, parseXmlFile, writePackageXmlFile } from '../../../../common/utils/xmlUtils.js';
import { GLOB_IGNORE_PATTERNS } from '../../../../common/utils/projectUtils.js';
import { deleteConnectedApps, deployConnectedApps, toConnectedAppFormat, validateConnectedApps, selectConnectedAppsForProcessing, createConnectedAppSuccessResponse, handleConnectedAppError } from '../../../../common/utils/refresh/connectedAppUtils.js';
import { getConfig } from '../../../../config/index.js';
import { prompts } from '../../../../common/utils/prompts.js';
import { WebSocketClient } from '../../../../common/websocketClient.js';
import { soqlQuery, soqlQueryTooling } from '../../../../common/utils/apiUtils.js';
import { importData, selectDataWorkspace } from '../../../../common/utils/dataUtils.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class OrgRefreshAfterRefresh extends SfCommand {
static title = 'Restore Connected Apps after org refresh';
static description = `
## Command Behavior
**Restores all previously backed-up Connected Apps (including Consumer Secrets), certificates, custom settings, records and other metadata to a Salesforce org after a sandbox refresh.**
This command is the second step in the sandbox refresh process. It scans the backup folder created before the refresh, allows interactive or flag-driven selection of items to restore, and automates cleanup and redeployment to the refreshed org while preserving credentials and configuration.
Key functionalities:
- **Choose a backup to restore:** Lets you pick the saved sandbox project that contains the artifacts to restore.
- **Select which items to restore:** Finds Connected App XMLs, certificates, custom settings and other artifacts and lets you pick what to restore (or restore all).
- **Safety checks and validation:** Confirms files exist and prompts before making changes to the target org.
- **Prepare org for restore:** Optionally cleans up existing Connected Apps so saved apps can be re-deployed without conflict.
- **Redeploy saved artifacts:** Restores Connected Apps (with saved secrets), certificates, SAML SSO configs, custom settings and other metadata.
- **Handle SAML configs:** Cleans and updates SAML XML files and helps you choose certificates to wire into restored configs.
- **Restore records:** Optionally runs data import from selected SFDMU workspaces to restore record data.
- **Reporting & persistence:** Sends restore reports and can update project config to record what was restored.
This command is part of [sfdx-hardis Sandbox Refresh](https://sfdx-hardis.cloudity.com/salesforce-sandbox-refresh/) and is intended to be run after a sandbox refresh to re-apply saved metadata, credentials and data.
<details markdown="1">
<summary>Technical explanations</summary>
- **Backup Folder Handling:** Reads the immediate subfolders of \`scripts/sandbox-refresh/\` and validates the chosen project contains the expected \`manifest/\` and \`force-app\` layout.
- **Metadata & Deployment APIs:** Uses \`sf project deploy start --manifest\` for package-based deploys, \`sf project deploy start --metadata-dir\` for MDAPI artifacts (certificates), and utility functions for Connected App deployment that preserve consumer secrets.
- **SAML Handling:** Queries active certificates via tooling API, updates SAML XML files, and deploys using \`sf project deploy start -m SamlSsoConfig\`.
- **Records Handling:** Uses interactive selection of SFDMU workspaces and runs data import utilities to restore records.
- **Error Handling & Summary:** Aggregates results, logs success/warnings/errors, and returns a structured result indicating which items were restored and any failures.
</details>
`;
static examples = [
`$ sf hardis:org:refresh:after-refresh`,
`$ sf hardis:org:refresh:after-refresh --name "MyConnectedApp" // Process specific app, no selection prompt`,
`$ sf hardis:org:refresh:after-refresh --name "App1,App2,App3" // Process multiple apps, no selection prompt`,
`$ sf hardis:org:refresh:after-refresh --all // Process all apps, no selection prompt`,
`$ sf hardis:org:refresh:after-refresh --target-org myDevOrg`,
];
static flags = {
"target-org": Flags.requiredOrg(),
name: Flags.string({
char: 'n',
summary: messages.getMessage('nameFilter'),
description: 'Connected App name(s) to process (bypasses selection prompt). For multiple apps, separate with commas (e.g., "App1,App2")'
}),
all: Flags.boolean({
char: 'a',
summary: 'Process all Connected Apps without selection prompt',
description: 'If set, all Connected Apps from the local repository will be processed. Takes precedence over --name if both are specified.'
}),
websocket: Flags.string({
summary: messages.getMessage('websocket'),
description: 'WebSocket host:port for VS Code SFDX Hardis UI integration'
}),
skipauth: Flags.boolean({
default: false,
summary: 'Skip authentication check when a default username is required',
description: 'Skip authentication check when a default username is required'
})
};
// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
static requiresProject = true;
refreshSandboxConfig = {};
saveProjectPath;
result;
orgUsername;
nameFilter;
processAll;
conn;
instanceUrl;
orgId;
async run() {
const { flags } = await this.parse(OrgRefreshAfterRefresh);
this.orgUsername = flags["target-org"].getUsername();
this.conn = flags["target-org"].getConnection();
this.orgId = flags["target-org"].getOrgId();
this.instanceUrl = this.conn.instanceUrl;
/* jscpd:ignore-start */
this.processAll = flags.all || false;
this.nameFilter = this.processAll ? undefined : flags.name; // If --all is set, ignore --name
const config = await getConfig("user");
this.refreshSandboxConfig = config?.refreshSandboxConfig || {};
this.result = {};
/* jscpd:ignore-end */
uxLog("action", this, c.cyan(`This command will restore information after the refresh of org ${this.instanceUrl}
Certificates
Other metadata
SAML SSO Config
Custom Settings
Records (using SFDMU projects)
Connected Apps`));
// Prompt user to select a save project path
const saveProjectPathRoot = path.join(process.cwd(), 'scripts', 'sandbox-refresh');
// Only get immediate subfolders of saveProjectPathRoot (not recursive)
const subFolders = fs.readdirSync(saveProjectPathRoot, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
const saveProjectPath = await prompts({
type: 'select',
name: 'path',
message: 'Select the project path where the sandbox info has been saved',
description: 'This is the path where the metadatas were saved before the org refresh',
choices: subFolders.map(folder => ({
title: folder,
value: path.join(saveProjectPathRoot, folder)
})),
});
this.saveProjectPath = saveProjectPath.path;
// 1. Restore Certificates
await this.restoreCertificates();
// 2. Restore Other Metadata
await this.restoreOtherMetadata();
// 3. Restore SamlSsoConfig
await this.restoreSamlSsoConfig();
// 4. Restore Custom Settings
await this.restoreCustomSettings();
// 5. Restore saved records
await this.restoreRecords();
// 6. Restore Connected Apps
await this.restoreConnectedApps();
return this.result;
}
async restoreCertificates() {
const certsDir = path.join(this.saveProjectPath, 'force-app', 'main', 'default', 'certs');
const manifestDir = path.join(this.saveProjectPath, 'manifest');
const certsPackageXml = path.join(manifestDir, 'package-certificates-to-save.xml');
if (!fs.existsSync(certsDir) || !fs.existsSync(certsPackageXml)) {
uxLog("log", this, c.yellow('No certificates backup found, skipping certificate restore.'));
return;
}
// Copy certs to a temporary folder for deployment
const mdApiCertsRestoreFolder = path.join(this.saveProjectPath, 'mdapi_certs_restore');
await fs.ensureDir(mdApiCertsRestoreFolder);
await fs.emptyDir(mdApiCertsRestoreFolder);
await fs.copy(certsDir, path.join(mdApiCertsRestoreFolder, "certs"), { overwrite: true });
// List certificates in the restore folder
const certsFiles = fs.readdirSync(certsDir);
if (certsFiles.length === 0) {
uxLog("log", this, c.yellow('No certificates found in the backup folder, skipping certificate restore.'));
return;
}
// List .crt files and get their name, then check that each cert must have a .crt and a .crt-meta.xml file
const certsToRestoreNames = certsFiles.filter(file => file.endsWith('.crt')).map(file => path.basename(file, '.crt'));
const validCertsToRestoreNames = certsToRestoreNames.filter(name => {
return fs.existsSync(path.join(certsDir, `${name}.crt-meta.xml`));
});
if (validCertsToRestoreNames.length === 0) {
uxLog("log", this, c.yellow('No valid certificates found in the backup folder (with .crt + .crt-meta.xml), skipping certificate restore.'));
return;
}
// Prompt certificates to restore (all by default)
const promptCerts = await prompts({
type: 'multiselect',
name: 'certs',
message: `Select certificates to restore`,
description: 'Select the certificates you want to restore from the backup. You can select multiple certificates.',
choices: validCertsToRestoreNames.map(name => ({
title: name,
value: name
})),
initial: validCertsToRestoreNames, // Select all by default
});
const selectedCerts = promptCerts.certs;
if (selectedCerts.length === 0) {
uxLog("log", this, c.yellow('No certificates selected for restore, skipping certificate restore.'));
return;
}
// Ask user confirmation before restoring certificates
const prompt = await prompts({
type: 'confirm',
name: 'restore',
message: `Do you confirm you want to restore ${selectedCerts.length} certificate(s) ?`,
description: 'This will deploy all certificate files and definitions saved before the refresh.',
initial: true
});
if (!prompt.restore) {
return;
}
// Create manifest/package.xml within mdApiCertsRestoreFolder
const packageXmlCerts = {
"Certificate": selectedCerts
};
await writePackageXmlFile(path.join(mdApiCertsRestoreFolder, 'package.xml'), packageXmlCerts);
// Deploy using metadata API
uxLog("log", this, c.grey(`Deploying certificates in org ${this.instanceUrl} using Metadata API (Source Api does not support it)...`));
await execSfdxJson(`sf project deploy start --metadata-dir ${mdApiCertsRestoreFolder} --target-org ${this.orgUsername}`, this, { output: true, fail: true, cwd: this.saveProjectPath });
uxLog("success", this, c.green(`Certificates restored successfully in org ${this.instanceUrl}`));
}
async restoreOtherMetadata() {
const manifestDir = path.join(this.saveProjectPath, 'manifest');
const restorePackageXml = path.join(manifestDir, 'package-metadata-to-restore.xml');
// Check if the restore package.xml exists
if (!fs.existsSync(restorePackageXml)) {
uxLog("log", this, c.yellow('No package-metadata-to-restore.xml found, skipping metadata restore.'));
return;
}
// Warn user about the restore package.xml that needs to be manually checked
WebSocketClient.sendReportFileMessage(restorePackageXml, "Restore Metadatas package.xml", "report");
uxLog("action", this, c.cyan(`Now handling the restore of other metadata from ${restorePackageXml}...`));
const metadataRestore = await parsePackageXmlFile(restorePackageXml);
const metadataSummary = Object.keys(metadataRestore).map(key => {
return `${key}(${Array.isArray(metadataRestore[key]) ? metadataRestore[key].length : 0})`;
}).join(', ');
uxLog("warning", this, c.yellow(`Look at the package-metadata-to-restore.xml file in ${c.bold(this.saveProjectPath)} to see what will be restored.`));
uxLog("warning", this, c.yellow(`Confirm it's content, or remove/comment part of it if you don't want some metadata to be restored\n${metadataSummary}`));
const prompt = await prompts({
type: 'confirm',
name: 'restore',
message: `Please double check package-metadata-to-restore.xml. Do you confirm you want to restore all these metadatas ?\n${metadataSummary}`,
description: `WARNING: Check and validate/update file ${restorePackageXml} BEFORE it is deployed !`,
initial: true
});
if (!prompt.restore) {
uxLog("warning", this, c.yellow('Metadata restore cancelled by user.'));
this.result = Object.assign(this.result, { success: false, message: 'Metadata restore cancelled by user.' });
return;
}
// Deploy the metadata using the package.xml
uxLog("action", this, c.cyan('Deploying other metadatas to org...'));
const deployCmd = `sf project deploy start --manifest ${restorePackageXml} --target-org ${this.orgUsername} --json`;
const deployResult = await execSfdxJson(deployCmd, this, { output: true, fail: true, cwd: this.saveProjectPath });
if (deployResult.status === 0) {
uxLog("success", this, c.green(`Other metadata restored successfully in org ${this.instanceUrl}`));
}
else {
uxLog("error", this, c.red(`Failed to restore other metadata in org ${this.instanceUrl}: ${deployResult.error}`));
this.result = Object.assign(this.result, { success: false, message: `Failed to restore other metadata: ${deployResult.error}` });
throw new Error(`Failed to restore other metadata:\n${JSON.stringify(deployResult, null, 2)}`);
}
}
async restoreSamlSsoConfig() {
// 0. List all samlssoconfigs in the project, prompt user to select which to restore
const samlDir = path.join(this.saveProjectPath, 'force-app', 'main', 'default', 'samlssoconfigs');
if (!fs.existsSync(samlDir)) {
uxLog("action", this, c.cyan('No SAML SSO Configs found, skipping SAML SSO config restore.'));
return;
}
const allSamlFiles = fs.readdirSync(samlDir).filter(f => f.endsWith('.samlssoconfig-meta.xml'));
if (allSamlFiles.length === 0) {
uxLog("action", this, c.yellow('No SAML SSO Config XML files found., skipping SAML SSO config restore.'));
return;
}
// Prompt user to select which SAML SSO configs to restore
const promptSaml = await prompts({
type: 'multiselect',
name: 'samlFiles',
message: 'Select SAML SSO Configs to restore',
description: 'Select the SAML SSO Configs you want to restore from the backup. You can select multiple configs.',
choices: allSamlFiles.map(f => ({ title: f.replace('.samlssoconfig-meta.xml', ''), value: f })),
initial: allSamlFiles // select all by default
});
const selectedSamlFiles = promptSaml.samlFiles;
if (!selectedSamlFiles || selectedSamlFiles.length === 0) {
uxLog("log", this, c.yellow('No SAML SSO Configs selected for restore, skipping.'));
return;
}
// 1. Clean up XML and prompt for cert
// Query active certificates
const soql = "SELECT Id, MasterLabel FROM Certificate WHERE ExpirationDate > TODAY LIMIT 200";
let certs = [];
try {
const res = await soqlQueryTooling(soql, this.conn);
certs = res.records;
}
catch (e) {
uxLog("error", this, c.red(`Failed to query active certificates: ${e}`));
return;
}
if (!certs.length) {
uxLog("error", this, c.yellow('No active certificates found in org. You\'ll need to update manually field requestSigningCertId with the id of a valid certificate.'));
return;
}
const updated = [];
const errors = [];
for (const samlFile of selectedSamlFiles) {
const samlName = samlFile.replace('.samlssoconfig-meta.xml', '');
// Prompt user to select a certificate
const certPrompt = await prompts({
type: 'select',
name: 'certId',
message: `Select the certificate to use for SAML SSO config ${samlName}`,
description: `This will update <requestSigningCertId> in ${samlFile}.`,
choices: certs.map(cert => ({
title: cert.MasterLabel,
value: cert.Id.substring(0, 15)
})),
});
const selectedCertId = certPrompt.certId;
if (!selectedCertId) {
uxLog("warning", this, c.yellow('No certificate selected. Skipping SAML SSO config update.'));
errors.push(`No certificate selected for ${samlName}`);
continue;
}
const filePath = path.join(samlDir, samlFile);
let xml = await fs.readFile(filePath, 'utf8');
// Remove <oauthTokenEndpoint>...</oauthTokenEndpoint>
xml = xml.replace(/<oauthTokenEndpoint>.*?<\/oauthTokenEndpoint>\s*/gs, '');
// Remove <salesforceLoginUrl>...</salesforceLoginUrl>
xml = xml.replace(/<salesforceLoginUrl>.*?<\/salesforceLoginUrl>\s*/gs, '');
// Replace <requestSigningCertId>...</requestSigningCertId>
if (/<requestSigningCertId>.*?<\/requestSigningCertId>/s.test(xml)) {
xml = xml.replace(/<requestSigningCertId>.*?<\/requestSigningCertId>/s, `<requestSigningCertId>${selectedCertId}</requestSigningCertId>`);
}
await fs.writeFile(filePath, xml, 'utf8');
uxLog("log", this, c.grey(`Updated SAML SSO config ${samlFile} with certificate ${selectedCertId} and removed readonly tags oauthTokenEndpoint & salesforceLoginUrl`));
// 2. Prompt user to confirm deployment
const promptDeploy = await prompts({
type: 'confirm',
name: 'deploy',
message: `Do you confirm you want to deploy ${samlFile} SAML SSO Config to the org?`,
description: 'This will deploy the selected SAML SSO Configs to the org using SFDX',
initial: true
});
if (!promptDeploy.deploy) {
uxLog("warning", this, c.yellow(`SAML SSO Config ${samlFile} deployment cancelled by user.`));
errors.push(`Deployment cancelled for ${samlFile}`);
continue;
}
const deployCommand = `sf project deploy start -m SamlSsoConfig:${samlName} --target-org ${this.orgUsername}`;
try {
uxLog("action", this, c.cyan(`Deploying SAML SSO Config ${samlName} to org ${this.instanceUrl}...`));
const deployResult = await execSfdxJson(deployCommand, this, { output: true, fail: true, cwd: this.saveProjectPath });
if (deployResult.status === 0) {
uxLog("success", this, c.green(`SAML SSO Config ${samlName} deployed successfully in org ${this.instanceUrl}`));
updated.push(samlName);
}
else {
uxLog("error", this, c.red(`Failed to deploy SAML SSO Config ${samlName}: ${deployResult.error}`));
errors.push(`Failed to deploy ${samlName}: ${deployResult.error}`);
}
}
catch (e) {
uxLog("error", this, c.red(`Error deploying SAML SSO Config ${samlName}: ${e.message}`));
errors.push(`Error deploying ${samlName}: ${e.message}`);
}
}
// 3. Summary of results
uxLog("action", this, c.cyan(`SAML SSO Config processing completed.`));
if (updated.length > 0) {
uxLog("success", this, c.green(`Successfully updated and deployed SAML SSO Configs: ${updated.join(', ')}`));
}
if (errors.length > 0) {
uxLog("error", this, c.red(`Errors occurred during SAML SSO Config processing:\n${errors.join('\n')}`));
this.result = Object.assign(this.result, { success: false, message: `SAML SSO Config processing errors:\n${errors.join('\n')}` });
}
}
async restoreCustomSettings() {
// Check there are custom settings to restore
const csDir = path.join(this.saveProjectPath, 'savedCustomSettings');
if (!fs.existsSync(csDir)) {
uxLog("log", this, c.yellow('No savedCustomSettings folder found, skipping custom settings restore.'));
return;
}
const csFolders = fs.readdirSync(csDir).filter(f => fs.statSync(path.join(csDir, f)).isDirectory());
if (csFolders.length === 0) {
uxLog("log", this, c.yellow('No custom settings data found, skipping custom settings restore.'));
return;
}
// List custom settings to restore so users can select them. Keep only folders that have a .json file
const csToRestore = csFolders.filter(folder => {
const jsonFile = path.join(csDir, folder, `${folder}.json`);
return fs.existsSync(jsonFile);
});
if (csToRestore.length === 0) {
uxLog("log", this, c.yellow('No custom settings data found to restore, skipping custom settings restore.'));
return;
}
// Prompt custom settings to restore: All by default
const promptRestore = await prompts({
type: 'multiselect',
name: 'settings',
message: `Select custom settings to restore`,
description: 'Select the custom settings you want to restore from the backup. You can select multiple settings.',
choices: csToRestore.map(folder => ({
title: folder,
value: folder
})),
initial: csToRestore // Select all by default
});
const selectedSettings = promptRestore.settings;
if (selectedSettings.length === 0) {
uxLog("log", this, c.yellow('No custom settings selected for restore, skipping custom settings restore.'));
return;
}
// Ask last confirmation to user
const prompt = await prompts({
type: 'confirm',
name: 'restore',
message: `Do you confirm you want to restore ${selectedSettings.length} Custom Settings values from backup?`,
description: 'This will import all custom settings data saved before the refresh.',
initial: true
});
if (!prompt.restore) {
uxLog("warning", this, c.yellow('Custom settings restore cancelled by user.'));
return;
}
uxLog("action", this, c.cyan(`Restoring ${selectedSettings.length} Custom Settings...`));
const successSettings = [];
const failedSettings = [];
for (const folder of selectedSettings) {
const jsonFile = path.join(csDir, folder, `${folder}.json`);
if (!fs.existsSync(jsonFile)) {
uxLog("warning", this, c.yellow(`No data file for custom setting ${folder}`));
failedSettings.push(folder);
continue;
}
// Remove standard fields from the JSON file and create a new file without them, and replace Org Id with the current org one
const jsonFileForImport = path.join(csDir, folder, `${folder}-without-standard-fields.json`);
const jsonData = await fs.readJson(jsonFile);
const standardFields = ['LastModifiedDate', 'IsDeleted', 'CreatedById', 'CreatedDate', 'LastModifiedById', 'SystemModstamp'];
let deleteExistingCsBefore = false;
jsonData.records = (jsonData?.records || []).map((record) => {
const newRecord = {};
for (const key in record) {
// Remove standard fields
if (!standardFields.includes(key)) {
newRecord[key] = record[key];
}
// Replace Org Id with the current org one
if (key === 'SetupOwnerId') {
newRecord[key] = this.orgId; // Replace with current org Id
deleteExistingCsBefore = true; // Use upsert if SetupOwnerId is present
}
}
return newRecord;
});
// Write the new JSON file without standard fields
await fs.writeJson(jsonFileForImport, jsonData, { spaces: 2 });
// Delete existing custom settings before import if needed
if (deleteExistingCsBefore) {
uxLog("log", this, c.grey(`Deleting existing custom settings for ${folder} in org ${this.orgUsername} before import...`));
// Query existing custom settings to delete
const query = `SELECT Id FROM ${folder} WHERE SetupOwnerId = '${this.orgId}'`;
const queryRes = await soqlQuery(query, this.conn);
if (queryRes.records.length > 0) {
const idsToDelete = (queryRes?.records.map(record => record.Id) || []).filter((id) => typeof id === 'string');
uxLog("log", this, c.grey(`Found ${idsToDelete.length} existing custom settings to delete for ${folder} in org ${this.orgUsername}`));
const deleteResults = await this.conn.sobject(folder).destroy(idsToDelete, { allOrNone: true });
const deletedSuccessFullyIds = deleteResults.filter(result => result.success).map(result => "- " + result.id).join('\n');
uxLog("log", this, c.grey(`Deleted ${deletedSuccessFullyIds.length} existing custom settings for ${folder} in org ${this.orgUsername}\n${deletedSuccessFullyIds}`));
const deletedErrorIds = deleteResults.filter(result => !result.success).map(result => "- " + result.id).join('\n');
if (deletedErrorIds.length > 0) {
uxLog("warning", this, c.yellow(`Failed to delete existing custom settings for ${folder} in org ${this.orgUsername}\n${deletedErrorIds}`));
continue; // Skip to next setting if deletion failed
}
}
else {
uxLog("log", this, c.grey(`No existing custom settings found for ${folder} in org ${this.orgUsername}.`));
}
}
// Import the custom setting using sf data tree import
const importCmd = `sf data tree import --files ${jsonFileForImport} --target-org ${this.orgUsername} --json`;
try {
const importRes = await execSfdxJson(importCmd, this, { output: true, fail: true, cwd: this.saveProjectPath });
if (importRes.status === 0) {
uxLog("success", this, c.green(`Custom setting ${folder} restored.`));
successSettings.push(folder);
}
else {
uxLog("error", this, c.red(`Failed to restore custom setting ${folder}:\n${JSON.stringify(importRes, null, 2)}`));
failedSettings.push(folder);
}
}
catch (e) {
uxLog("error", this, c.red(`Custom setting ${folder} restore failed:\n${JSON.stringify(e)}`));
failedSettings.push(folder);
continue;
}
}
uxLog("action", this, c.cyan(`Custom settings restore complete (${successSettings.length} successful, ${failedSettings.length} failed)`));
if (successSettings.length > 0) {
const successSettingsNames = successSettings.map(name => "- " + name).join('\n');
uxLog("success", this, c.green(`Successfully restored ${successSettings.length} Custom Setting(s):\n ${successSettingsNames}`));
}
if (failedSettings.length > 0) {
const failedSettingsNames = failedSettings.map(name => "- " + name).join('\n');
uxLog("error", this, c.red(`Failed to restore ${failedSettings.length} Custom Setting(s): ${failedSettingsNames}`));
}
}
async restoreRecords() {
const sfdmuWorkspaces = await selectDataWorkspace({
selectDataLabel: 'Select data workspaces to use to restore records after sandbox refresh',
multiple: true,
initial: "all",
cwd: this.saveProjectPath
});
if (!(Array.isArray(sfdmuWorkspaces) && sfdmuWorkspaces.length > 0)) {
uxLog("warning", this, c.yellow('No data workspace found, skipping record restore'));
return;
}
const confirmRestore = await prompts({
type: 'confirm',
name: 'confirm',
message: `Before launching the data loading, please make sure your user ${this.orgUsername} has the appropriate ByPasses / Activation Settings / Custom Permissions / Whatever you need to do before starting the data load.`,
initial: true,
description: 'Once confirmed, the data loading will start'
});
if (!confirmRestore.confirm) {
uxLog("warning", this, c.yellow('Record restore cancelled by user.'));
return;
}
for (const sfdmuPath of sfdmuWorkspaces) {
await importData(sfdmuPath || '', this, {
targetUsername: this.orgUsername,
cwd: this.saveProjectPath,
});
}
}
async restoreConnectedApps() {
let restoreConnectedApps = false;
const promptRestoreConnectedApps = await prompts({
type: 'confirm',
name: 'confirmRestore',
message: `Do you want to restore Connected Apps from the backup in ${c.bold(this.saveProjectPath)}?`,
initial: true,
description: 'This will restore all Connected Apps (including Consumer Secrets) from the backup created before the org refresh.'
});
if (promptRestoreConnectedApps.confirmRestore) {
restoreConnectedApps = true;
}
if (restoreConnectedApps) {
try {
// Step 1: Find Connected Apps in the project
const connectedApps = await this.findConnectedAppsInProject(this.nameFilter, this.processAll);
if (connectedApps.length === 0) {
uxLog("warning", this, c.yellow('No Connected Apps found in the project'));
this.result = Object.assign(this.result, { success: false, message: 'No Connected Apps found in the project' });
return;
}
/* jscpd:ignore-start */
// Step 2: Select which Connected Apps to process
const selectedApps = await this.selectConnectedApps(connectedApps, this.processAll, this.nameFilter);
if (selectedApps.length === 0) {
uxLog("warning", this, c.yellow('No Connected Apps selected'));
this.result = Object.assign(this.result, { success: false, message: 'No Connected Apps selected' });
return;
}
/* jscpd:ignore-end */
// Step 3: Delete existing Connected Apps from the org for clean deployment
await this.deleteExistingConnectedApps(this.orgUsername, selectedApps);
// Step 4: Deploy the Connected Apps to the org
await this.deployConnectedApps(this.orgUsername, selectedApps);
// Return the result
uxLog("action", this, c.cyan(`Summary`));
const appNames = selectedApps.map(app => `- ${app.fullName}`).join('\n');
uxLog("success", this, c.green(`Successfully restored ${selectedApps.length} Connected App(s) to ${this.conn.instanceUrl}\n${appNames}`));
const restoreResult = createConnectedAppSuccessResponse(`Successfully restored ${selectedApps.length} Connected App(s) to the org`, selectedApps.map(app => app.fullName));
this.result = Object.assign(this.result, restoreResult);
}
catch (error) {
const restoreResult = handleConnectedAppError(error, this);
this.result = Object.assign(this.result, restoreResult);
}
}
}
async findConnectedAppsInProject(nameFilter, processAll) {
if (processAll) {
uxLog("action", this, c.cyan('Processing all Connected Apps from local repository (selection prompt bypassed)'));
}
else if (nameFilter) {
uxLog("action", this, c.cyan(`Processing specified Connected App(s): ${nameFilter} (selection prompt bypassed)`));
}
else {
uxLog("action", this, c.cyan('Scanning project for Connected Apps...'));
}
try {
// Get all Connected App files in the project once
const connectedAppFilesRaw = await glob('**/*.connectedApp-meta.xml', {
ignore: GLOB_IGNORE_PATTERNS,
cwd: this.saveProjectPath
});
const connectedAppFiles = connectedAppFilesRaw.map(file => path.join(this.saveProjectPath, file));
if (connectedAppFiles.length === 0) {
uxLog("warning", this, c.yellow('No Connected App files found in the project'));
return [];
}
// Create ConnectedApp objects from the files
const connectedApps = [];
const allFoundApps = [];
// First, collect all available Connected Apps in the project in one pass
for (const filePath of connectedAppFiles) {
try {
const xmlData = await parseXmlFile(filePath);
if (xmlData && xmlData.ConnectedApp) {
const fullName = xmlData.ConnectedApp.fullName?.[0] || path.basename(filePath, '.connectedApp-meta.xml');
allFoundApps.push({ fullName, filePath });
}
}
catch (error) {
uxLog("warning", this, c.yellow(`Error parsing ${filePath}: ${error}`));
// Continue with the next file
}
}
if (allFoundApps.length === 0) {
uxLog("warning", this, c.yellow('No valid Connected Apps found in the project'));
return [];
}
// If name filter is specified, validate that all requested apps exist
if (nameFilter) {
const appNames = nameFilter.split(',').map(name => name.trim());
const availableAppNames = allFoundApps.map(app => app.fullName);
// Case-insensitive matching for app names
validateConnectedApps(appNames, availableAppNames, this, 'project');
// Filter apps based on name filter
for (const app of allFoundApps) {
const matchesFilter = appNames.some(name => name.toLowerCase() === app.fullName.toLowerCase());
if (matchesFilter) {
connectedApps.push({
fullName: app.fullName,
filePath: app.filePath,
type: 'ConnectedApp'
});
}
}
}
else {
// No filter - add all apps
for (const app of allFoundApps) {
connectedApps.push({
fullName: app.fullName,
filePath: app.filePath,
type: 'ConnectedApp'
});
}
}
// Display results
if (connectedApps.length > 0) {
const appNamesAndPaths = connectedApps.map(app => `- ${app.fullName} (${app.filePath})`).join('\n');
uxLog("log", this, c.cyan(`Found ${connectedApps.length} Connected App(s) in project\n${appNamesAndPaths}`));
}
else if (nameFilter) {
uxLog("warning", this, c.yellow(`No Connected Apps matching the filter "${nameFilter}" found in the project`));
}
return connectedApps;
}
catch (error) {
uxLog("error", this, c.red(`Error searching for Connected App files: ${error}`));
return [];
}
}
/* jscpd:ignore-start */
async selectConnectedApps(connectedApps, processAll, nameFilter) {
const initialSelection = [];
if (this.refreshSandboxConfig.connectedApps && this.refreshSandboxConfig.connectedApps.length > 0) {
initialSelection.push(...this.refreshSandboxConfig.connectedApps);
}
return selectConnectedAppsForProcessing(connectedApps, initialSelection, processAll, nameFilter, 'Select Connected Apps to restore', this);
}
/* jscpd:ignore-end */
async deleteExistingConnectedApps(orgUsername, connectedApps) {
if (connectedApps.length === 0)
return;
const promptResponse = await prompts({
type: 'confirm',
name: 'confirmDelete',
message: `Now we need to delete ${connectedApps.length} Connected App(s) from the refreshed sandbox, to be able to reupload them with saved credentials. Proceed ?`,
description: 'This step is necessary to ensure that the Connected Apps can be re-deployed with their saved credentials.',
initial: true
});
if (!promptResponse.confirmDelete) {
throw new Error('Connected Apps deletion cancelled by user.');
}
// Convert ProjectConnectedApp to the format required by deleteConnectedApps
const appsToDelete = toConnectedAppFormat(connectedApps);
// Delete the apps without prompting
await deleteConnectedApps(orgUsername, appsToDelete, this, this.saveProjectPath);
uxLog("success", this, c.green('Connected Apps were successfully deleted from the org.'));
}
async deployConnectedApps(orgUsername, connectedApps) {
if (connectedApps.length === 0)
return;
const promptResponse = await prompts({
type: 'confirm',
name: 'confirmDeploy',
message: `Now we will deploy ${connectedApps.length} Connected App(s) to the org to restore the original credentials. Proceed ?`,
description: 'This step will deploy the Connected Apps with their saved credentials.',
initial: true
});
if (!promptResponse.confirmDeploy) {
throw new Error('Connected Apps deployment cancelled by user.');
}
// Convert ProjectConnectedApp to the format needed by deployConnectedApps
const connectedAppsList = toConnectedAppFormat(connectedApps);
await deployConnectedApps(orgUsername, connectedAppsList, this, this.saveProjectPath);
uxLog("success", this, c.green(`Deployment of ${connectedApps.length} Connected App(s) completed successfully`));
}
}
//# sourceMappingURL=after-refresh.js.map