From 8c5b9fdc572bb85a03381728b2cd77c6b8e689cf Mon Sep 17 00:00:00 2001 From: mshanemc Date: Wed, 12 Nov 2025 11:46:41 -0600 Subject: [PATCH 01/10] fix: deployResponses for non-set cwd --- src/client/deployMessages.ts | 39 ++++++++++++++++++++------------- src/client/metadataApiDeploy.ts | 7 ++++-- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index d7675b35f..4e25e67ea 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -79,22 +79,31 @@ const shouldWalkContent = (component: SourceComponent): boolean => (t) => t.unaddressableWithoutParent === true || t.isAddressable === false )); -export const createResponses = (component: SourceComponent, responseMessages: DeployMessage[]): FileResponse[] => - responseMessages.flatMap((message): FileResponse[] => { - const state = getState(message); - const base = { fullName: component.fullName, type: component.type.name } as const; +export const createResponses = + (projectPath?: string) => + (component: SourceComponent, responseMessages: DeployMessage[]): FileResponse[] => + responseMessages.flatMap((message): FileResponse[] => { + const state = getState(message); + const base = { fullName: component.fullName, type: component.type.name } as const; - if (state === ComponentStatus.Failed) { - return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure]; - } else { - return [ - ...(shouldWalkContent(component) - ? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath })) - : []), - ...(component.xml ? [{ ...base, state, filePath: component.xml } satisfies FileResponseSuccess] : []), - ]; - } - }); + if (state === ComponentStatus.Failed) { + return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure]; + } else { + return [ + ...(shouldWalkContent(component) + ? component.walkContent().map( + (filePath): FileResponseSuccess => ({ + ...base, + state, + // deployResults will produce filePaths relative to cwd, which might not be set in all environments + filePath: process.cwd() === projectPath ? filePath : join(projectPath ?? '', filePath), + }) + ) + : []), + ...(component.xml ? [{ ...base, state, filePath: component.xml } satisfies FileResponseSuccess] : []), + ]; + } + }); /** * Groups messages from the deploy result by component fullName and type */ diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 2d3ca0b6f..0d415ddd7 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -509,11 +509,14 @@ const buildFileResponsesFromComponentSet = const fileResponses = (cs.getSourceComponents().toArray() ?? []) .flatMap((deployedComponent) => - createResponses(deployedComponent, responseMessages.get(toKey(deployedComponent)) ?? []).concat( + createResponses(cs.projectDirectory)( + deployedComponent, + responseMessages.get(toKey(deployedComponent)) ?? [] + ).concat( deployedComponent.type.children ? deployedComponent.getChildren().flatMap((child) => { const childMessages = responseMessages.get(toKey(child)); - return childMessages ? createResponses(child, childMessages) : []; + return childMessages ? createResponses(cs.projectDirectory)(child, childMessages) : []; }) : [] ) From 810a95a076daa0f981f205b7a207e96f0b5dc99d Mon Sep 17 00:00:00 2001 From: mshanemc Date: Wed, 12 Nov 2025 13:55:12 -0600 Subject: [PATCH 02/10] feat: deploy has FileResponses relative to projectDir if one exists on the CS and doesn't match cwd --- src/client/deployMessages.ts | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index 4e25e67ea..f2c8f45e3 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -89,21 +89,26 @@ export const createResponses = if (state === ComponentStatus.Failed) { return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure]; } else { - return [ - ...(shouldWalkContent(component) - ? component.walkContent().map( - (filePath): FileResponseSuccess => ({ - ...base, - state, - // deployResults will produce filePaths relative to cwd, which might not be set in all environments - filePath: process.cwd() === projectPath ? filePath : join(projectPath ?? '', filePath), - }) - ) - : []), - ...(component.xml ? [{ ...base, state, filePath: component.xml } satisfies FileResponseSuccess] : []), - ]; + return ( + [ + ...(shouldWalkContent(component) + ? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath })) + : []), + ...(component.xml ? [{ ...base, state, filePath: component.xml } satisfies FileResponseSuccess] : []), + ] + // deployResults will produce filePaths relative to cwd, which might not be set in all environments + // if our CS had a projectDir set, we'll make the results relative to that path + .map((response) => ({ + ...response, + filePath: + projectPath && process.cwd() === projectPath + ? response.filePath + : join(projectPath ?? '', response.filePath), + })) + ); } }); + /** * Groups messages from the deploy result by component fullName and type */ From 9df3d5aa960de2e8430c5909a11f51180f5fe38c Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 20 Nov 2025 07:10:10 -0600 Subject: [PATCH 03/10] refactor: isWebAppBundle as shared type guard --- src/client/metadataApiRetrieve.ts | 26 +++++++++++++------------- src/client/utils.ts | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 src/client/utils.ts diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index 51a2e6d28..fce3e93e0 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -38,6 +38,7 @@ import { import { extract } from './retrieveExtract'; import { getPackageOptions } from './retrieveExtract'; import { MetadataApiRetrieveOptions } from './types'; +import { isWebAppBundle } from './utils'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -101,7 +102,7 @@ export class RetrieveResult implements MetadataTransferResult { // construct successes for (const retrievedComponent of this.components.getSourceComponents()) { - const { fullName, type, xml, content } = retrievedComponent; + const { fullName, type, xml } = retrievedComponent; const baseResponse = { fullName, type: type.name, @@ -109,20 +110,19 @@ export class RetrieveResult implements MetadataTransferResult { } as const; // Special handling for web_app bundles - they need to walk content and report individual files - const isWebAppBundle = type.name === 'DigitalExperienceBundle' && fullName.startsWith('web_app/') && content; - - if (isWebAppBundle) { - const walkedPaths = retrievedComponent.walkContent(); + if (isWebAppBundle(retrievedComponent)) { // Add the bundle directory itself - this.fileResponses.push({ ...baseResponse, filePath: content } satisfies FileResponseSuccess); - // Add each file with its specific path - for (const filePath of walkedPaths) { - this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); - } + this.fileResponses.push( + ...[retrievedComponent.content, ...retrievedComponent.walkContent()].map( + (filePath) => ({ ...baseResponse, filePath } satisfies FileResponseSuccess) + ) + ); } else if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) { - for (const filePath of retrievedComponent.walkContent()) { - this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); - } + this.fileResponses.push( + ...retrievedComponent + .walkContent() + .map((filePath) => ({ ...baseResponse, filePath } satisfies FileResponseSuccess)) + ); } if (xml) { diff --git a/src/client/utils.ts b/src/client/utils.ts new file mode 100644 index 000000000..f6d416717 --- /dev/null +++ b/src/client/utils.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2025, 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. + */ +import { SourceComponent } from '../resolve/sourceComponent'; + +export const isWebAppBundle = (component: SourceComponent): component is SourceComponent & { content: string } => + component.type.name === 'DigitalExperienceBundle' && + component.fullName.startsWith('web_app/') && + typeof component.content === 'string'; From 616fe752cb01e1369579adacbe6f8f3018c6c6ee Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 20 Nov 2025 07:39:30 -0600 Subject: [PATCH 04/10] chore: empty From 430992f834883ab17a58613910a0e21b894ffeeb Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 20 Nov 2025 08:35:20 -0600 Subject: [PATCH 05/10] refactor: webappbundle also relative to project path if provided --- src/client/deployMessages.ts | 77 ++++++++++++++++------------------ src/client/retrieveExtract.ts | 6 +-- src/client/utils.ts | 4 +- src/resolve/sourceComponent.ts | 2 + 4 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index 86aa79812..f30593f7e 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -17,7 +17,8 @@ import { basename, dirname, extname, join, posix, sep } from 'node:path'; import { SfError } from '@salesforce/core/sfError'; import { ensureArray } from '@salesforce/kit'; -import { ComponentLike, SourceComponent } from '../resolve'; +import { SourceComponentWithContent, SourceComponent } from '../resolve/sourceComponent'; +import { ComponentLike } from '../resolve'; import { registry } from '../registry/registry'; import { BooleanString, @@ -91,47 +92,45 @@ export const createResponses = return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure]; } - if (isWebAppBundle(component)) { - const walkedPaths = component.walkContent(); - const bundleResponse: FileResponseSuccess = { - fullName: component.fullName, - type: component.type.name, - state, - filePath: component.content, - }; - const fileResponses: FileResponseSuccess[] = walkedPaths.map((filePath) => { - // Normalize paths to ensure relative() works correctly on Windows - const normalizedContent = component.content.split(sep).join(posix.sep); - const normalizedFilePath = filePath.split(sep).join(posix.sep); - const relPath = posix.relative(normalizedContent, normalizedFilePath); - return { - fullName: posix.join(component.fullName, relPath), - type: 'DigitalExperience', - state, - filePath, - }; - }); - return [bundleResponse, ...fileResponses]; - } - return ( - [ - ...(shouldWalkContent(component) - ? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath })) - : []), - ...(component.xml ? [{ ...base, state, filePath: component.xml } satisfies FileResponseSuccess] : []), - ] + isWebAppBundle(component) + ? [ + { + ...base, + state, + filePath: component.content, + }, + ...component.walkContent().map((filePath) => ({ + fullName: getWebAppBundleContentFullName(component)(filePath), + type: 'DigitalExperience', + state, + filePath, + })), + ] + : [ + ...(shouldWalkContent(component) + ? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath })) + : []), + ...(component.xml ? [{ ...base, state, filePath: component.xml }] : []), + ] + ).map((response) => ({ + ...response, + filePath: // deployResults will produce filePaths relative to cwd, which might not be set in all environments // if our CS had a projectDir set, we'll make the results relative to that path - .map((response) => ({ - ...response, - filePath: - projectPath && process.cwd() === projectPath - ? response.filePath - : join(projectPath ?? '', response.filePath), - })) - ); + projectPath && process.cwd() === projectPath ? response.filePath : join(projectPath ?? '', response.filePath), + })) satisfies FileResponseSuccess[]; }); + +const getWebAppBundleContentFullName = + (component: SourceComponentWithContent) => + (filePath: string): string => { + // Normalize paths to ensure relative() works correctly on Windows + const normalizedContent = component.content.split(sep).join(posix.sep); + const normalizedFilePath = filePath.split(sep).join(posix.sep); + return posix.relative(normalizedContent, normalizedFilePath); + }; + /** * Groups messages from the deploy result by component fullName and type */ @@ -152,8 +151,6 @@ export const getDeployMessages = (result: MetadataApiDeployStatus): Map +const supportsPartialDeleteAndHasContent = (comp: SourceComponent): comp is SourceComponentWithContent => supportsPartialDelete(comp) && typeof comp.content === 'string' && fs.statSync(comp.content).isDirectory(); const supportsPartialDeleteAndHasZipContent = (tree: ZipTreeContainer) => - (comp: SourceComponent): comp is SourceComponent & { content: string } => + (comp: SourceComponent): comp is SourceComponentWithContent => supportsPartialDelete(comp) && typeof comp.content === 'string' && tree.isDirectory(comp.content); const supportsPartialDeleteAndIsInMap = diff --git a/src/client/utils.ts b/src/client/utils.ts index f6d416717..c09d9c25e 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SourceComponent } from '../resolve/sourceComponent'; +import { SourceComponent, SourceComponentWithContent } from '../resolve/sourceComponent'; -export const isWebAppBundle = (component: SourceComponent): component is SourceComponent & { content: string } => +export const isWebAppBundle = (component: SourceComponent): component is SourceComponentWithContent => component.type.name === 'DigitalExperienceBundle' && component.fullName.startsWith('web_app/') && typeof component.content === 'string'; diff --git a/src/resolve/sourceComponent.ts b/src/resolve/sourceComponent.ts index fc92dc62c..af42d0549 100644 --- a/src/resolve/sourceComponent.ts +++ b/src/resolve/sourceComponent.ts @@ -47,6 +47,8 @@ export type ComponentProperties = { parentType?: MetadataType; }; +export type SourceComponentWithContent = SourceComponent & { content: string }; + /** * Representation of a MetadataComponent in a file tree. */ From cf4defc16454a8a756601cecc338f04bfb720180 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 20 Nov 2025 09:02:37 -0600 Subject: [PATCH 06/10] chore: test failure on fullname fn --- src/client/deployMessages.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index f30593f7e..9e66378f0 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -128,7 +128,8 @@ const getWebAppBundleContentFullName = // Normalize paths to ensure relative() works correctly on Windows const normalizedContent = component.content.split(sep).join(posix.sep); const normalizedFilePath = filePath.split(sep).join(posix.sep); - return posix.relative(normalizedContent, normalizedFilePath); + const relPath = posix.relative(normalizedContent, normalizedFilePath); + return posix.join(component.fullName, relPath); }; /** From c2f10d04a0fe275da2141205812bc1318f48d1c3 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 20 Nov 2025 09:07:50 -0600 Subject: [PATCH 07/10] chore: accidentally removed code --- src/client/deployMessages.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index 9e66378f0..f72346bb1 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -152,6 +152,8 @@ export const getDeployMessages = (result: MetadataApiDeployStatus): Map Date: Thu, 20 Nov 2025 13:14:32 -0600 Subject: [PATCH 08/10] test: ut for projectDir on CS --- test/client/metadataApiDeploy.test.ts | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/client/metadataApiDeploy.test.ts b/test/client/metadataApiDeploy.test.ts index 38075544f..3b56984d9 100644 --- a/test/client/metadataApiDeploy.test.ts +++ b/test/client/metadataApiDeploy.test.ts @@ -1293,6 +1293,44 @@ describe('MetadataApiDeploy', () => { result.getFileResponses(); expect(spy.callCount).to.equal(1); }); + + it('should prepend projectDirectory to filePaths when projectDirectory differs from cwd', () => { + const component = matchingContentFile.COMPONENT; + const projectDir = join('my', 'project', 'dir'); + const deployedSet = new ComponentSet([component]); + deployedSet.projectDirectory = projectDir; + const { fullName, type, content, xml } = component; + const apiStatus: Partial = { + details: { + componentSuccesses: { + changed: 'true', + created: 'false', + deleted: 'false', + fullName, + componentType: type.name, + } as DeployMessage, + }, + }; + const result = new DeployResult(apiStatus as MetadataApiDeployStatus, deployedSet); + + const responses = result.getFileResponses(); + const expected: FileResponse[] = [ + { + fullName, + type: type.name, + state: ComponentStatus.Changed, + filePath: join(projectDir, ensureString(content)), + }, + { + fullName, + type: type.name, + state: ComponentStatus.Changed, + filePath: join(projectDir, ensureString(xml)), + }, + ]; + + expect(responses).to.deep.equal(expected); + }); }); }); From 897c328b987e1be3a300b9eb35cbc3543bea796a Mon Sep 17 00:00:00 2001 From: mshanemc Date: Fri, 21 Nov 2025 11:49:22 -0600 Subject: [PATCH 09/10] fix: handling desktop non-matching cwd scenarios for projectPath --- src/client/deployMessages.ts | 6 ++++-- src/resolve/treeContainers.ts | 2 +- test/resolve/treeContainers.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index f72346bb1..d01842cff 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -117,8 +117,10 @@ export const createResponses = ...response, filePath: // deployResults will produce filePaths relative to cwd, which might not be set in all environments - // if our CS had a projectDir set, we'll make the results relative to that path - projectPath && process.cwd() === projectPath ? response.filePath : join(projectPath ?? '', response.filePath), + // if our CS had a projectDir set, we'll make the results relative to that path unless it already is + projectPath && process.cwd() !== projectPath && !response.filePath.startsWith(projectPath) + ? join(projectPath, response.filePath) + : response.filePath, })) satisfies FileResponseSuccess[]; }); diff --git a/src/resolve/treeContainers.ts b/src/resolve/treeContainers.ts index 24192686a..fbfb26b97 100644 --- a/src/resolve/treeContainers.ts +++ b/src/resolve/treeContainers.ts @@ -63,7 +63,7 @@ export abstract class TreeContainer { /** if the container has cwd set, apply it to the path */ protected getUpdatedFsPath(fsPath: SourcePath): string { - return this.cwd ? join(this.cwd, fsPath) : fsPath; + return this.cwd && !fsPath.startsWith(this.cwd) ? join(this.cwd, fsPath) : fsPath; } /** diff --git a/test/resolve/treeContainers.test.ts b/test/resolve/treeContainers.test.ts index 2cd7ed3f0..2297725bb 100644 --- a/test/resolve/treeContainers.test.ts +++ b/test/resolve/treeContainers.test.ts @@ -141,6 +141,34 @@ describe('Tree Containers', () => { env.stub(fs, 'existsSync').returns(true); expect(tree.stream(path)).to.deep.equal(readable); }); + + describe('with projectPath/cwd', () => { + let projectPath: string; + // mocking fs will defeat the goal of these tests, so we'll use the real one and clean it up + before(async () => { + projectPath = join('path', 'to', 'project'); + await fs.promises.mkdir(projectPath, { recursive: true }); + await fs.promises.writeFile(join(projectPath, 'test.txt'), 'test'); + }); + + after(async () => { + await fs.promises.rm(projectPath, { recursive: true }); + }); + + it('should return the path as is if it is already relative to the project path', () => { + const tree = new NodeFSTreeContainer(projectPath); + const path = join(projectPath, 'test.txt'); + expect(tree.exists(path)).to.be.true; + expect(tree.readDirectory(projectPath)).to.deep.equal(['test.txt']); + }); + + it('should add the project path to the path if it is not already relative to the project path', () => { + const tree = new NodeFSTreeContainer(projectPath); + const path = 'test.txt'; + expect(tree.exists(path)).to.be.true; + expect(tree.readDirectory(projectPath)).to.deep.equal(['test.txt']); + }); + }); }); describe('ZipTreeContainer', () => { From 003c70a3129d838e394a0cefab4fdd11dc0d0d4b Mon Sep 17 00:00:00 2001 From: mshanemc Date: Fri, 21 Nov 2025 11:59:41 -0600 Subject: [PATCH 10/10] chore: changelog --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11699eb79..1d9bd2e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,8 @@ # [12.29.0](https://github.com/forcedotcom/source-deploy-retrieve/compare/12.28.0...12.29.0) (2025-11-20) - ### Features -* web compatibility W-20175875 ([#1650](https://github.com/forcedotcom/source-deploy-retrieve/issues/1650)) ([f350cd6](https://github.com/forcedotcom/source-deploy-retrieve/commit/f350cd6e719e0aa8c05335e34fd642a0226e6cd9)) - - +- web compatibility W-20175875 ([#1650](https://github.com/forcedotcom/source-deploy-retrieve/issues/1650)) ([f350cd6](https://github.com/forcedotcom/source-deploy-retrieve/commit/f350cd6e719e0aa8c05335e34fd642a0226e6cd9)) # [12.28.0](https://github.com/forcedotcom/source-deploy-retrieve/compare/12.27.2...12.28.0) (2025-11-13)