diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 9ef9e592..56881e41 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -22,7 +22,14 @@ import { DeployStages } from '../../../utils/deployStages.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { AsyncDeployResultJson, DeployResultJson, TestLevel } from '../../../utils/types.js'; -import { executeDeploy, resolveApi, validateTests, determineExitCode, buildDeployUrl } from '../../../utils/deploy.js'; +import { + executeDeploy, + resolveApi, + validateTests, + determineExitCode, + buildDeployUrl, + buildPreDestructiveFileResponses, +} from '../../../utils/deploy.js'; import { DeployCache } from '../../../utils/deployCache.js'; import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes.js'; import { ConfigVars } from '../../../configMeta.js'; @@ -250,7 +257,7 @@ export default class DeployMetadata extends SfCommand { return Promise.resolve(); }); - const { deploy } = await executeDeploy( + const { deploy, componentSet } = await executeDeploy( { ...flags, 'target-org': username, @@ -268,6 +275,9 @@ export default class DeployMetadata extends SfCommand { throw new SfError('The deploy id is not available.'); } + // Capture pre-destructive file responses BEFORE deploy executes + const preDestructiveFileResponses = await buildPreDestructiveFileResponses(componentSet, project); + this.stages = new DeployStages({ title, jsonEnabled: this.jsonEnabled(), @@ -301,7 +311,8 @@ export default class DeployMetadata extends SfCommand { const result = await deploy.pollStatus({ timeout: flags.wait }); process.exitCode = determineExitCode(result); this.stages.stop(); - const formatter = new DeployResultFormatter(result, flags, undefined, true); + + const formatter = new DeployResultFormatter(result, flags, preDestructiveFileResponses, true); if (!this.jsonEnabled()) { formatter.display(); diff --git a/src/utils/deploy.ts b/src/utils/deploy.ts index 909f07f4..423857a4 100644 --- a/src/utils/deploy.ts +++ b/src/utils/deploy.ts @@ -14,13 +14,17 @@ * limitations under the License. */ +import { relative } from 'node:path'; import { ConfigAggregator, Messages, Org, SfError, SfProject } from '@salesforce/core'; import { Duration } from '@salesforce/kit'; import { Nullable } from '@salesforce/ts-types'; import { ComponentSet, ComponentSetBuilder, + ComponentStatus, DeployResult, + DestructiveChangesType, + FileResponseSuccess, MetadataApiDeploy, MetadataApiDeployOptions, RegistryAccess, @@ -254,3 +258,84 @@ export function buildDeployUrl(org: Org, deployId: string): string { const orgInstanceUrl = String(org.getField(Org.Fields.INSTANCE_URL)); return `${orgInstanceUrl}/lightning/setup/DeployStatus/page?address=%2Fchangemgmt%2FmonitorDeploymentsDetails.apexp%3FasyncId%3D${deployId}%26retURL%3D%252Fchangemgmt%252FmonitorDeployment.apexp`; } + +/** + * Creates synthetic FileResponse objects for components in pre-destructive changes. + * This ensures all file paths (e.g., .cls and .xml for ApexClass, or all LWC bundle files) + * are shown in the deployment results table. This is needed because pre-destructive files + * are deleted BEFORE the deploy, so getFileResponses() cannot access them. + * + * @param componentSet - The ComponentSet from the deployment (before deploy executes) + * @param project - The SfProject to resolve file paths from + * @returns Array of synthetic FileResponseSuccess objects representing pre-deleted files + */ +export async function buildPreDestructiveFileResponses( + componentSet?: ComponentSet, + project?: SfProject +): Promise { + if (!componentSet || !project) { + return []; + } + + const fileResponses: FileResponseSuccess[] = []; + + // Get all source components and filter for pre-destructive ones + const allComponents = componentSet.getSourceComponents().toArray(); + + const preDestructiveComponents = allComponents.filter( + (component) => component.getDestructiveChangesType() === DestructiveChangesType.PRE + ); + + if (preDestructiveComponents.length === 0) { + return []; + } + + // Build metadata entries for ComponentSetBuilder + const metadataEntries = preDestructiveComponents.map((comp) => `${comp.type.name}:${comp.fullName}`); + + // Resolve the components from the project to get their file paths + try { + const resolvedComponentSet = await ComponentSetBuilder.build({ + metadata: { + metadataEntries, + directoryPaths: await getPackageDirs(), + }, + projectDir: project.getPath(), + }); + const resolvedComponents = resolvedComponentSet.getSourceComponents().toArray(); + + preDestructiveComponents.length = 0; + preDestructiveComponents.push(...resolvedComponents); + } catch (error) { + // If this's not resolve, try to resolve with registry only + } + + for (const component of preDestructiveComponents) { + // Get all file paths for this component (metadata XML + content files) + const filePaths: string[] = []; + const projectPath = project.getPath(); + + if (component.xml) { + const relativePath = relative(projectPath, component.xml); + filePaths.push(relativePath); + } + + // Add all content files (for bundles, this includes all files in the directory) + const contentPaths = component.walkContent(); + for (const contentPath of contentPaths) { + const relativePath = relative(projectPath, contentPath); + filePaths.push(relativePath); + } + + for (const filePath of filePaths) { + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Deleted, + filePath, + }); + } + } + + return fileResponses; +}