From 3256005878d0bbfde363e3b12d28c08b6a57ae00 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 30 Oct 2026 19:55:02 -0400 Subject: [PATCH 1/2] Manage the Business Manager cartridge path through SCAPI Business Manager (Sites-Site) cartridge path updates always ran a site archive import, and 'sites cartridges list --bm' read the SCAPI site resource, which does not exist for Sites-Site. The SCAPI custom-cartridges resource does work for Sites-Site, so use it for reads and updates, with site archive import as the fallback. OCAPI is skipped for BM updates since it cannot update that site. --- .changeset/bm-cartridge-path-scapi.md | 6 ++ .changeset/bm-cartridge-path-skill.md | 5 ++ docs/cli/sites.md | 2 +- .../src/commands/sites/cartridges/list.ts | 8 +- .../commands/sites/cartridges/list.test.ts | 30 ++++++++ .../src/operations/sites/cartridges.ts | 57 ++++++++------ .../src/operations/sites/index.ts | 4 +- .../test/operations/sites/cartridges.test.ts | 75 +++++++++++++++++++ skills/b2c-cli/skills/b2c-sites/SKILL.md | 2 +- 9 files changed, 160 insertions(+), 29 deletions(-) create mode 100644 .changeset/bm-cartridge-path-scapi.md create mode 100644 .changeset/bm-cartridge-path-skill.md diff --git a/.changeset/bm-cartridge-path-scapi.md b/.changeset/bm-cartridge-path-scapi.md new file mode 100644 index 000000000..75207795f --- /dev/null +++ b/.changeset/bm-cartridge-path-scapi.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-tooling-sdk': patch +'@salesforce/b2c-cli': patch +--- + +`b2c sites cartridges` commands with `--bm` now read and update the Business Manager cartridge path through the SCAPI Sites API instead of failing or running a site archive import. Site archive import remains the fallback when SCAPI isn't configured. diff --git a/.changeset/bm-cartridge-path-skill.md b/.changeset/bm-cartridge-path-skill.md new file mode 100644 index 000000000..81758710b --- /dev/null +++ b/.changeset/bm-cartridge-path-skill.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-agent-plugins': patch +--- + +The b2c-sites skill now explains that Business Manager cartridge path updates use the SCAPI Sites API. diff --git a/docs/cli/sites.md b/docs/cli/sites.md index d2e71e96c..566aed6eb 100644 --- a/docs/cli/sites.md +++ b/docs/cli/sites.md @@ -85,7 +85,7 @@ Found 2 site(s): Manage the cartridge path for a site — the ordered list of cartridges that are active on a storefront. Use `sites cartridges` or the singular alias `sites cartridge`. ::: tip Business Manager -Use the `--bm` flag as a shorthand for `--site-id Sites-Site` to manage the Business Manager cartridge path. BM updates always use site archive import since OCAPI direct updates are not supported for the BM site. +Use the `--bm` flag as a shorthand for `--site-id Sites-Site` to manage the Business Manager cartridge path. BM reads and updates use the SCAPI Sites API (`sfcc.sites.rw`) and fall back to site archive import when SCAPI isn't available. OCAPI can't update the BM site. ::: ::: tip Automatic Fallback diff --git a/packages/b2c-cli/src/commands/sites/cartridges/list.ts b/packages/b2c-cli/src/commands/sites/cartridges/list.ts index 6f2bb64df..214c7a9c2 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/list.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/list.ts @@ -51,8 +51,12 @@ export default class SitesCartridgesList extends InstanceCommand { expect(ocapiGet.firstCall.args[1].params.path.site_id).to.equal('Sites-Site'); }); + it('reads --bm from SCAPI custom-cartridges (no site resource for Sites-Site)', async () => { + const server = setupServer( + http.get( + 'https://abcd1234.api.commercecloud.salesforce.com/site/sites/v1/organizations/f_ecom_zzxy_dev/sites/Sites-Site/custom-cartridges', + () => HttpResponse.json({customCartridges: 'bm_a:bm_b'}), + ), + ); + server.listen({onUnhandledRequest: 'error'}); + try { + const command: any = await createCommand({bm: true}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'flags').get(() => ({'site-id': undefined, bm: true})); + const auth = { + fetch: (url: string, init?: Parameters[1]) => fetch(url, init), + getAuthorizationHeader: async () => 'Bearer test', + }; + sinon.stub(command, 'instance').get(() => ({ + apiBackend: 'scapi', + scapiClientConfig: {shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth}, + })); + + const result = await command.run(); + expect(result.cartridgeList).to.deep.equal(['bm_a', 'bm_b']); + } finally { + server.close(); + } + }); + it('errors when neither --site-id nor --bm provided', async () => { const command: any = await createCommand(); stubCommon(command, {jsonEnabled: false}); diff --git a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts index 8e91d9b94..3ed96ab12 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts @@ -16,7 +16,7 @@ import {getLogger} from '../../logging/logger.js'; import {siteArchiveImport, siteArchiveExportToBuffer} from '../jobs/site-archive.js'; import type {WaitForJobOptions} from '../jobs/run.js'; import {createSitesBackend} from './sites-backend.js'; -import type {CartridgePosition} from './sites-types.js'; +import type {CartridgePosition, SitesBackend} from './sites-types.js'; /** The special site ID for Business Manager. */ export const BM_SITE_ID = 'Sites-Site'; @@ -97,9 +97,9 @@ export async function getCartridgePath(instance: B2CInstance, siteId: string): P /** * Adds a cartridge to a site's cartridge path. * - * For regular sites, uses the SCAPI-first Sites backend and falls back to site - * archive import if neither direct backend is available. - * For Business Manager (Sites-Site), always uses site archive import. + * Uses the SCAPI-first Sites backend and falls back to site archive import if + * no direct backend is available. Business Manager (Sites-Site) uses SCAPI only + * (OCAPI cannot update it), then site archive import. * * @param instance - B2C instance * @param siteId - Site ID @@ -128,13 +128,12 @@ export async function addCartridge( ): Promise { const logger = getLogger(); - // BM always uses import/export - if (siteId === BM_SITE_ID) { - logger.debug({siteId}, 'Business Manager site — using site archive import for cartridge add'); + const backend = cartridgeWriteBackend(instance, siteId); + if (!backend) { + logger.debug({siteId}, 'Business Manager site without SCAPI — using site archive import for cartridge add'); return addCartridgeViaImport(instance, siteId, options, updateOptions); } - const backend = createSitesBackend({instance}); try { return toResult(siteId, await backend.addCartridge(siteId, options.name, options.position, options.target)); } catch (backendError) { @@ -147,9 +146,9 @@ export async function addCartridge( /** * Removes a cartridge from a site's cartridge path. * - * For regular sites, uses the SCAPI-first Sites backend and falls back to site - * archive import if neither direct backend is available. - * For Business Manager (Sites-Site), always uses site archive import. + * Uses the SCAPI-first Sites backend and falls back to site archive import if + * no direct backend is available. Business Manager (Sites-Site) uses SCAPI only + * (OCAPI cannot update it), then site archive import. * * @param instance - B2C instance * @param siteId - Site ID @@ -169,12 +168,12 @@ export async function removeCartridge( ): Promise { const logger = getLogger(); - if (siteId === BM_SITE_ID) { - logger.debug({siteId}, 'Business Manager site — using site archive import for cartridge remove'); + const backend = cartridgeWriteBackend(instance, siteId); + if (!backend) { + logger.debug({siteId}, 'Business Manager site without SCAPI — using site archive import for cartridge remove'); return removeCartridgeViaImport(instance, siteId, cartridgeName, updateOptions); } - const backend = createSitesBackend({instance}); try { return toResult(siteId, await backend.removeCartridge(siteId, cartridgeName)); } catch (backendError) { @@ -187,9 +186,9 @@ export async function removeCartridge( /** * Replaces the entire cartridge path for a site. * - * For regular sites, uses the SCAPI-first Sites backend and falls back to site - * archive import if neither direct backend is available. - * For Business Manager (Sites-Site), always uses site archive import. + * Uses the SCAPI-first Sites backend and falls back to site archive import if + * no direct backend is available. Business Manager (Sites-Site) uses SCAPI only + * (OCAPI cannot update it), then site archive import. * * @param instance - B2C instance * @param siteId - Site ID @@ -209,12 +208,12 @@ export async function setCartridgePath( ): Promise { const logger = getLogger(); - if (siteId === BM_SITE_ID) { - logger.debug({siteId}, 'Business Manager site — using site archive import for cartridge set'); + const backend = cartridgeWriteBackend(instance, siteId); + if (!backend) { + logger.debug({siteId}, 'Business Manager site without SCAPI — using site archive import for cartridge set'); return setCartridgePathViaImport(instance, siteId, cartridges, updateOptions); } - const backend = createSitesBackend({instance}); try { return toResult(siteId, await backend.setCartridgePath(siteId, cartridges)); } catch (backendError) { @@ -228,6 +227,18 @@ export async function setCartridgePath( // Internal: Fallback handler // --------------------------------------------------------------------------- +/** + * Picks the direct backend for a cartridge path update. Business Manager is + * SCAPI-only (OCAPI's site cartridges resource does not cover Sites-Site), so it + * returns undefined when SCAPI is unavailable or OCAPI is explicitly preferred, + * meaning the caller should go straight to site archive import. + */ +function cartridgeWriteBackend(instance: B2CInstance, siteId: string): SitesBackend | undefined { + if (siteId !== BM_SITE_ID) return createSitesBackend({instance}); + if (!instance.scapiClientConfig || instance.apiBackend === 'ocapi') return undefined; + return createSitesBackend({instance, preference: 'scapi'}); +} + async function handleFallback( instance: B2CInstance, siteId: string, @@ -251,12 +262,12 @@ async function handleFallback( [ `Failed to ${operation} cartridge path for site "${siteId}".`, '', - `SCAPI/OCAPI direct update failed: ${backendMessage}`, + `${siteId === BM_SITE_ID ? 'SCAPI' : 'SCAPI/OCAPI'} direct update failed: ${backendMessage}`, `Site archive import fallback also failed: ${importMessage}`, '', 'To fix, configure one of:', ' • SCAPI Sites API: Grant sfcc.sites.rw', - ' • OCAPI Data API: Grant POST/PUT/DELETE on /sites/*/cartridges', + ...(siteId === BM_SITE_ID ? [] : [' • OCAPI Data API: Grant POST/PUT/DELETE on /sites/*/cartridges']), ' • Site import: Grant job execution permissions for sfcc-site-archive-import and WebDAV write access to Impex/', '', 'See: https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/authentication.html', @@ -272,7 +283,7 @@ async function handleFallback( /** * Reads the current cartridge path via site archive export. - * Used as a fallback when OCAPI GET also fails. + * Used as a fallback when the direct read also fails. */ async function getCartridgePathViaExport( instance: B2CInstance, diff --git a/packages/b2c-tooling-sdk/src/operations/sites/index.ts b/packages/b2c-tooling-sdk/src/operations/sites/index.ts index 454c029e3..f02282987 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/index.ts @@ -8,8 +8,8 @@ * * This module provides functions for managing site cartridge paths * on B2C Commerce instances. Operations use SCAPI first, with temporary - * OCAPI and site-archive fallbacks. Business Manager (Sites-Site) is - * supported via the import/export mechanism. + * OCAPI and site-archive fallbacks. Business Manager (Sites-Site) uses SCAPI, + * then the import/export mechanism (OCAPI cannot update it). * * ## Cartridge Path Functions * diff --git a/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts index b13dc37cc..7b4e36211 100644 --- a/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts @@ -24,6 +24,10 @@ const TEST_HOST = 'test.demandware.net'; const FAST_POLL = {waitOptions: {pollIntervalSeconds: 0}}; const BASE_URL = `https://${TEST_HOST}/s/-/dw/data/v25_6`; const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; +const SCAPI_BM_CARTRIDGES = + 'https://abcd1234.api.commercecloud.salesforce.com/site/sites/v1/organizations/f_ecom_zzxy_dev/sites/Sites-Site/custom-cartridges'; +const SCAPI_JOB_EXECUTIONS = + 'https://abcd1234.api.commercecloud.salesforce.com/operation/jobs/v1/organizations/f_ecom_zzxy_dev/jobs/sfcc-site-archive-import/executions'; /** * Creates MSW handlers for site archive import (upload zip + execute job + poll + cleanup). @@ -366,4 +370,75 @@ describe('operations/sites/cartridges', () => { } }); }); + + describe('Business Manager via SCAPI', () => { + let scapiInstance: any; + + beforeEach(() => { + scapiInstance = { + ...mockInstance, + apiBackend: 'auto', + scapiClientConfig: {shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: new MockAuthStrategy()}, + }; + }); + + it('sets the BM cartridge path with SCAPI custom-cartridges', async () => { + let putBody: any; + server.use( + http.put(SCAPI_BM_CARTRIDGES, async ({request}) => { + putBody = await request.json(); + return HttpResponse.json({customCartridges: 'bm_a:bm_b'}); + }), + ); + + const result = await setCartridgePath(scapiInstance, 'Sites-Site', 'bm_a:bm_b'); + + expect(putBody).to.deep.equal({customCartridges: 'bm_a:bm_b'}); + expect(result.cartridgeList).to.deep.equal(['bm_a', 'bm_b']); + }); + + it('adds a BM cartridge by reading and writing SCAPI custom-cartridges', async () => { + server.use( + http.get(SCAPI_BM_CARTRIDGES, () => HttpResponse.json({customCartridges: 'bm_a'})), + http.put(SCAPI_BM_CARTRIDGES, async ({request}) => HttpResponse.json(await request.json())), + ); + + const result = await addCartridge(scapiInstance, 'Sites-Site', {name: 'bm_new', position: 'first'}); + + expect(result.cartridges).to.equal('bm_new:bm_a'); + }); + + it('falls back to import without trying OCAPI when SCAPI is rejected', async () => { + let importedBuffer: Buffer | undefined; + server.use( + http.put(SCAPI_BM_CARTRIDGES, () => HttpResponse.json({title: 'Forbidden'}, {status: 403})), + // With SCAPI configured, the import job itself runs over SCAPI Jobs. + http.post(`${SCAPI_JOB_EXECUTIONS}`, () => HttpResponse.json({id: 'exec-1', executionStatus: 'pending'})), + http.get(`${SCAPI_JOB_EXECUTIONS}/exec-1`, () => + HttpResponse.json({id: 'exec-1', executionStatus: 'finished', exitStatus: {code: 'OK', status: 'ok'}}), + ), + ...createImportHandlers((buf) => { + importedBuffer = buf; + }), + ); + + const result = await setCartridgePath(scapiInstance, 'Sites-Site', 'bm_a', FAST_POLL); + + expect(result.cartridges).to.equal('bm_a'); + expect(importedBuffer).to.exist; + }); + + it('uses import when the instance prefers OCAPI', async () => { + let importedBuffer: Buffer | undefined; + server.use( + ...createImportHandlers((buf) => { + importedBuffer = buf; + }), + ); + + await setCartridgePath({...scapiInstance, apiBackend: 'ocapi'}, 'Sites-Site', 'bm_a', FAST_POLL); + + expect(importedBuffer).to.exist; + }); + }); }); diff --git a/skills/b2c-cli/skills/b2c-sites/SKILL.md b/skills/b2c-cli/skills/b2c-sites/SKILL.md index a3aeb8719..dbf0ded68 100644 --- a/skills/b2c-cli/skills/b2c-sites/SKILL.md +++ b/skills/b2c-cli/skills/b2c-sites/SKILL.md @@ -71,7 +71,7 @@ b2c sites cartridges set "app_storefront_base:plugin_applepay:plugin_wishlists" b2c sites cartridges list --site-id RefArch --json ``` -When OCAPI direct permissions for `/sites/*/cartridges` are unavailable, cartridge commands automatically fall back to site archive import/export. Business Manager (`--bm`) updates always use site archive import. +When OCAPI direct permissions for `/sites/*/cartridges` are unavailable, cartridge commands automatically fall back to site archive import/export. Business Manager (`--bm`) updates use SCAPI (`sfcc.sites.rw`), then site archive import; OCAPI can't update the BM site. **Key flags (inherited from InstanceCommand):** From a0f9702684695a76bf61ee1c21b43678a1f06006 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 30 Oct 2026 20:14:49 -0400 Subject: [PATCH 2/2] test: pin clock in describeSignIn tests so they pass near midnight --- .../test/auth/instance-auth.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/b2c-tooling-sdk/test/auth/instance-auth.test.ts b/packages/b2c-tooling-sdk/test/auth/instance-auth.test.ts index 30ca74127..fb5884841 100644 --- a/packages/b2c-tooling-sdk/test/auth/instance-auth.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/instance-auth.test.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; +import sinon from 'sinon'; import { describeAuthSession, describeInstanceAuth, @@ -111,8 +112,21 @@ describe('auth/instance-auth', () => { }); describe('describeSignIn', () => { - const expiresAt = new Date(Date.now() + 30 * 60 * 1000); - const time = expiresAt.toLocaleString(undefined, {timeStyle: 'short'}); + let clock: sinon.SinonFakeTimers; + let expiresAt: Date; + let time: string; + + // Pin "now" to local midday so a 30-minute expiry never crosses midnight + // (which would add the date to the message). + beforeEach(() => { + clock = sinon.useFakeTimers({now: new Date(2026, 9, 23, 12, 0), toFake: ['Date']}); + expiresAt = new Date(Date.now() + 30 * 60 * 1000); + time = expiresAt.toLocaleString(undefined, {timeStyle: 'short'}); + }); + + afterEach(() => { + clock.restore(); + }); it('names the user and when access expires', () => { expect(describeSignIn({clientId: DEFAULT_PUBLIC_CLIENT_ID, user: 'dev@example.com', expiresAt})).to.equal(