Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bm-cartridge-path-scapi.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/bm-cartridge-path-skill.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/cli/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/b2c-cli/src/commands/sites/cartridges/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ export default class SitesCartridgesList extends InstanceCommand<typeof SitesCar
const backend = createSitesBackend({instance: this.instance});
this.logger.debug(`Using ${backend.name} backend for cartridge path read`);

const site = await backend.getSite(siteId);
const cartridges = (site.cartridges ?? '').trim();
// SCAPI has no site resource for Business Manager (getSite 404s), but its custom-cartridges resource works.
const cartridges = (
siteId === BM_SITE_ID
? await backend.getCartridgePath(siteId)
: ((await backend.getSite(siteId)).cartridges ?? '')
).trim();
const result: CartridgePathResult = {
siteId,
cartridges,
Expand Down
30 changes: 30 additions & 0 deletions packages/b2c-cli/test/commands/sites/cartridges/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import {ux} from '@oclif/core';
import {expect} from 'chai';
import {http, HttpResponse} from 'msw';
import {setupServer} from 'msw/node';
import sinon from 'sinon';
import SitesCartridgesList from '../../../../src/commands/sites/cartridges/list.js';
import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js';
Expand Down Expand Up @@ -82,6 +84,34 @@ describe('sites cartridges list', () => {
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<typeof fetch>[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});
Expand Down
57 changes: 34 additions & 23 deletions packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -128,13 +128,12 @@ export async function addCartridge(
): Promise<CartridgePathResult> {
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) {
Expand All @@ -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
Expand All @@ -169,12 +168,12 @@ export async function removeCartridge(
): Promise<CartridgePathResult> {
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) {
Expand All @@ -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
Expand All @@ -209,12 +208,12 @@ export async function setCartridgePath(
): Promise<CartridgePathResult> {
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) {
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/b2c-tooling-sdk/src/operations/sites/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
18 changes: 16 additions & 2 deletions packages/b2c-tooling-sdk/test/auth/instance-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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;
});
});
});
2 changes: 1 addition & 1 deletion skills/b2c-cli/skills/b2c-sites/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand Down
Loading