From 9b70bf4bed5f5def96e83fc1916aa585ec6a3028 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 29 Mar 2026 16:02:44 -0400 Subject: [PATCH 1/2] Add site cartridge path management commands (closes #194) Add `sites cartridges list|add|remove|set` commands with SDK operations for managing the ordered list of active cartridges on a site. Includes `--bm` flag for Business Manager support and automatic fallback to site archive import/export when OCAPI permissions are unavailable. --- .changeset/site-cartridge-path-management.md | 6 + docs/cli/sites.md | 171 ++++++ docs/guide/sdk-migration.md | 2 +- docs/guide/sfcc-ci-migration.md | 11 + packages/b2c-cli/.gitignore | 2 + packages/b2c-cli/package.json | 7 +- .../src/commands/sites/cartridges/add.ts | 131 +++++ .../src/commands/sites/cartridges/list.ts | 90 +++ .../src/commands/sites/cartridges/remove.ts | 104 ++++ .../src/commands/sites/cartridges/set.ts | 104 ++++ .../commands/sites/cartridges/add.test.ts | 109 ++++ .../commands/sites/cartridges/list.test.ts | 99 ++++ .../commands/sites/cartridges/remove.test.ts | 81 +++ .../commands/sites/cartridges/set.test.ts | 82 +++ packages/b2c-tooling-sdk/package.json | 11 + .../src/operations/sites/cartridges.ts | 512 ++++++++++++++++++ .../src/operations/sites/index.ts | 57 ++ .../test/operations/sites/cartridges.test.ts | 365 +++++++++++++ skills/b2c-cli/skills/b2c-code/SKILL.md | 3 + skills/b2c-cli/skills/b2c-sites/SKILL.md | 39 +- 20 files changed, 1982 insertions(+), 4 deletions(-) create mode 100644 .changeset/site-cartridge-path-management.md create mode 100644 packages/b2c-cli/src/commands/sites/cartridges/add.ts create mode 100644 packages/b2c-cli/src/commands/sites/cartridges/list.ts create mode 100644 packages/b2c-cli/src/commands/sites/cartridges/remove.ts create mode 100644 packages/b2c-cli/src/commands/sites/cartridges/set.ts create mode 100644 packages/b2c-cli/test/commands/sites/cartridges/add.test.ts create mode 100644 packages/b2c-cli/test/commands/sites/cartridges/list.test.ts create mode 100644 packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts create mode 100644 packages/b2c-cli/test/commands/sites/cartridges/set.test.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/index.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts diff --git a/.changeset/site-cartridge-path-management.md b/.changeset/site-cartridge-path-management.md new file mode 100644 index 00000000..211e958e --- /dev/null +++ b/.changeset/site-cartridge-path-management.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add site cartridge path management commands (`sites cartridges list|add|remove|set`) with `--bm` flag for Business Manager support and automatic fallback to site archive import when OCAPI permissions are unavailable diff --git a/docs/cli/sites.md b/docs/cli/sites.md index af7ea9bd..ce818f0f 100644 --- a/docs/cli/sites.md +++ b/docs/cli/sites.md @@ -16,6 +16,9 @@ Sites commands require OAuth authentication with OCAPI permissions for the `/sit |----------|---------| | `/sites` | GET | | `/sites/*` | GET | +| `/sites/*/cartridges` | POST, PUT, DELETE | + +Cartridge path commands also work without the cartridge-specific OCAPI permissions — they automatically fall back to site archive import/export when direct OCAPI access is unavailable. The fallback requires job execution permissions for `sfcc-site-archive-import` and WebDAV write access to `Impex/`. ### Configuration @@ -77,3 +80,171 @@ Found 2 site(s): Status: online ``` +--- + +## Cartridge Commands + +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. +::: + +::: tip Automatic Fallback +If OCAPI permissions for `/sites/*/cartridges` are not available, cartridge commands automatically fall back to site archive import/export. This means the commands work even without specific cartridge OCAPI permissions, as long as job execution and WebDAV access are configured. +::: + +--- + +### b2c sites cartridges list + +List the cartridge path for a site. + +#### Usage + +```bash +b2c sites cartridges list --site-id +b2c sites cartridges list --bm +``` + +#### Flags + +| Flag | Description | +|------|-------------| +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--json` | Output as JSON | + +One of `--site-id` or `--bm` is required. + +#### Examples + +```bash +# List cartridge path for a storefront site +b2c sites cartridges list --site-id RefArch + +# List Business Manager cartridge path +b2c sites cartridges list --bm + +# JSON output for automation +b2c sites cartridges list --site-id RefArch --json +``` + +--- + +### b2c sites cartridges add + +Add a cartridge to a site's cartridge path. + +#### Usage + +```bash +b2c sites cartridges add --site-id [--position ] [--target ] +``` + +#### Arguments + +| Argument | Description | +|----------|-------------| +| `cartridge` | Name of the cartridge to add | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--position ` | Position: `first` (default), `last`, `before`, `after` | +| `--target ` | Target cartridge (required when position is `before` or `after`) | +| `--json` | Output as JSON | + +#### Examples + +```bash +# Add to beginning of path (default) +b2c sites cartridges add plugin_applepay --site-id RefArch + +# Add to end +b2c sites cartridges add plugin_applepay --site-id RefArch --position last + +# Add after a specific cartridge +b2c sites cartridges add plugin_applepay --site-id RefArch --position after --target app_storefront_base + +# Add to Business Manager +b2c sites cartridges add bm_extension --bm --position first +``` + +--- + +### b2c sites cartridges remove + +Remove a cartridge from a site's cartridge path. + +::: warning Destructive Operation +This command modifies the site cartridge path. It is blocked in safe mode — use `--safety-level off` to allow it. +::: + +#### Usage + +```bash +b2c sites cartridges remove --site-id +``` + +#### Arguments + +| Argument | Description | +|----------|-------------| +| `cartridge` | Name of the cartridge to remove | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--json` | Output as JSON | + +#### Examples + +```bash +b2c sites cartridges remove old_cartridge --site-id RefArch +b2c sites cartridges remove bm_extension --bm +``` + +--- + +### b2c sites cartridges set + +Replace the entire cartridge path for a site. + +::: warning Destructive Operation +This command replaces the entire cartridge path. It is blocked in safe mode — use `--safety-level off` to allow it. +::: + +#### Usage + +```bash +b2c sites cartridges set --site-id +``` + +#### Arguments + +| Argument | Description | +|----------|-------------| +| `cartridges` | New cartridge path (colon-separated, e.g. `cart1:cart2:cart3`) | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--json` | Output as JSON | + +#### Examples + +```bash +b2c sites cartridges set "app_storefront_base:plugin_applepay:plugin_wishlists" --site-id RefArch +b2c sites cartridges set "bm_ext1:bm_ext2" --bm +``` + diff --git a/docs/guide/sdk-migration.md b/docs/guide/sdk-migration.md index 21ae7343..d4ce7588 100644 --- a/docs/guide/sdk-migration.md +++ b/docs/guide/sdk-migration.md @@ -388,7 +388,7 @@ The OCAPI client is generated from the OpenAPI spec, so all paths, parameters, a | `code.compare(...)` | _Not ported_ | | | `code.diffdeploy(...)` | _Not ported_ | | | `manifest.generate(...)` | _Not ported_ | | -| `cartridge.add(...)` | _Planned for a future release_ | | +| `cartridge.add(...)` | `addCartridge(instance, siteId, opts)` | `*/operations/sites` | | `instance.upload(instance, file, token, opts, cb)` | `instance.webdav.put(path, data)` | `*/instance` | | `instance.import(instance, file, token, cb)` | `siteArchiveImport(instance, zipPath)` | `*/operations/jobs` | | `job.run(instance, jobId, params, token, cb)` | `executeJob(instance, jobId, opts?)` | `*/operations/jobs` | diff --git a/docs/guide/sfcc-ci-migration.md b/docs/guide/sfcc-ci-migration.md index 1c859bab..e075cebb 100644 --- a/docs/guide/sfcc-ci-migration.md +++ b/docs/guide/sfcc-ci-migration.md @@ -77,6 +77,17 @@ The B2C CLI's canonical syntax uses spaces instead of colons (e.g., `b2c code de | `sfcc-ci instance:import ` | `b2c content import ` | Or `b2c job run sfcc-site-archive-import` | | `sfcc-ci instance:export` | `b2c content export` | See [Content commands](/cli/content) | +### Cartridge Path + +| sfcc-ci | b2c-cli | Notes | +|---------|---------|-------| +| `sfcc-ci cartridge:add --siteid ` | `b2c sites cartridges add --site-id ` | Supports `--position` and `--target` flags | +| _(no equivalent)_ | `b2c sites cartridges list --site-id ` | List the active cartridge path | +| _(no equivalent)_ | `b2c sites cartridges remove --site-id ` | Remove a cartridge from the path | +| _(no equivalent)_ | `b2c sites cartridges set --site-id ` | Replace the entire cartridge path | + +The B2C CLI also supports the Business Manager cartridge path via the `--bm` flag (shorthand for `--site-id Sites-Site`). When OCAPI direct permissions are unavailable, the commands automatically fall back to site archive import/export. See [Sites commands](/cli/sites#cartridge-commands) for details. + ### Jobs | sfcc-ci | b2c-cli | Notes | diff --git a/packages/b2c-cli/.gitignore b/packages/b2c-cli/.gitignore index f298a9f4..d3457726 100644 --- a/packages/b2c-cli/.gitignore +++ b/packages/b2c-cli/.gitignore @@ -18,3 +18,5 @@ dw.json export/ cartridges/ +!src/commands/**/cartridges/ +!test/commands/**/cartridges/ diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index b5e469f1..47f8b31a 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -183,7 +183,12 @@ } }, "sites": { - "description": "List and inspect storefront sites\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/sites.html" + "description": "List and manage storefront sites\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/sites.html", + "subtopics": { + "cartridges": { + "description": "Manage site cartridge path (list, add, remove, set)" + } + } }, "am": { "description": "Manage Account Manager resources", diff --git a/packages/b2c-cli/src/commands/sites/cartridges/add.ts b/packages/b2c-cli/src/commands/sites/cartridges/add.ts new file mode 100644 index 00000000..614afa84 --- /dev/null +++ b/packages/b2c-cli/src/commands/sites/cartridges/add.ts @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + type CartridgePathResult, + type CartridgePosition, + BM_SITE_ID, + addCartridge, +} from '@salesforce/b2c-tooling-sdk/operations/sites'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class SitesCartridgesAdd extends InstanceCommand { + static description = withDocs( + t('commands.sites.cartridges.add.description', 'Add a cartridge to a site\'s cartridge path'), + '/cli/sites.html#b2c-sites-cartridges-add', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> sites cartridges add my_cartridge --site-id RefArch', + '<%= config.bin %> sites cartridges add my_cartridge --site-id RefArch --position first', + '<%= config.bin %> sites cartridges add my_cartridge --site-id RefArch --position after --target app_storefront_base', + '<%= config.bin %> sites cartridges add bm_extension --bm', + ]; + + static hiddenAliases = ['sites:cartridge:add']; + + static args = { + cartridge: Args.string({ + description: t('args.cartridge.description', 'Cartridge name to add'), + required: true, + }), + }; + + static flags = { + 'site-id': Flags.string({ + description: t('flags.siteId.description', 'Site ID (e.g. RefArch)'), + exclusive: ['bm'], + }), + bm: Flags.boolean({ + description: t('flags.bm.description', 'Use Business Manager site (Sites-Site)'), + exclusive: ['site-id'], + }), + position: Flags.string({ + description: t('flags.position.description', 'Position to add the cartridge'), + options: ['first', 'last', 'before', 'after'], + default: 'first', + }), + target: Flags.string({ + description: t('flags.target.description', 'Target cartridge (required when position is before/after)'), + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const siteId = this.resolveSiteId(); + const {cartridge} = this.args; + const position = this.flags.position as CartridgePosition; + const {target} = this.flags; + + // Validate target is provided for relative positions + if ((position === 'before' || position === 'after') && !target) { + this.error( + t( + 'commands.sites.cartridges.add.targetRequired', + '--target is required when --position is "{{position}}"', + {position}, + ), + ); + } + + const result = await addCartridge(this.instance, siteId, {name: cartridge, position, target}, { + log: (msg) => { + if (!this.jsonEnabled()) this.log(msg); + }, + waitOptions: { + onProgress: (exec, elapsed) => { + if (!this.jsonEnabled()) { + const elapsedSec = Math.floor(elapsed / 1000); + this.log( + t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: exec.execution_status, + elapsed: elapsedSec.toString(), + }), + ); + } + }, + }, + }); + + if (this.jsonEnabled()) { + return result; + } + + this.log( + t('commands.sites.cartridges.add.success', 'Added "{{cartridge}}" to site "{{siteId}}" cartridge path.', { + cartridge, + siteId, + }), + ); + this.log( + t('commands.sites.cartridges.add.updatedPath', 'Updated path: {{cartridges}}', { + cartridges: result.cartridges, + }), + ); + + return result; + } + + private resolveSiteId(): string { + const siteId = this.flags['site-id']; + const bm = this.flags.bm; + + if (!siteId && !bm) { + this.error( + t( + 'commands.sites.cartridges.siteIdRequired', + 'Provide --site-id or --bm to specify a site.', + ), + ); + } + + return bm ? BM_SITE_ID : siteId!; + } +} diff --git a/packages/b2c-cli/src/commands/sites/cartridges/list.ts b/packages/b2c-cli/src/commands/sites/cartridges/list.ts new file mode 100644 index 00000000..87ea13dd --- /dev/null +++ b/packages/b2c-cli/src/commands/sites/cartridges/list.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, ux} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type CartridgePathResult, BM_SITE_ID, getCartridgePath} from '@salesforce/b2c-tooling-sdk/operations/sites'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class SitesCartridgesList extends InstanceCommand { + static description = withDocs( + t('commands.sites.cartridges.list.description', 'List the cartridge path for a site'), + '/cli/sites.html#b2c-sites-cartridges-list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> sites cartridges list --site-id RefArch', + '<%= config.bin %> sites cartridges list --bm', + '<%= config.bin %> sites cartridges list --site-id RefArch --json', + ]; + + static hiddenAliases = ['sites:cartridge:list']; + + static flags = { + 'site-id': Flags.string({ + description: t('flags.siteId.description', 'Site ID (e.g. RefArch)'), + exclusive: ['bm'], + }), + bm: Flags.boolean({ + description: t('flags.bm.description', 'Use Business Manager site (Sites-Site)'), + exclusive: ['site-id'], + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const siteId = this.resolveSiteId(); + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.sites.cartridges.list.fetching', 'Fetching cartridge path for site "{{siteId}}" on {{hostname}}...', { + siteId, + hostname, + }), + ); + + const result = await getCartridgePath(this.instance, siteId); + + if (this.jsonEnabled()) { + return result; + } + + if (result.cartridgeList.length === 0) { + ux.stdout(t('commands.sites.cartridges.list.empty', 'No cartridges configured for site "{{siteId}}".', {siteId})); + return result; + } + + ux.stdout( + t('commands.sites.cartridges.list.header', 'Cartridge path for site "{{siteId}}" ({{total}} cartridges):', { + siteId, + total: String(result.cartridgeList.length), + }), + ); + for (let i = 0; i < result.cartridgeList.length; i++) { + ux.stdout(` ${i + 1}. ${result.cartridgeList[i]}`); + } + + return result; + } + + private resolveSiteId(): string { + const siteId = this.flags['site-id']; + const bm = this.flags.bm; + + if (!siteId && !bm) { + this.error( + t( + 'commands.sites.cartridges.siteIdRequired', + 'Provide --site-id or --bm to specify a site.', + ), + ); + } + + return bm ? BM_SITE_ID : siteId!; + } +} diff --git a/packages/b2c-cli/src/commands/sites/cartridges/remove.ts b/packages/b2c-cli/src/commands/sites/cartridges/remove.ts new file mode 100644 index 00000000..22d6ee90 --- /dev/null +++ b/packages/b2c-cli/src/commands/sites/cartridges/remove.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type CartridgePathResult, BM_SITE_ID, removeCartridge} from '@salesforce/b2c-tooling-sdk/operations/sites'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class SitesCartridgesRemove extends InstanceCommand { + static description = withDocs( + t('commands.sites.cartridges.remove.description', 'Remove a cartridge from a site\'s cartridge path'), + '/cli/sites.html#b2c-sites-cartridges-remove', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> sites cartridges remove old_cartridge --site-id RefArch', + '<%= config.bin %> sites cartridges remove bm_extension --bm', + ]; + + static hiddenAliases = ['sites:cartridge:remove']; + + static args = { + cartridge: Args.string({ + description: t('args.cartridge.description', 'Cartridge name to remove'), + required: true, + }), + }; + + static flags = { + 'site-id': Flags.string({ + description: t('flags.siteId.description', 'Site ID (e.g. RefArch)'), + exclusive: ['bm'], + }), + bm: Flags.boolean({ + description: t('flags.bm.description', 'Use Business Manager site (Sites-Site)'), + exclusive: ['site-id'], + }), + }; + + async run(): Promise { + this.assertDestructiveOperationAllowed('remove cartridge from site cartridge path'); + this.requireOAuthCredentials(); + + const siteId = this.resolveSiteId(); + const {cartridge} = this.args; + + const result = await removeCartridge(this.instance, siteId, cartridge, { + log: (msg) => { + if (!this.jsonEnabled()) this.log(msg); + }, + waitOptions: { + onProgress: (exec, elapsed) => { + if (!this.jsonEnabled()) { + const elapsedSec = Math.floor(elapsed / 1000); + this.log( + t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: exec.execution_status, + elapsed: elapsedSec.toString(), + }), + ); + } + }, + }, + }); + + if (this.jsonEnabled()) { + return result; + } + + this.log( + t('commands.sites.cartridges.remove.success', 'Removed "{{cartridge}}" from site "{{siteId}}" cartridge path.', { + cartridge, + siteId, + }), + ); + this.log( + t('commands.sites.cartridges.remove.updatedPath', 'Updated path: {{cartridges}}', { + cartridges: result.cartridges || '(empty)', + }), + ); + + return result; + } + + private resolveSiteId(): string { + const siteId = this.flags['site-id']; + const bm = this.flags.bm; + + if (!siteId && !bm) { + this.error( + t( + 'commands.sites.cartridges.siteIdRequired', + 'Provide --site-id or --bm to specify a site.', + ), + ); + } + + return bm ? BM_SITE_ID : siteId!; + } +} diff --git a/packages/b2c-cli/src/commands/sites/cartridges/set.ts b/packages/b2c-cli/src/commands/sites/cartridges/set.ts new file mode 100644 index 00000000..99a4642c --- /dev/null +++ b/packages/b2c-cli/src/commands/sites/cartridges/set.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type CartridgePathResult, BM_SITE_ID, setCartridgePath} from '@salesforce/b2c-tooling-sdk/operations/sites'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class SitesCartridgesSet extends InstanceCommand { + static description = withDocs( + t('commands.sites.cartridges.set.description', 'Replace the entire cartridge path for a site'), + '/cli/sites.html#b2c-sites-cartridges-set', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> sites cartridges set "app_storefront_base:plugin_applepay" --site-id RefArch', + '<%= config.bin %> sites cartridges set "bm_ext1:bm_ext2" --bm', + ]; + + static hiddenAliases = ['sites:cartridge:set']; + + static args = { + cartridges: Args.string({ + description: t( + 'args.cartridges.description', + 'New cartridge path (colon-separated, e.g. "cart1:cart2:cart3")', + ), + required: true, + }), + }; + + static flags = { + 'site-id': Flags.string({ + description: t('flags.siteId.description', 'Site ID (e.g. RefArch)'), + exclusive: ['bm'], + }), + bm: Flags.boolean({ + description: t('flags.bm.description', 'Use Business Manager site (Sites-Site)'), + exclusive: ['site-id'], + }), + }; + + async run(): Promise { + this.assertDestructiveOperationAllowed('replace site cartridge path'); + this.requireOAuthCredentials(); + + const siteId = this.resolveSiteId(); + const {cartridges} = this.args; + + const result = await setCartridgePath(this.instance, siteId, cartridges, { + log: (msg) => { + if (!this.jsonEnabled()) this.log(msg); + }, + waitOptions: { + onProgress: (exec, elapsed) => { + if (!this.jsonEnabled()) { + const elapsedSec = Math.floor(elapsed / 1000); + this.log( + t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: exec.execution_status, + elapsed: elapsedSec.toString(), + }), + ); + } + }, + }, + }); + + if (this.jsonEnabled()) { + return result; + } + + this.log( + t('commands.sites.cartridges.set.success', 'Cartridge path updated for site "{{siteId}}".', {siteId}), + ); + this.log( + t('commands.sites.cartridges.set.updatedPath', 'New path: {{cartridges}}', { + cartridges: result.cartridges, + }), + ); + + return result; + } + + private resolveSiteId(): string { + const siteId = this.flags['site-id']; + const bm = this.flags.bm; + + if (!siteId && !bm) { + this.error( + t( + 'commands.sites.cartridges.siteIdRequired', + 'Provide --site-id or --bm to specify a site.', + ), + ); + } + + return bm ? BM_SITE_ID : siteId!; + } +} diff --git a/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts new file mode 100644 index 00000000..9ec52b7b --- /dev/null +++ b/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * 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 SitesCartridgesAdd from '../../../../src/commands/sites/cartridges/add.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('sites cartridges add', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(SitesCartridgesAdd, hooks.getConfig(), flags, args); + } + + function stubCommon( + command: any, + {jsonEnabled, siteId, position, target}: {jsonEnabled: boolean; siteId: string; position?: string; target?: string}, + ) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + sinon.stub(command, 'flags').get(() => ({ + 'site-id': siteId, + bm: false, + position: position ?? 'first', + target, + })); + sinon.stub(command, 'args').get(() => ({cartridge: 'my_cartridge'})); + } + + it('adds cartridge via OCAPI POST and returns result', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridge: 'my_cartridge'}); + stubCommon(command, {jsonEnabled: true, siteId: 'RefArch'}); + + const ocapiPost = sinon.stub().resolves({ + data: {cartridges: 'cart_a:my_cartridge', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + const result = await command.run(); + expect(result.cartridges).to.equal('cart_a:my_cartridge'); + expect(ocapiPost.calledOnce).to.be.true; + }); + + it('passes position and target to OCAPI', async () => { + const command: any = await createCommand( + {'site-id': 'RefArch', position: 'after', target: 'cart_a'}, + {cartridge: 'my_cartridge'}, + ); + stubCommon(command, {jsonEnabled: true, siteId: 'RefArch', position: 'after', target: 'cart_a'}); + + const ocapiPost = sinon.stub().resolves({ + data: {cartridges: 'cart_a:my_cartridge', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + await command.run(); + + const body = ocapiPost.firstCall.args[1].body; + expect(body.name).to.equal('my_cartridge'); + expect(body.position).to.equal('after'); + expect(body.target).to.equal('cart_a'); + }); + + it('prints success message in non-JSON mode', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridge: 'my_cartridge'}); + stubCommon(command, {jsonEnabled: false, siteId: 'RefArch'}); + + const ocapiPost = sinon.stub().resolves({ + data: {cartridges: 'cart_a:my_cartridge', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + const logStub = sinon.stub(command, 'log'); + + await command.run(); + expect(logStub.callCount).to.be.at.least(1); + expect(logStub.firstCall.args[0]).to.include('Added'); + expect(logStub.firstCall.args[0]).to.include('my_cartridge'); + }); + + it('errors when position is before/after without --target', async () => { + const command: any = await createCommand( + {'site-id': 'RefArch', position: 'before'}, + {cartridge: 'my_cartridge'}, + ); + stubCommon(command, {jsonEnabled: false, siteId: 'RefArch', position: 'before'}); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.be.true; + expect(errorStub.firstCall.args[0]).to.include('--target'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts new file mode 100644 index 00000000..3fece33d --- /dev/null +++ b/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {ux} from '@oclif/core'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import SitesCartridgesList from '../../../../src/commands/sites/cartridges/list.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('sites cartridges list', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}) { + return createTestCommand(SitesCartridgesList, hooks.getConfig(), flags); + } + + function stubCommon(command: any, {jsonEnabled, siteId}: {jsonEnabled: boolean; siteId?: string}) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + if (siteId) { + sinon.stub(command, 'flags').get(() => ({'site-id': siteId, bm: false})); + } + } + + it('returns CartridgePathResult in JSON mode', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}); + stubCommon(command, {jsonEnabled: true, siteId: 'RefArch'}); + + const ocapiGet = sinon.stub().resolves({ + data: {id: 'RefArch', cartridges: 'cart_a:cart_b'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const result = await command.run(); + expect(result.siteId).to.equal('RefArch'); + expect(result.cartridgeList).to.deep.equal(['cart_a', 'cart_b']); + }); + + it('prints numbered list in non-JSON mode', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}); + stubCommon(command, {jsonEnabled: false, siteId: 'RefArch'}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({ + data: {id: 'RefArch', cartridges: 'cart_a:cart_b'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); + + await command.run(); + // Header + 2 cartridge lines + expect(stdoutStub.callCount).to.equal(3); + expect(stdoutStub.secondCall.args[0]).to.include('1. cart_a'); + expect(stdoutStub.thirdCall.args[0]).to.include('2. cart_b'); + }); + + it('resolves --bm to Sites-Site', async () => { + const command: any = await createCommand({bm: true}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'flags').get(() => ({'site-id': undefined, bm: true})); + + const ocapiGet = sinon.stub().resolves({ + data: {id: 'Sites-Site', cartridges: 'bm_cart'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const result = await command.run(); + expect(result.siteId).to.equal('Sites-Site'); + expect(ocapiGet.firstCall.args[0]).to.equal('/sites/{site_id}'); + expect(ocapiGet.firstCall.args[1].params.path.site_id).to.equal('Sites-Site'); + }); + + it('errors when neither --site-id nor --bm provided', async () => { + const command: any = await createCommand(); + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'flags').get(() => ({'site-id': undefined, bm: false})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.be.true; + expect(errorStub.firstCall.args[0]).to.include('--site-id'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts new file mode 100644 index 00000000..06b0020c --- /dev/null +++ b/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * 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 SitesCartridgesRemove from '../../../../src/commands/sites/cartridges/remove.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('sites cartridges remove', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(SitesCartridgesRemove, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any, {jsonEnabled, siteId}: {jsonEnabled: boolean; siteId: string}) { + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + sinon.stub(command, 'flags').get(() => ({'site-id': siteId, bm: false})); + sinon.stub(command, 'args').get(() => ({cartridge: 'old_cart'})); + } + + it('removes cartridge via OCAPI DELETE', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridge: 'old_cart'}); + stubCommon(command, {jsonEnabled: true, siteId: 'RefArch'}); + + const ocapiDelete = sinon.stub().resolves({ + data: {cartridges: 'cart_a', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + const result = await command.run(); + expect(result.cartridges).to.equal('cart_a'); + expect(ocapiDelete.calledOnce).to.be.true; + }); + + it('calls assertDestructiveOperationAllowed', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridge: 'old_cart'}); + const destructiveStub = sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'flags').get(() => ({'site-id': 'RefArch', bm: false})); + sinon.stub(command, 'args').get(() => ({cartridge: 'old_cart'})); + + const ocapiDelete = sinon.stub().resolves({ + data: {cartridges: 'cart_a', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + await command.run(); + expect(destructiveStub.calledOnce).to.be.true; + }); + + it('prints success message in non-JSON mode', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridge: 'old_cart'}); + stubCommon(command, {jsonEnabled: false, siteId: 'RefArch'}); + + const ocapiDelete = sinon.stub().resolves({ + data: {cartridges: 'cart_a', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + const logStub = sinon.stub(command, 'log'); + + await command.run(); + expect(logStub.callCount).to.be.at.least(1); + expect(logStub.firstCall.args[0]).to.include('Removed'); + }); +}); diff --git a/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts new file mode 100644 index 00000000..c7c94300 --- /dev/null +++ b/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * 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 SitesCartridgesSet from '../../../../src/commands/sites/cartridges/set.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('sites cartridges set', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(SitesCartridgesSet, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any, {jsonEnabled, siteId}: {jsonEnabled: boolean; siteId: string}) { + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + sinon.stub(command, 'flags').get(() => ({'site-id': siteId, bm: false})); + sinon.stub(command, 'args').get(() => ({cartridges: 'new_cart1:new_cart2'})); + } + + it('sets cartridge path via OCAPI PUT', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridges: 'new_cart1:new_cart2'}); + stubCommon(command, {jsonEnabled: true, siteId: 'RefArch'}); + + const ocapiPut = sinon.stub().resolves({ + data: {cartridges: 'new_cart1:new_cart2', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + + const result = await command.run(); + expect(result.cartridges).to.equal('new_cart1:new_cart2'); + expect(result.cartridgeList).to.deep.equal(['new_cart1', 'new_cart2']); + expect(ocapiPut.calledOnce).to.be.true; + }); + + it('calls assertDestructiveOperationAllowed', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridges: 'cart1'}); + const destructiveStub = sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'flags').get(() => ({'site-id': 'RefArch', bm: false})); + sinon.stub(command, 'args').get(() => ({cartridges: 'cart1'})); + + const ocapiPut = sinon.stub().resolves({ + data: {cartridges: 'cart1', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + + await command.run(); + expect(destructiveStub.calledOnce).to.be.true; + }); + + it('prints success message in non-JSON mode', async () => { + const command: any = await createCommand({'site-id': 'RefArch'}, {cartridges: 'cart1:cart2'}); + stubCommon(command, {jsonEnabled: false, siteId: 'RefArch'}); + + const ocapiPut = sinon.stub().resolves({ + data: {cartridges: 'cart1:cart2', site_id: 'RefArch'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + + const logStub = sinon.stub(command, 'log'); + + await command.run(); + expect(logStub.callCount).to.be.at.least(1); + expect(logStub.firstCall.args[0]).to.include('updated'); + }); +}); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index fbd49ec3..fb66cf7e 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -156,6 +156,17 @@ "default": "./dist/cjs/operations/bm-roles/index.js" } }, + "./operations/sites": { + "development": "./src/operations/sites/index.ts", + "import": { + "types": "./dist/esm/operations/sites/index.d.ts", + "default": "./dist/esm/operations/sites/index.js" + }, + "require": { + "types": "./dist/cjs/operations/sites/index.d.ts", + "default": "./dist/cjs/operations/sites/index.js" + } + }, "./operations/orgs": { "development": "./src/operations/orgs/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts new file mode 100644 index 00000000..77173f5e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts @@ -0,0 +1,512 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Site cartridge path operations for B2C Commerce instances. + * + * Provides functions for managing the ordered list of active cartridges + * on a site via OCAPI Data API, with automatic fallback to site archive + * import/export when OCAPI permissions are unavailable. + */ +import JSZip from 'jszip'; +import type {B2CInstance} from '../../instance/index.js'; +import type {components} from '../../clients/ocapi.generated.js'; +import {getApiErrorMessage} from '../../clients/error-utils.js'; +import {getLogger} from '../../logging/logger.js'; +import {siteArchiveImport, siteArchiveExportToBuffer} from '../jobs/site-archive.js'; +import type {WaitForJobOptions} from '../jobs/run.js'; + +/** The special site ID for Business Manager. */ +export const BM_SITE_ID = 'Sites-Site'; + +/** Position options for adding a cartridge. */ +export type CartridgePosition = 'first' | 'last' | 'before' | 'after'; + +/** Options for adding a cartridge to a site's cartridge path. */ +export interface AddCartridgeOptions { + /** Cartridge name to add. */ + name: string; + /** Position to add the cartridge (default: 'first'). */ + position: CartridgePosition; + /** Target cartridge name (required when position is 'before' or 'after'). */ + target?: string; +} + +/** Options for cartridge path update operations that may run jobs. */ +export interface CartridgeUpdateOptions { + /** Callback for operation-level status messages (e.g. "Exporting site preferences..."). */ + log?: (message: string) => void; + /** Wait options for underlying job execution (polling interval, timeout, progress). */ + waitOptions?: WaitForJobOptions; +} + +/** Result of a cartridge path operation. */ +export interface CartridgePathResult { + /** Site ID. */ + siteId: string; + /** Colon-separated cartridge path string. */ + cartridges: string; + /** Cartridge names as an ordered array. */ + cartridgeList: string[]; +} + +type CartridgePathApiResponse = components['schemas']['cartridge_path_api_response']; + +/** + * Parses a colon-separated cartridge path string into a CartridgePathResult. + */ +function toResult(siteId: string, cartridges: string): CartridgePathResult { + const trimmed = cartridges.trim(); + return { + siteId, + cartridges: trimmed, + cartridgeList: trimmed ? trimmed.split(':') : [], + }; +} + +/** + * Gets the cartridge path for a site. + * + * Uses OCAPI `GET /sites/{site_id}` to read the cartridge path. + * Works for all sites including Business Manager (Sites-Site). + * + * @param instance - B2C instance to query + * @param siteId - Site ID (e.g. 'RefArch', 'Sites-Site') + * @returns Cartridge path result + * + * @example + * ```typescript + * const result = await getCartridgePath(instance, 'RefArch'); + * console.log(result.cartridgeList); // ['app_storefront_base', 'plugin_applepay'] + * + * // Business Manager + * const bmResult = await getCartridgePath(instance, 'Sites-Site'); + * ``` + */ +export async function getCartridgePath(instance: B2CInstance, siteId: string): Promise { + const {data, error, response} = await instance.ocapi.GET('/sites/{site_id}', { + params: {path: {site_id: siteId}}, + }); + + if (error) { + throw new Error(`Failed to get cartridge path for site "${siteId}": ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + const site = data as components['schemas']['site']; + return toResult(siteId, site.cartridges ?? ''); +} + +/** + * Adds a cartridge to a site's cartridge path. + * + * For regular sites, tries OCAPI `POST /sites/{site_id}/cartridges` first, + * falling back to site archive import if OCAPI permissions are unavailable. + * For Business Manager (Sites-Site), always uses site archive import. + * + * @param instance - B2C instance + * @param siteId - Site ID + * @param options - Cartridge name, position, and optional target + * @returns Updated cartridge path + * + * @example + * ```typescript + * // Add to beginning (default) + * await addCartridge(instance, 'RefArch', { name: 'my_cartridge', position: 'first' }); + * + * // Add before a specific cartridge + * await addCartridge(instance, 'RefArch', { + * name: 'my_cartridge', position: 'before', target: 'app_storefront_base' + * }); + * + * // Business Manager + * await addCartridge(instance, 'Sites-Site', { name: 'bm_ext', position: 'first' }); + * ``` + */ +export async function addCartridge( + instance: B2CInstance, + siteId: string, + options: AddCartridgeOptions, + updateOptions?: CartridgeUpdateOptions, +): 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'); + return addCartridgeViaImport(instance, siteId, options, updateOptions); + } + + // Try OCAPI first for regular sites + try { + const {data, error, response} = await instance.ocapi.POST('/sites/{site_id}/cartridges', { + params: {path: {site_id: siteId}}, + body: options as components['schemas']['cartridge_path_add_request'], + }); + + if (error) { + throw new OcapiError(getApiErrorMessage(error, response), response.status); + } + + const result = data as CartridgePathApiResponse; + return toResult(siteId, result.cartridges ?? ''); + } catch (ocapiError) { + return handleFallback(instance, siteId, 'add', ocapiError, () => + addCartridgeViaImport(instance, siteId, options, updateOptions), + ); + } +} + +/** + * Removes a cartridge from a site's cartridge path. + * + * For regular sites, tries OCAPI `DELETE /sites/{site_id}/cartridges/{cartridge_name}` + * first, falling back to site archive import if OCAPI permissions are unavailable. + * For Business Manager (Sites-Site), always uses site archive import. + * + * @param instance - B2C instance + * @param siteId - Site ID + * @param cartridgeName - Name of the cartridge to remove + * @returns Updated cartridge path + * + * @example + * ```typescript + * await removeCartridge(instance, 'RefArch', 'old_cartridge'); + * ``` + */ +export async function removeCartridge( + instance: B2CInstance, + siteId: string, + cartridgeName: string, + updateOptions?: CartridgeUpdateOptions, +): Promise { + const logger = getLogger(); + + if (siteId === BM_SITE_ID) { + logger.debug({siteId}, 'Business Manager site — using site archive import for cartridge remove'); + return removeCartridgeViaImport(instance, siteId, cartridgeName, updateOptions); + } + + try { + const {data, error, response} = await instance.ocapi.DELETE('/sites/{site_id}/cartridges/{cartridge_name}', { + params: {path: {site_id: siteId, cartridge_name: cartridgeName}}, + }); + + if (error) { + throw new OcapiError(getApiErrorMessage(error, response), response.status); + } + + const result = data as CartridgePathApiResponse; + return toResult(siteId, result.cartridges ?? ''); + } catch (ocapiError) { + return handleFallback(instance, siteId, 'remove', ocapiError, () => + removeCartridgeViaImport(instance, siteId, cartridgeName, updateOptions), + ); + } +} + +/** + * Replaces the entire cartridge path for a site. + * + * For regular sites, tries OCAPI `PUT /sites/{site_id}/cartridges` first, + * falling back to site archive import if OCAPI permissions are unavailable. + * For Business Manager (Sites-Site), always uses site archive import. + * + * @param instance - B2C instance + * @param siteId - Site ID + * @param cartridges - New cartridge path (colon-separated string) + * @returns Updated cartridge path + * + * @example + * ```typescript + * await setCartridgePath(instance, 'RefArch', 'app_storefront_base:plugin_applepay'); + * ``` + */ +export async function setCartridgePath( + instance: B2CInstance, + siteId: string, + cartridges: string, + updateOptions?: CartridgeUpdateOptions, +): Promise { + const logger = getLogger(); + + if (siteId === BM_SITE_ID) { + logger.debug({siteId}, 'Business Manager site — using site archive import for cartridge set'); + return setCartridgePathViaImport(instance, siteId, cartridges, updateOptions); + } + + try { + const {data, error, response} = await instance.ocapi.PUT('/sites/{site_id}/cartridges', { + params: {path: {site_id: siteId}}, + body: {cartridges} as components['schemas']['cartridge_path_create_request'], + }); + + if (error) { + throw new OcapiError(getApiErrorMessage(error, response), response.status); + } + + const result = data as CartridgePathApiResponse; + return toResult(siteId, result.cartridges ?? ''); + } catch (ocapiError) { + return handleFallback(instance, siteId, 'set', ocapiError, () => + setCartridgePathViaImport(instance, siteId, cartridges, updateOptions), + ); + } +} + +// --------------------------------------------------------------------------- +// Internal: OCAPI error wrapper +// --------------------------------------------------------------------------- + +class OcapiError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = 'OcapiError'; + this.statusCode = statusCode; + } +} + +// --------------------------------------------------------------------------- +// Internal: Fallback handler +// --------------------------------------------------------------------------- + +async function handleFallback( + instance: B2CInstance, + siteId: string, + operation: string, + ocapiError: unknown, + fallbackFn: () => Promise, +): Promise { + const logger = getLogger(); + const ocapiMessage = ocapiError instanceof Error ? ocapiError.message : String(ocapiError); + + logger.warn( + {siteId, operation, error: ocapiMessage}, + `OCAPI ${operation} failed, trying site archive import fallback`, + ); + + try { + return await fallbackFn(); + } catch (importError) { + const importMessage = importError instanceof Error ? importError.message : String(importError); + throw new Error( + [ + `Failed to ${operation} cartridge path for site "${siteId}".`, + '', + `OCAPI direct update failed: ${ocapiMessage}`, + `Site archive import fallback also failed: ${importMessage}`, + '', + 'To fix, configure one of:', + ' • 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', + ].join('\n'), + {cause: importError}, + ); + } +} + +// --------------------------------------------------------------------------- +// Internal: Import/export-based operations +// --------------------------------------------------------------------------- + +/** + * Reads the current cartridge path via site archive export. + * Used as a fallback when OCAPI GET also fails. + */ +async function getCartridgePathViaExport( + instance: B2CInstance, + siteId: string, + updateOptions?: CartridgeUpdateOptions, +): Promise { + const logger = getLogger(); + const {log, waitOptions} = updateOptions ?? {}; + logger.debug({siteId}, 'Reading cartridge path via site archive export'); + log?.(`Exporting ${siteId === BM_SITE_ID ? 'organization preferences' : 'site descriptor'} to read cartridge path...`); + + if (siteId === BM_SITE_ID) { + const result = await siteArchiveExportToBuffer(instance, {global_data: {preferences: true}}, {waitOptions}); + const zip = await JSZip.loadAsync(result.data); + const prefsXml = await findFileInZip(zip, 'preferences.xml'); + if (!prefsXml) { + throw new Error('preferences.xml not found in export archive'); + } + return parseBmCartridgesFromPreferencesXml(prefsXml); + } + + const result = await siteArchiveExportToBuffer(instance, {sites: {[siteId]: {site_descriptor: true}}}, {waitOptions}); + const zip = await JSZip.loadAsync(result.data); + const descriptorXml = await findFileInZip(zip, 'site.xml'); + if (!descriptorXml) { + throw new Error(`site.xml not found in export archive for site "${siteId}"`); + } + return parseSiteCartridgesFromDescriptorXml(descriptorXml); +} + +async function setCartridgePathViaImport( + instance: B2CInstance, + siteId: string, + cartridges: string, + updateOptions?: CartridgeUpdateOptions, +): Promise { + const logger = getLogger(); + const {log, waitOptions} = updateOptions ?? {}; + logger.debug({siteId, cartridges}, 'Setting cartridge path via site archive import'); + log?.('Importing updated cartridge path...'); + + const zip = new JSZip(); + + if (siteId === BM_SITE_ID) { + zip.file('preferences.xml', generateBmPreferencesXml(cartridges)); + } else { + const sitesFolder = zip.folder(`sites/${siteId}`)!; + sitesFolder.file('site.xml', generateSiteDescriptorXml(siteId, cartridges)); + } + + const buffer = await zip.generateAsync({type: 'nodebuffer', compression: 'DEFLATE'}); + await siteArchiveImport(instance, buffer, {waitOptions}); + + return toResult(siteId, cartridges); +} + +async function addCartridgeViaImport( + instance: B2CInstance, + siteId: string, + options: AddCartridgeOptions, + updateOptions?: CartridgeUpdateOptions, +): Promise { + // Read current path + let currentPath: string; + try { + const result = await getCartridgePath(instance, siteId); + currentPath = result.cartridges; + } catch { + currentPath = await getCartridgePathViaExport(instance, siteId, updateOptions); + } + + const cartridgeList = currentPath ? currentPath.split(':') : []; + + // Check if already exists + if (cartridgeList.includes(options.name)) { + throw new Error(`Cartridge "${options.name}" already exists in the cartridge path for site "${siteId}"`); + } + + // Apply position logic + const newList = applyCartridgePosition(cartridgeList, options); + + return setCartridgePathViaImport(instance, siteId, newList.join(':'), updateOptions); +} + +async function removeCartridgeViaImport( + instance: B2CInstance, + siteId: string, + cartridgeName: string, + updateOptions?: CartridgeUpdateOptions, +): Promise { + let currentPath: string; + try { + const result = await getCartridgePath(instance, siteId); + currentPath = result.cartridges; + } catch { + currentPath = await getCartridgePathViaExport(instance, siteId, updateOptions); + } + + const cartridgeList = currentPath ? currentPath.split(':') : []; + const index = cartridgeList.indexOf(cartridgeName); + if (index === -1) { + throw new Error(`Cartridge "${cartridgeName}" not found in the cartridge path for site "${siteId}"`); + } + + cartridgeList.splice(index, 1); + return setCartridgePathViaImport(instance, siteId, cartridgeList.join(':'), updateOptions); +} + +// --------------------------------------------------------------------------- +// Internal: Cartridge position logic +// --------------------------------------------------------------------------- + +function applyCartridgePosition(cartridgeList: string[], options: AddCartridgeOptions): string[] { + const list = [...cartridgeList]; + const {name, position, target} = options; + + switch (position) { + case 'first': + list.unshift(name); + break; + case 'last': + list.push(name); + break; + case 'before': { + const idx = list.indexOf(target!); + if (idx === -1) { + throw new Error(`Target cartridge "${target}" not found in the cartridge path`); + } + list.splice(idx, 0, name); + break; + } + case 'after': { + const idx = list.indexOf(target!); + if (idx === -1) { + throw new Error(`Target cartridge "${target}" not found in the cartridge path`); + } + list.splice(idx + 1, 0, name); + break; + } + } + + return list; +} + +// --------------------------------------------------------------------------- +// Internal: XML generation and parsing +// --------------------------------------------------------------------------- + +function generateBmPreferencesXml(cartridges: string): string { + return ` + + + + ${escapeXml(cartridges)} + + + +`; +} + +function generateSiteDescriptorXml(siteId: string, cartridges: string): string { + return ` + + ${escapeXml(cartridges)} + +`; +} + +function parseBmCartridgesFromPreferencesXml(xml: string): string { + // Extract CustomCartridges preference value + const match = xml.match(/]*>([^<]*)<\/preference>/); + return match?.[1]?.trim() ?? ''; +} + +function parseSiteCartridgesFromDescriptorXml(xml: string): string { + // Extract cartridges element value + const match = xml.match(/([^<]*)<\/cartridges>/); + return match?.[1]?.trim() ?? ''; +} + +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +async function findFileInZip(zip: JSZip, filename: string): Promise { + for (const [path, entry] of Object.entries(zip.files)) { + if (!entry.dir && path.endsWith(`/${filename}`)) { + return entry.async('text'); + } + } + return null; +} diff --git a/packages/b2c-tooling-sdk/src/operations/sites/index.ts b/packages/b2c-tooling-sdk/src/operations/sites/index.ts new file mode 100644 index 00000000..45c2d83d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Site operations for B2C Commerce instances. + * + * This module provides functions for managing site cartridge paths + * on B2C Commerce instances. Operations work via OCAPI Data API with + * automatic fallback to site archive import/export when OCAPI permissions + * are unavailable. Business Manager (Sites-Site) is supported via the + * import/export mechanism. + * + * ## Cartridge Path Functions + * + * - {@link getCartridgePath} - Get the current cartridge path for a site + * - {@link addCartridge} - Add a cartridge at a specific position + * - {@link removeCartridge} - Remove a cartridge from the path + * - {@link setCartridgePath} - Replace the entire cartridge path + * + * ## Usage + * + * ```typescript + * import {getCartridgePath, addCartridge, setCartridgePath} from '@salesforce/b2c-tooling-sdk/operations/sites'; + * import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig(); + * const instance = config.createB2CInstance(); + * + * // List cartridge path + * const result = await getCartridgePath(instance, 'RefArch'); + * console.log(result.cartridgeList); + * + * // Add a cartridge + * await addCartridge(instance, 'RefArch', { name: 'my_cartridge', position: 'first' }); + * + * // Business Manager + * await addCartridge(instance, 'Sites-Site', { name: 'bm_ext', position: 'first' }); + * ``` + * + * ## Authentication + * + * Cartridge path operations require OAuth authentication. For OCAPI direct updates, + * grant POST/PUT/DELETE on `/sites/∗/cartridges`. For import/export fallback, + * grant job execution permissions and WebDAV write access. + * + * @module operations/sites + */ +export {getCartridgePath, addCartridge, removeCartridge, setCartridgePath, BM_SITE_ID} from './cartridges.js'; + +export type { + CartridgePathResult, + AddCartridgeOptions, + CartridgePosition, + CartridgeUpdateOptions, +} from './cartridges.js'; diff --git a/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts new file mode 100644 index 00000000..8567bf94 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/sites/cartridges.test.ts @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import JSZip from 'jszip'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {WebDavClient} from '../../../src/clients/webdav.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import { + getCartridgePath, + addCartridge, + removeCartridge, + setCartridgePath, +} from '../../../src/operations/sites/cartridges.js'; + +const TEST_HOST = 'test.demandware.net'; +const BASE_URL = `https://${TEST_HOST}/s/-/dw/data/v25_6`; +const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; + +/** + * Creates MSW handlers for site archive import (upload zip + execute job + poll + cleanup). + * The onImport callback receives the uploaded zip buffer so tests can inspect it. + */ +function createImportHandlers(onImport?: (buffer: Buffer) => void) { + return [ + // WebDAV PUT - upload archive + http.put(`${WEBDAV_BASE}/Impex/src/instance/:filename`, async ({request}) => { + if (onImport) { + const buffer = Buffer.from(await request.arrayBuffer()); + onImport(buffer); + } + return new HttpResponse(null, {status: 201}); + }), + // Job execution - start import + http.post(`${BASE_URL}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({id: 'exec-1', execution_status: 'running'}); + }), + // Job polling - complete immediately + http.get(`${BASE_URL}/jobs/sfcc-site-archive-import/executions/exec-1`, () => { + return HttpResponse.json({ + id: 'exec-1', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + // WebDAV DELETE - cleanup archive + http.delete(`${WEBDAV_BASE}/Impex/src/instance/:filename`, () => { + return new HttpResponse(null, {status: 204}); + }), + ]; +} + +describe('operations/sites/cartridges', () => { + const server = setupServer(); + let mockInstance: any; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + const auth = new MockAuthStrategy(); + const ocapi = createOcapiClient(TEST_HOST, auth); + const webdav = new WebDavClient(TEST_HOST, auth); + mockInstance = {ocapi, webdav}; + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('getCartridgePath', () => { + it('should return cartridge path from site details', async () => { + server.use( + http.get(`${BASE_URL}/sites/RefArch`, () => { + return HttpResponse.json({id: 'RefArch', cartridges: 'app_storefront_base:plugin_applepay'}); + }), + ); + + const result = await getCartridgePath(mockInstance, 'RefArch'); + + expect(result.siteId).to.equal('RefArch'); + expect(result.cartridges).to.equal('app_storefront_base:plugin_applepay'); + expect(result.cartridgeList).to.deep.equal(['app_storefront_base', 'plugin_applepay']); + }); + + it('should handle empty cartridge path', async () => { + server.use( + http.get(`${BASE_URL}/sites/RefArch`, () => { + return HttpResponse.json({id: 'RefArch'}); + }), + ); + + const result = await getCartridgePath(mockInstance, 'RefArch'); + + expect(result.cartridges).to.equal(''); + expect(result.cartridgeList).to.deep.equal([]); + }); + + it('should work for Business Manager site', async () => { + server.use( + http.get(`${BASE_URL}/sites/Sites-Site`, () => { + return HttpResponse.json({id: 'Sites-Site', cartridges: 'bm_app_storefront_base'}); + }), + ); + + const result = await getCartridgePath(mockInstance, 'Sites-Site'); + + expect(result.siteId).to.equal('Sites-Site'); + expect(result.cartridgeList).to.deep.equal(['bm_app_storefront_base']); + }); + + it('should throw on error', async () => { + server.use( + http.get(`${BASE_URL}/sites/BadSite`, () => { + return HttpResponse.json({fault: {message: 'Site not found'}}, {status: 404}); + }), + ); + + try { + await getCartridgePath(mockInstance, 'BadSite'); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Failed to get cartridge path'); + expect(error.message).to.include('BadSite'); + } + }); + }); + + describe('addCartridge', () => { + it('should add cartridge via OCAPI for regular sites', async () => { + server.use( + http.post(`${BASE_URL}/sites/RefArch/cartridges`, async ({request}) => { + const body = (await request.json()) as any; + expect(body.name).to.equal('my_cartridge'); + expect(body.position).to.equal('last'); + return HttpResponse.json({cartridges: 'app_storefront_base:my_cartridge', site_id: 'RefArch'}); + }), + ); + + const result = await addCartridge(mockInstance, 'RefArch', {name: 'my_cartridge', position: 'last'}); + + expect(result.cartridges).to.equal('app_storefront_base:my_cartridge'); + expect(result.cartridgeList).to.deep.equal(['app_storefront_base', 'my_cartridge']); + }); + + it('should use import/export for Business Manager', async () => { + let importedBuffer: Buffer | undefined; + + server.use( + // GET site to read current path + http.get(`${BASE_URL}/sites/Sites-Site`, () => { + return HttpResponse.json({id: 'Sites-Site', cartridges: 'existing_cart'}); + }), + ...createImportHandlers((buf) => { + importedBuffer = buf; + }), + ); + + const result = await addCartridge(mockInstance, 'Sites-Site', {name: 'new_cart', position: 'first'}); + + expect(result.cartridges).to.equal('new_cart:existing_cart'); + expect(importedBuffer).to.exist; + + // Verify the imported zip has BM preferences XML + const zip = await JSZip.loadAsync(importedBuffer!); + const files = Object.keys(zip.files); + const prefsFile = files.find((f) => f.endsWith('preferences.xml')); + expect(prefsFile).to.exist; + const prefsXml = await zip.file(prefsFile!)!.async('text'); + expect(prefsXml).to.include('CustomCartridges'); + expect(prefsXml).to.include('new_cart:existing_cart'); + }); + + it('should fall back to import/export when OCAPI fails', async () => { + server.use( + // OCAPI POST fails with 403 + http.post(`${BASE_URL}/sites/RefArch/cartridges`, () => { + return HttpResponse.json({fault: {message: 'Access denied'}}, {status: 403}); + }), + // Fallback reads current path via GET + http.get(`${BASE_URL}/sites/RefArch`, () => { + return HttpResponse.json({id: 'RefArch', cartridges: 'cart_a:cart_b'}); + }), + ...createImportHandlers(), + ); + + const result = await addCartridge(mockInstance, 'RefArch', {name: 'cart_c', position: 'last'}); + + expect(result.cartridges).to.equal('cart_a:cart_b:cart_c'); + }); + }); + + describe('removeCartridge', () => { + it('should remove cartridge via OCAPI for regular sites', async () => { + server.use( + http.delete(`${BASE_URL}/sites/RefArch/cartridges/old_cart`, () => { + return HttpResponse.json({cartridges: 'cart_a', site_id: 'RefArch'}); + }), + ); + + const result = await removeCartridge(mockInstance, 'RefArch', 'old_cart'); + + expect(result.cartridges).to.equal('cart_a'); + }); + + it('should use import/export for Business Manager', async () => { + server.use( + http.get(`${BASE_URL}/sites/Sites-Site`, () => { + return HttpResponse.json({id: 'Sites-Site', cartridges: 'cart_a:cart_b'}); + }), + ...createImportHandlers(), + ); + + const result = await removeCartridge(mockInstance, 'Sites-Site', 'cart_a'); + + expect(result.cartridges).to.equal('cart_b'); + }); + + it('should throw when cartridge not found via import/export path', async () => { + server.use( + http.get(`${BASE_URL}/sites/Sites-Site`, () => { + return HttpResponse.json({id: 'Sites-Site', cartridges: 'cart_a:cart_b'}); + }), + ); + + try { + await removeCartridge(mockInstance, 'Sites-Site', 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('not found'); + expect(error.message).to.include('nonexistent'); + } + }); + }); + + describe('setCartridgePath', () => { + it('should set cartridge path via OCAPI for regular sites', async () => { + server.use( + http.put(`${BASE_URL}/sites/RefArch/cartridges`, async ({request}) => { + const body = (await request.json()) as any; + expect(body.cartridges).to.equal('new_cart1:new_cart2'); + return HttpResponse.json({cartridges: 'new_cart1:new_cart2', site_id: 'RefArch'}); + }), + ); + + const result = await setCartridgePath(mockInstance, 'RefArch', 'new_cart1:new_cart2'); + + expect(result.cartridges).to.equal('new_cart1:new_cart2'); + expect(result.cartridgeList).to.deep.equal(['new_cart1', 'new_cart2']); + }); + + it('should use import/export for Business Manager with correct XML', async () => { + let importedBuffer: Buffer | undefined; + + server.use( + ...createImportHandlers((buf) => { + importedBuffer = buf; + }), + ); + + const result = await setCartridgePath(mockInstance, 'Sites-Site', 'bm_cart1:bm_cart2'); + + expect(result.cartridges).to.equal('bm_cart1:bm_cart2'); + + const zip = await JSZip.loadAsync(importedBuffer!); + const files = Object.keys(zip.files); + const prefsFile = files.find((f) => f.endsWith('preferences.xml')); + const prefsXml = await zip.file(prefsFile!)!.async('text'); + expect(prefsXml).to.include('CustomCartridges'); + expect(prefsXml).to.include('bm_cart1:bm_cart2'); + }); + + it('should generate site descriptor XML for regular site fallback', async () => { + let importedBuffer: Buffer | undefined; + + server.use( + http.put(`${BASE_URL}/sites/RefArch/cartridges`, () => { + return HttpResponse.json({fault: {message: 'Access denied'}}, {status: 403}); + }), + ...createImportHandlers((buf) => { + importedBuffer = buf; + }), + ); + + await setCartridgePath(mockInstance, 'RefArch', 'cart1:cart2'); + + const zip = await JSZip.loadAsync(importedBuffer!); + const files = Object.keys(zip.files); + const siteXmlFile = files.find((f) => f.endsWith('site.xml')); + const siteXml = await zip.file(siteXmlFile!)!.async('text'); + expect(siteXml).to.include('site-id="RefArch"'); + expect(siteXml).to.include('cart1:cart2'); + }); + }); + + describe('addCartridge position logic (via BM import path)', () => { + // These tests use BM to exercise the internal position logic + // since BM always goes through the import/export path. + beforeEach(() => { + server.use( + http.get(`${BASE_URL}/sites/Sites-Site`, () => { + return HttpResponse.json({id: 'Sites-Site', cartridges: 'cart_a:cart_b:cart_c'}); + }), + ...createImportHandlers(), + ); + }); + + it('should add at first position', async () => { + const result = await addCartridge(mockInstance, 'Sites-Site', {name: 'new', position: 'first'}); + expect(result.cartridgeList).to.deep.equal(['new', 'cart_a', 'cart_b', 'cart_c']); + }); + + it('should add at last position', async () => { + const result = await addCartridge(mockInstance, 'Sites-Site', {name: 'new', position: 'last'}); + expect(result.cartridgeList).to.deep.equal(['cart_a', 'cart_b', 'cart_c', 'new']); + }); + + it('should add before target', async () => { + const result = await addCartridge(mockInstance, 'Sites-Site', { + name: 'new', + position: 'before', + target: 'cart_b', + }); + expect(result.cartridgeList).to.deep.equal(['cart_a', 'new', 'cart_b', 'cart_c']); + }); + + it('should add after target', async () => { + const result = await addCartridge(mockInstance, 'Sites-Site', { + name: 'new', + position: 'after', + target: 'cart_b', + }); + expect(result.cartridgeList).to.deep.equal(['cart_a', 'cart_b', 'new', 'cart_c']); + }); + + it('should throw when target not found', async () => { + try { + await addCartridge(mockInstance, 'Sites-Site', {name: 'new', position: 'before', target: 'nonexistent'}); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Target cartridge "nonexistent" not found'); + } + }); + + it('should throw when cartridge already exists', async () => { + try { + await addCartridge(mockInstance, 'Sites-Site', {name: 'cart_a', position: 'last'}); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('already exists'); + } + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index 59ed157f..9ad39b3d 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -88,8 +88,11 @@ b2c code delete See `b2c code --help` for a full list of available commands and options in the `code` topic. +> **Note:** `b2c code deploy` uploads cartridge *code* to an instance. To manage which cartridges are *active on a site* (the cartridge path), see the `b2c-cli:b2c-sites` skill for the `b2c sites cartridges` commands. + ## Related Skills +- `b2c-cli:b2c-sites` - Manage site cartridge paths (list, add, remove, set active cartridges) - `b2c-cli:b2c-scapi-custom` - Check Custom API registration status after deployment - `b2c-cli:b2c-webdav` - Low-level file operations (delete cartridges, list files) - `b2c:b2c-custom-api-development` - Creating Custom API endpoints diff --git a/skills/b2c-cli/skills/b2c-sites/SKILL.md b/skills/b2c-cli/skills/b2c-sites/SKILL.md index 54b24151..653c669c 100644 --- a/skills/b2c-cli/skills/b2c-sites/SKILL.md +++ b/skills/b2c-cli/skills/b2c-sites/SKILL.md @@ -1,11 +1,11 @@ --- name: b2c-sites -description: List and inspect storefront sites on B2C Commerce (SFCC/Demandware) instances with the b2c cli. Always reference when using the CLI to list storefront sites, find site IDs, or check site configuration. +description: List and manage storefront sites and cartridge paths on B2C Commerce (SFCC/Demandware) instances with the b2c cli. Always reference when using the CLI to list storefront sites, find site IDs, check site configuration, or manage the ordered list of active cartridges on a site or Business Manager. --- # B2C Sites Skill -Use the `b2c` CLI plugin to list and inspect storefront sites on Salesforce B2C Commerce instances. +Use the `b2c` CLI plugin to list and manage storefront sites on Salesforce B2C Commerce instances. > **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli sites list`). @@ -30,6 +30,41 @@ b2c sites list --instance production b2c sites list --debug ``` +### Cartridge Path Management + +Manage the ordered list of active cartridges on a site. The singular alias `sites cartridge` also works. + +```bash +# list the cartridge path for a storefront site +b2c sites cartridges list --site-id RefArch + +# list the Business Manager cartridge path +b2c sites cartridges list --bm + +# add a cartridge to the beginning of a site's path (default) +b2c sites cartridges add plugin_applepay --site-id RefArch + +# add a cartridge to the end +b2c sites cartridges add plugin_applepay --site-id RefArch --position last + +# add a cartridge after a specific cartridge +b2c sites cartridges add plugin_applepay --site-id RefArch --position after --target app_storefront_base + +# add a cartridge to Business Manager +b2c sites cartridges add bm_extension --bm --position first + +# remove a cartridge from a site +b2c sites cartridges remove old_cartridge --site-id RefArch + +# replace the entire cartridge path +b2c sites cartridges set "app_storefront_base:plugin_applepay:plugin_wishlists" --site-id RefArch + +# JSON output for automation +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. + ### More Commands See `b2c sites --help` for a full list of available commands and options in the `sites` topic. From 096fd9ec2c482081178359730fa888bc4b329d91 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 29 Mar 2026 17:23:59 -0400 Subject: [PATCH 2/2] linting --- .../src/commands/sites/cartridges/add.ts | 54 +++++++++---------- .../src/commands/sites/cartridges/list.ts | 7 +-- .../src/commands/sites/cartridges/remove.ts | 9 +--- .../src/commands/sites/cartridges/set.ts | 16 ++---- .../commands/sites/cartridges/add.test.ts | 6 +-- .../commands/sites/cartridges/list.test.ts | 1 + .../commands/sites/cartridges/remove.test.ts | 1 + .../commands/sites/cartridges/set.test.ts | 1 + .../src/operations/sites/cartridges.ts | 4 +- 9 files changed, 40 insertions(+), 59 deletions(-) diff --git a/packages/b2c-cli/src/commands/sites/cartridges/add.ts b/packages/b2c-cli/src/commands/sites/cartridges/add.ts index 614afa84..d219fc2e 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/add.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/add.ts @@ -15,7 +15,7 @@ import {t, withDocs} from '../../../i18n/index.js'; export default class SitesCartridgesAdd extends InstanceCommand { static description = withDocs( - t('commands.sites.cartridges.add.description', 'Add a cartridge to a site\'s cartridge path'), + t('commands.sites.cartridges.add.description', "Add a cartridge to a site's cartridge path"), '/cli/sites.html#b2c-sites-cartridges-add', ); @@ -67,32 +67,35 @@ export default class SitesCartridgesAdd extends InstanceCommand { - if (!this.jsonEnabled()) this.log(msg); - }, - waitOptions: { - onProgress: (exec, elapsed) => { - if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); - this.log( - t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), - }), - ); - } + const result = await addCartridge( + this.instance, + siteId, + {name: cartridge, position, target}, + { + log: (msg) => { + if (!this.jsonEnabled()) this.log(msg); + }, + waitOptions: { + onProgress: (exec, elapsed) => { + if (!this.jsonEnabled()) { + const elapsedSec = Math.floor(elapsed / 1000); + this.log( + t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: exec.execution_status, + elapsed: elapsedSec.toString(), + }), + ); + } + }, }, }, - }); + ); if (this.jsonEnabled()) { return result; @@ -118,12 +121,7 @@ export default class SitesCartridgesAdd extends InstanceCommand or --bm to specify a site.', - ), - ); + this.error(t('commands.sites.cartridges.siteIdRequired', 'Provide --site-id or --bm to specify a site.')); } return bm ? BM_SITE_ID : siteId!; diff --git a/packages/b2c-cli/src/commands/sites/cartridges/list.ts b/packages/b2c-cli/src/commands/sites/cartridges/list.ts index 87ea13dd..e3acc937 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/list.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/list.ts @@ -77,12 +77,7 @@ export default class SitesCartridgesList extends InstanceCommand or --bm to specify a site.', - ), - ); + this.error(t('commands.sites.cartridges.siteIdRequired', 'Provide --site-id or --bm to specify a site.')); } return bm ? BM_SITE_ID : siteId!; diff --git a/packages/b2c-cli/src/commands/sites/cartridges/remove.ts b/packages/b2c-cli/src/commands/sites/cartridges/remove.ts index 22d6ee90..d10dc2fd 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/remove.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/remove.ts @@ -10,7 +10,7 @@ import {t, withDocs} from '../../../i18n/index.js'; export default class SitesCartridgesRemove extends InstanceCommand { static description = withDocs( - t('commands.sites.cartridges.remove.description', 'Remove a cartridge from a site\'s cartridge path'), + t('commands.sites.cartridges.remove.description', "Remove a cartridge from a site's cartridge path"), '/cli/sites.html#b2c-sites-cartridges-remove', ); @@ -91,12 +91,7 @@ export default class SitesCartridgesRemove extends InstanceCommand or --bm to specify a site.', - ), - ); + this.error(t('commands.sites.cartridges.siteIdRequired', 'Provide --site-id or --bm to specify a site.')); } return bm ? BM_SITE_ID : siteId!; diff --git a/packages/b2c-cli/src/commands/sites/cartridges/set.ts b/packages/b2c-cli/src/commands/sites/cartridges/set.ts index 99a4642c..e2820571 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/set.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/set.ts @@ -25,10 +25,7 @@ export default class SitesCartridgesSet extends InstanceCommand or --bm to specify a site.', - ), - ); + this.error(t('commands.sites.cartridges.siteIdRequired', 'Provide --site-id or --bm to specify a site.')); } return bm ? BM_SITE_ID : siteId!; diff --git a/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts index 9ec52b7b..9e5c36c5 100644 --- a/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts +++ b/packages/b2c-cli/test/commands/sites/cartridges/add.test.ts @@ -13,6 +13,7 @@ describe('sites cartridges add', () => { const hooks = createIsolatedConfigHooks(); beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); async function createCommand(flags: Record = {}, args: Record = {}) { @@ -90,10 +91,7 @@ describe('sites cartridges add', () => { }); it('errors when position is before/after without --target', async () => { - const command: any = await createCommand( - {'site-id': 'RefArch', position: 'before'}, - {cartridge: 'my_cartridge'}, - ); + const command: any = await createCommand({'site-id': 'RefArch', position: 'before'}, {cartridge: 'my_cartridge'}); stubCommon(command, {jsonEnabled: false, siteId: 'RefArch', position: 'before'}); const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); diff --git a/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts index 3fece33d..6717f21c 100644 --- a/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts +++ b/packages/b2c-cli/test/commands/sites/cartridges/list.test.ts @@ -14,6 +14,7 @@ describe('sites cartridges list', () => { const hooks = createIsolatedConfigHooks(); beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); async function createCommand(flags: Record = {}) { diff --git a/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts index 06b0020c..517ffa31 100644 --- a/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts +++ b/packages/b2c-cli/test/commands/sites/cartridges/remove.test.ts @@ -13,6 +13,7 @@ describe('sites cartridges remove', () => { const hooks = createIsolatedConfigHooks(); beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); async function createCommand(flags: Record = {}, args: Record = {}) { diff --git a/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts b/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts index c7c94300..bac517ab 100644 --- a/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts +++ b/packages/b2c-cli/test/commands/sites/cartridges/set.test.ts @@ -13,6 +13,7 @@ describe('sites cartridges set', () => { const hooks = createIsolatedConfigHooks(); beforeEach(hooks.beforeEach); + afterEach(hooks.afterEach); async function createCommand(flags: Record = {}, args: Record = {}) { diff --git a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts index 77173f5e..f0559de3 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts @@ -327,7 +327,9 @@ async function getCartridgePathViaExport( const logger = getLogger(); const {log, waitOptions} = updateOptions ?? {}; logger.debug({siteId}, 'Reading cartridge path via site archive export'); - log?.(`Exporting ${siteId === BM_SITE_ID ? 'organization preferences' : 'site descriptor'} to read cartridge path...`); + log?.( + `Exporting ${siteId === BM_SITE_ID ? 'organization preferences' : 'site descriptor'} to read cartridge path...`, + ); if (siteId === BM_SITE_ID) { const result = await siteArchiveExportToBuffer(instance, {global_data: {preferences: true}}, {waitOptions});