From 78c67b9c61e8043fab2687d36a90c6062adf868d Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 11:36:25 -0500 Subject: [PATCH 1/8] adding custom API topic, commands and SDK support --- .../.github/workflows/onPushToMain.yml | 56 --- .../b2c-cli/.github/workflows/onRelease.yml | 21 - packages/b2c-cli/.github/workflows/test.yml | 23 - packages/b2c-cli/package.json | 8 + .../src/commands/scapi/custom/status.ts | 399 ++++++++++++++++++ .../test/commands/scapi/custom/status.test.ts | 20 + packages/b2c-tooling-sdk/package.json | 2 +- .../b2c-tooling-sdk/specs/custom-apis-v1.yaml | 309 ++++++++++++++ packages/b2c-tooling-sdk/src/auth/oauth.ts | 15 + .../src/clients/custom-apis.generated.ts | 203 +++++++++ .../src/clients/custom-apis.ts | 176 ++++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 19 + packages/b2c-tooling-sdk/src/index.ts | 13 + .../test/clients/custom-apis.test.ts | 169 ++++++++ .../b2c-cli/skills/b2c-scapi-custom/SKILL.md | 69 +++ 15 files changed, 1401 insertions(+), 101 deletions(-) delete mode 100644 packages/b2c-cli/.github/workflows/onPushToMain.yml delete mode 100644 packages/b2c-cli/.github/workflows/onRelease.yml delete mode 100644 packages/b2c-cli/.github/workflows/test.yml create mode 100644 packages/b2c-cli/src/commands/scapi/custom/status.ts create mode 100644 packages/b2c-cli/test/commands/scapi/custom/status.test.ts create mode 100644 packages/b2c-tooling-sdk/specs/custom-apis-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/custom-apis.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/custom-apis.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts create mode 100644 plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md diff --git a/packages/b2c-cli/.github/workflows/onPushToMain.yml b/packages/b2c-cli/.github/workflows/onPushToMain.yml deleted file mode 100644 index de7dda10..00000000 --- a/packages/b2c-cli/.github/workflows/onPushToMain.yml +++ /dev/null @@ -1,56 +0,0 @@ -# test -name: version, tag and github release - -on: - push: - branches: [main] - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - - name: Check if version already exists - id: version-check - run: | - package_version=$(node -p "require('./package.json').version") - exists=$(gh api repos/${{ github.repository }}/releases/tags/v$package_version >/dev/null 2>&1 && echo "true" || echo "") - - if [ -n "$exists" ]; - then - echo "Version v$package_version already exists" - echo "::warning file=package.json,line=1::Version v$package_version already exists - no release will be created. If you want to create a new release, please update the version in package.json and push again." - echo "skipped=true" >> $GITHUB_OUTPUT - else - echo "Version v$package_version does not exist. Creating release..." - echo "skipped=false" >> $GITHUB_OUTPUT - echo "tag=v$package_version" >> $GITHUB_OUTPUT - fi - env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} - - name: Setup git - if: ${{ steps.version-check.outputs.skipped == 'false' }} - run: | - git config --global user.email ${{ secrets.GH_EMAIL }} - git config --global user.name ${{ secrets.GH_USERNAME }} - - name: Generate oclif README - if: ${{ steps.version-check.outputs.skipped == 'false' }} - id: oclif-readme - run: | - pnpm install - pnpm exec oclif readme - if [ -n "$(git status --porcelain)" ]; then - git add . - git commit -am "chore: update README.md" - git push -u origin ${{ github.ref_name }} - fi - - name: Create Github Release - uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 - if: ${{ steps.version-check.outputs.skipped == 'false' }} - with: - name: ${{ steps.version-check.outputs.tag }} - tag: ${{ steps.version-check.outputs.tag }} - commit: ${{ github.ref_name }} - token: ${{ secrets.GH_TOKEN }} - skipIfReleaseExists: true diff --git a/packages/b2c-cli/.github/workflows/onRelease.yml b/packages/b2c-cli/.github/workflows/onRelease.yml deleted file mode 100644 index d7fb3e92..00000000 --- a/packages/b2c-cli/.github/workflows/onRelease.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: publish - -on: - release: - types: [released] - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: latest - - run: pnpm install - - run: pnpm run build - - run: pnpm run prepack - - uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c - with: - token: ${{ secrets.NPM_TOKEN }} - - run: pnpm run postpack diff --git a/packages/b2c-cli/.github/workflows/test.yml b/packages/b2c-cli/.github/workflows/test.yml deleted file mode 100644 index 848357dc..00000000 --- a/packages/b2c-cli/.github/workflows/test.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: tests -on: - push: - branches-ignore: [main] - workflow_dispatch: - -jobs: - unit-tests: - strategy: - matrix: - os: ['ubuntu-latest', 'windows-latest'] - node_version: [lts/-1, lts/*, latest] - fail-fast: false - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node_version }} - cache: pnpm - - run: pnpm install - - run: pnpm run build - - run: pnpm run test diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 528c7743..89e1c9cb 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -115,6 +115,14 @@ "description": "Manage SLAS client configurations" } } + }, + "scapi": { + "description": "Manage Salesforce Commerce APIs (SCAPI)", + "subtopics": { + "custom": { + "description": "Manage Custom API endpoints" + } + } } } }, diff --git a/packages/b2c-cli/src/commands/scapi/custom/status.ts b/packages/b2c-cli/src/commands/scapi/custom/status.ts new file mode 100644 index 00000000..de5c066c --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/custom/status.ts @@ -0,0 +1,399 @@ +/* + * 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 {Command, Flags, ux} from '@oclif/core'; +import {OAuthCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {createCustomApisClient, toOrganizationId, type CustomApisComponents} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +type CustomApiEndpoint = CustomApisComponents['schemas']['CustomApiEndpoint']; + +/** + * Maps security scheme to human-readable API type. + */ +function getApiType(securityScheme?: string): string { + switch (securityScheme) { + case 'AmOAuth2': { + return 'Admin'; + } + case 'ShopperToken': { + return 'Shopper'; + } + default: { + return securityScheme || '-'; + } + } +} + +/** + * Rolled-up endpoint with sites combined. + * Multiple endpoints with same API/version/path/method are grouped, with siteIds combined. + */ +interface RolledUpEndpoint extends Omit { + siteIds: string[]; + type: string; +} + +/** + * Creates a unique key for grouping endpoints. + */ +function getEndpointKey(endpoint: CustomApiEndpoint): string { + return [ + endpoint.apiName, + endpoint.apiVersion, + endpoint.cartridgeName, + endpoint.endpointPath, + endpoint.httpMethod, + endpoint.status, + endpoint.securityScheme, + ].join('|'); +} + +/** + * Rolls up endpoints by combining those with the same key into a single entry with multiple siteIds. + */ +function rollUpEndpoints(endpoints: CustomApiEndpoint[]): RolledUpEndpoint[] { + const grouped = new Map(); + + for (const endpoint of endpoints) { + const key = getEndpointKey(endpoint); + const existing = grouped.get(key); + + if (existing) { + // Add site to existing entry if not already present + if (endpoint.siteId && !existing.siteIds.includes(endpoint.siteId)) { + existing.siteIds.push(endpoint.siteId); + } + } else { + // Create new rolled-up entry + const {siteId, ...rest} = endpoint; + grouped.set(key, { + ...rest, + siteIds: siteId ? [siteId] : [], + type: getApiType(endpoint.securityScheme), + }); + } + } + + return [...grouped.values()]; +} + +/** + * Response type for the status command. + */ +interface CustomApiStatusResponse { + total: number; + activeCodeVersion?: string; + data: CustomApiEndpoint[]; +} + +const COLUMNS: Record> = { + type: { + header: 'Type', + get: (e) => e.type, + }, + apiName: { + header: 'API Name', + get: (e) => e.apiName || '-', + }, + apiVersion: { + header: 'Version', + get: (e) => e.apiVersion || '-', + }, + cartridgeName: { + header: 'Cartridge', + get: (e) => e.cartridgeName || '-', + }, + endpointPath: { + header: 'Path', + get: (e) => e.endpointPath || '-', + }, + httpMethod: { + header: 'Method', + get: (e) => e.httpMethod || '-', + }, + status: { + header: 'Status', + get: (e) => e.status || '-', + }, + sites: { + header: 'Sites', + get: (e) => (e.siteIds.length > 0 ? e.siteIds.join(', ') : '-'), + extended: true, + }, + securityScheme: { + header: 'Security', + get: (e) => e.securityScheme || '-', + extended: true, + }, + operationId: { + header: 'Operation ID', + get: (e) => e.operationId || '-', + extended: true, + }, + schemaFile: { + header: 'Schema File', + get: (e) => e.schemaFile || '-', + extended: true, + }, + implementationScript: { + header: 'Script', + get: (e) => e.implementationScript || '-', + extended: true, + }, + errorReason: { + header: 'Error Reason', + get: (e) => e.errorReason || '-', + extended: true, + }, + id: { + header: 'ID', + get: (e) => e.id || '-', + extended: true, + }, +}; + +/** Default columns shown without --extended */ +const DEFAULT_COLUMNS = ['type', 'apiName', 'endpointPath', 'httpMethod', 'status']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Base command for SCAPI Custom API operations. + */ +abstract class ScapiCustomCommand extends OAuthCommand { + static baseFlags = { + ...OAuthCommand.baseFlags, + 'tenant-id': Flags.string({ + description: 'Organization/tenant ID', + env: 'SFCC_TENANT_ID', + required: true, + }), + }; +} + +/** + * Command to get the status of Custom API endpoints. + */ +export default class ScapiCustomStatus extends ScapiCustomCommand { + static description = t('commands.scapi.custom.status.description', 'Get the status of Custom API endpoints'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --status active', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --group-by type', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --group-by site', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --extended', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --columns type,apiName,status,sites', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --json', + ]; + + static flags = { + ...ScapiCustomCommand.baseFlags, + status: Flags.string({ + char: 's', + description: 'Filter by endpoint status', + options: ['active', 'not_registered'], + }), + 'group-by': Flags.string({ + char: 'g', + description: 'Group output by field (type or site)', + options: ['type', 'site'], + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns including extended fields', + default: false, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {'tenant-id': tenantId, status, 'group-by': groupBy} = this.flags; + const {shortCode} = this.resolvedConfig; + + if (!shortCode) { + this.error( + t( + 'error.shortCodeRequired', + 'SCAPI short code required. Provide --short-code, set SFCC_SHORTCODE, or configure short-code in dw.json.', + ), + ); + } + + if (!this.jsonEnabled()) { + this.log( + t('commands.scapi.custom.status.fetching', 'Fetching Custom API endpoints for {{tenantId}}...', {tenantId}), + ); + } + + const oauthStrategy = this.getOAuthStrategy(); + const client = createCustomApisClient({shortCode, tenantId}, oauthStrategy); + + // Ensure organizationId has the required f_ecom_ prefix + const organizationId = toOrganizationId(tenantId); + + const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', { + params: { + path: {organizationId}, + query: status ? {status: status as 'active' | 'not_registered'} : undefined, + }, + }); + + if (error) { + this.error( + t('commands.scapi.custom.status.error', 'Failed to fetch Custom API endpoints: {{message}}', { + message: typeof error === 'object' ? JSON.stringify(error) : String(error), + }), + ); + } + + const endpoints = data?.data ?? []; + const response: CustomApiStatusResponse = { + total: data?.total ?? endpoints.length, + activeCodeVersion: data?.activeCodeVersion, + data: endpoints, + }; + + if (this.jsonEnabled()) { + return response; + } + + if (endpoints.length === 0) { + this.log(t('commands.scapi.custom.status.noEndpoints', 'No Custom API endpoints found.')); + return response; + } + + if (data?.activeCodeVersion) { + this.log( + t('commands.scapi.custom.status.codeVersion', 'Active code version: {{version}}', { + version: data.activeCodeVersion, + }), + ); + } + + // Roll up endpoints to combine duplicate entries with different siteIds + const rolledUp = rollUpEndpoints(endpoints); + + this.log( + t('commands.scapi.custom.status.count', 'Found {{count}} endpoint(s):', { + count: rolledUp.length, + }), + ); + this.log(''); + + // Render with optional grouping + this.renderEndpoints(rolledUp, groupBy as 'site' | 'type' | undefined); + + return response; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + // User specified explicit columns + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + // Show all columns + return tableRenderer.getColumnKeys(); + } + + // Default columns (non-extended) + return DEFAULT_COLUMNS; + } + + /** + * Groups endpoints by a key function. + */ + private groupEndpointsBy( + endpoints: RolledUpEndpoint[], + keyFn: (e: RolledUpEndpoint) => string, + ): Map { + const groups = new Map(); + for (const endpoint of endpoints) { + const key = keyFn(endpoint); + const group = groups.get(key) || []; + group.push(endpoint); + groups.set(key, group); + } + return groups; + } + + /** + * Renders endpoints, optionally grouped by type or site. + */ + private renderEndpoints(endpoints: RolledUpEndpoint[], groupBy?: 'site' | 'type'): void { + const columns = this.getSelectedColumns(); + + if (!groupBy) { + // No grouping - render flat table + tableRenderer.render(endpoints, columns); + return; + } + + if (groupBy === 'type') { + // Group by API type (Admin/Shopper) + const grouped = this.groupEndpointsBy(endpoints, (e) => e.type); + for (const [type, items] of grouped) { + ux.stdout(`${type} APIs:\n`); + tableRenderer.render( + items, + columns.filter((c) => c !== 'type'), + ); + ux.stdout('\n'); + } + } else if (groupBy === 'site') { + // Group by site - each endpoint may appear under multiple sites + const siteGroups = new Map(); + + for (const endpoint of endpoints) { + if (endpoint.siteIds.length === 0) { + // No site - put in "Global" group + const group = siteGroups.get('Global') || []; + group.push(endpoint); + siteGroups.set('Global', group); + } else { + for (const siteId of endpoint.siteIds) { + const group = siteGroups.get(siteId) || []; + group.push(endpoint); + siteGroups.set(siteId, group); + } + } + } + + // Sort site names + const sortedSites = [...siteGroups.keys()].sort(); + for (const site of sortedSites) { + const items = siteGroups.get(site)!; + ux.stdout(`Site: ${site}\n`); + tableRenderer.render( + items, + columns.filter((c) => c !== 'sites'), + ); + ux.stdout('\n'); + } + } + } +} diff --git a/packages/b2c-cli/test/commands/scapi/custom/status.test.ts b/packages/b2c-cli/test/commands/scapi/custom/status.test.ts new file mode 100644 index 00000000..a528d214 --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/custom/status.test.ts @@ -0,0 +1,20 @@ +/* + * 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 {runCommand} from '@oclif/test'; +import {expect} from 'chai'; + +describe('scapi custom status', () => { + it('shows help without errors', async () => { + const {error} = await runCommand('scapi custom status --help'); + expect(error).to.be.undefined; + }); + + it('requires tenant-id flag', async () => { + const {error} = await runCommand('scapi custom status'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('tenant-id'); + }); +}); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index e50a7d44..0fa2f6ad 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -154,7 +154,7 @@ "specs" ], "scripts": { - "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts", + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm && pnpm run build:cjs", "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/packages/b2c-tooling-sdk/specs/custom-apis-v1.yaml b/packages/b2c-tooling-sdk/specs/custom-apis-v1.yaml new file mode 100644 index 00000000..00b1cc4d --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/custom-apis-v1.yaml @@ -0,0 +1,309 @@ +openapi: 3.0.3 +info: + x-api-type: Admin + x-api-family: DX + title: Custom APIs + version: 1.0.1 + description: |- + [Download API specification](https://developer.salesforce.com/static/commercecloud/commerce-api/custom-apis/custom-apis-oas-v1-public.yaml) + + # API Overview + + The Custom APIs DX API provides enhanced transparency and manageability of Custom API endpoints within the platform. This API offers a user-friendly and secure approach by providing real-time feedback on endpoint registration status, including successful registrations and failed attempts with error reasons. + + ## Authentication & Authorization + + The Custom APIs DX endpoint requires valid authentication and authorization to be accessed. It uses an Account Manager OAuth 2.0 bearer token for authentication. The necessary scopes for accessing the API are: + + * `sfcc.custom-apis`: Provides read-only access. + * `sfcc.custom-apis.rw`: Provides read-write access. + + Access is intended for admin users only. + + ## Use Cases + + ### Endpoint Status Transparency + + During a new code version activation, developers and administrators often struggle to verify the status of all their Custom API endpoints. This can lead to confusion and time-consuming manual log checks to diagnose why an endpoint might not be active. By using the Custom APIs DX endpoint, a user can programmatically fetch a list of all endpoints and their current status (`active` or `not_registered`). This allows for quick identification of any registration issues, including specific error reasons for endpoints that failed to register, thereby streamlining the validation process after a deployment. + + ### Debugging Failed Registrations + + When a Custom API endpoint fails to register, it can be difficult to understand the root cause without accessible error details. This API directly addresses this by providing an `errorReason` property for any endpoint with a `not_registered` status. For example, if an endpoint registration is skipped because its schema file cannot be read, the API response will explicitly state this, allowing developers to quickly pinpoint and resolve the issue without needing to contact support or search through extensive log files. +servers: + - url: https://{shortCode}.api.commercecloud.salesforce.com/dx/custom-apis/v1 + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/endpoints: + get: + summary: Get a list of resolved Custom API endpoints. + description: List resolved Custom API endpoints, including not registered endpoints. + operationId: getEndpoints + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/status' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/CustomApiEndpointResult' + examples: + SuccessWithFilter: + $ref: '#/components/examples/SuccessWithFilter' + SuccessWithoutFilter: + $ref: '#/components/examples/SuccessWithoutFilter' + '400': + description: Invalid or malformed filter parameter + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + InvalidFilterParameter: + $ref: '#/components/examples/InvalidFilterParameter' + security: + - AmOAuth2: + - sfcc.custom-apis +components: + securitySchemes: + AmOAuth2: + type: oauth2 + description: AccountManager OAuth 2.0 bearer token Authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.custom-apis: Custom APIs READONLY scope + sfcc.custom-apis.rw: Custom APIs scope + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.custom-apis: Custom APIs READONLY scope + sfcc.custom-apis.rw: Custom APIs scope + schemas: + OrganizationId: + description: An identifier for the organization the request is being made by + example: f_ecom_zzxy_prd + type: string + minLength: 1 + maxLength: 32 + EndpointStatus: + type: string + enum: + - active + - not_registered + example: active + Limit: + default: 10 + minimum: 1 + format: int32 + description: Maximum records to retrieve per request, not to exceed the maximum defined. A limit must be at least 1 so at least one record is returned (if any match the criteria). + type: integer + example: 10 + Total: + default: 0 + minimum: 0 + format: int32 + description: The total number of hits that match the search's criteria. This can be greater than the number of results returned as search results are pagenated. + type: integer + example: 10 + ResultBase: + description: "Schema defining generic list result. Each response schema of a resource requiring a list response should extend this schema. \nAdditionally it needs to be defined what data is returned." + type: object + required: + - limit + - total + properties: + limit: + maximum: 200 + allOf: + - $ref: '#/components/schemas/Limit' + total: + $ref: '#/components/schemas/Total' + CustomApiEndpointFilter: + type: object + properties: + status: + type: string + enum: + - active + - not_registered + example: not_registered + SiteId: + minLength: 1 + maxLength: 32 + description: The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites + example: RefArch + type: string + CustomApiEndpoint: + type: object + properties: + apiName: + type: string + pattern: ^[a-z0-9-]+$ + example: loyalty-info + apiVersion: + type: string + maxLength: 100 + example: v1 + cartridgeName: + type: string + pattern: ^[a-zA-Z][a-zA-Z0-9_]*$ + example: test_bc_wapi + endpointPath: + type: string + maxLength: 4000 + example: /customers + errorReason: + type: string + maxLength: 4000 + example: API schema not found. + httpMethod: + type: string + enum: + - GET + - POST + - PUT + - DELETE + - PATCH + - OPTIONS + - HEAD + example: GET + id: + type: string + minLength: 36 + maxLength: 36 + example: 10bd7f2dc40ab7aede7f0d60e5c3a783 + implementationScript: + type: string + maxLength: 100 + example: script.js + operationId: + type: string + maxLength: 100 + example: getLoyaltyInfo + securityScheme: + type: string + enum: + - ShopperToken + - AmOAuth2 + example: ShopperToken + schemaFile: + type: string + maxLength: 100 + example: schema.yaml + siteId: + $ref: '#/components/schemas/SiteId' + status: + $ref: '#/components/schemas/EndpointStatus' + CustomApiEndpointResult: + type: object + allOf: + - $ref: '#/components/schemas/ResultBase' + properties: + filter: + $ref: '#/components/schemas/CustomApiEndpointFilter' + data: + type: array + items: + $ref: '#/components/schemas/CustomApiEndpoint' + activeCodeVersion: + type: string + minLength: 1 + maxLength: 100 + example: version1 + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + description: "A short, human-readable summary of the problem\ntype. It will not change from occurrence to occurrence of the \nproblem, except for purposes of localization\n" + type: string + maxLength: 256 + example: You do not have enough credit + type: + description: | + A URI reference [RFC3986] that identifies the + problem type. This specification encourages that, when + dereferenced, it provide human-readable documentation for the + problem type (e.g., using HTML [W3C.REC-html5-20141028]). When + this member is not present, its value is assumed to be + "about:blank". It accepts relative URIs; this means + that they must be resolved relative to the document's base URI, as + per [RFC3986], Section 5. + type: string + maxLength: 2048 + example: NotEnoughMoney + detail: + description: A human-readable explanation specific to this occurrence of the problem. + type: string + example: Your current balance is 30, but that costs 50 + instance: + description: | + A URI reference that identifies the specific + occurrence of the problem. It may or may not yield further + information if dereferenced. It accepts relative URIs; this means + that they must be resolved relative to the document's base URI, as + per [RFC3986], Section 5. + type: string + maxLength: 2048 + example: /account/12345/msgs/abc + required: + - title + - type + - detail + parameters: + organizationId: + description: An identifier for the organization the request is being made by + name: organizationId + in: path + required: true + example: f_ecom_zzxy_prd + schema: + $ref: '#/components/schemas/OrganizationId' + status: + name: status + in: query + required: false + schema: + $ref: '#/components/schemas/EndpointStatus' + examples: + SuccessWithFilter: + value: + limit: 1 + total: 2 + filter: + status: active + data: + - apiName: loyalty-info + apiVersion: v1 + cartridgeName: my_cartridge + endpointPath: /customers + status: active + activeCodeVersion: version1 + SuccessWithoutFilter: + value: + limit: 2 + total: 2 + data: + - apiName: loyalty-info + apiVersion: v1 + cartridgeName: my_cartridge + endpointPath: /customers + status: active + - apiName: loyalty-info + apiVersion: v1 + cartridgeName: 'null' + endpointPath: /customers + errorReason: Cartridge 'my_cartridge_2' not found. + status: not_registered + activeCodeVersion: version1 + InvalidFilterParameter: + value: + title: Bad Request + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/bad-request + detail: Invalid value 'foo' for filter parameter 'status'. diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index ec823daf..b5e1e39f 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -103,6 +103,21 @@ export class OAuthStrategy implements AuthStrategy { ACCESS_TOKEN_CACHE.delete(this.config.clientId); } + /** + * Creates a new OAuthStrategy with additional scopes merged in. + * Used by clients that have specific scope requirements. + * + * @param additionalScopes - Scopes to add to this strategy's existing scopes + * @returns A new OAuthStrategy instance with merged scopes + */ + withAdditionalScopes(additionalScopes: string[]): OAuthStrategy { + const mergedScopes = [...new Set([...(this.config.scopes || []), ...additionalScopes])]; + return new OAuthStrategy({ + ...this.config, + scopes: mergedScopes, + }); + } + /** * Gets an access token, using cache if valid */ diff --git a/packages/b2c-tooling-sdk/src/clients/custom-apis.generated.ts b/packages/b2c-tooling-sdk/src/clients/custom-apis.generated.ts new file mode 100644 index 00000000..08a70820 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/custom-apis.generated.ts @@ -0,0 +1,203 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/endpoints": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a list of resolved Custom API endpoints. + * @description List resolved Custom API endpoints, including not registered endpoints. + */ + get: operations["getEndpoints"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + OrganizationId: string; + /** + * @example active + * @enum {string} + */ + EndpointStatus: "active" | "not_registered"; + /** + * Format: int32 + * @description Maximum records to retrieve per request, not to exceed the maximum defined. A limit must be at least 1 so at least one record is returned (if any match the criteria). + * @default 10 + * @example 10 + */ + Limit: number; + /** + * Format: int32 + * @description The total number of hits that match the search's criteria. This can be greater than the number of results returned as search results are pagenated. + * @default 0 + * @example 10 + */ + Total: number; + /** + * @description Schema defining generic list result. Each response schema of a resource requiring a list response should extend this schema. + * Additionally it needs to be defined what data is returned. + */ + ResultBase: { + limit: components["schemas"]["Limit"]; + total: components["schemas"]["Total"]; + }; + CustomApiEndpointFilter: { + /** + * @example not_registered + * @enum {string} + */ + status?: "active" | "not_registered"; + }; + /** + * @description The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites + * @example RefArch + */ + SiteId: string; + CustomApiEndpoint: { + /** @example loyalty-info */ + apiName?: string; + /** @example v1 */ + apiVersion?: string; + /** @example test_bc_wapi */ + cartridgeName?: string; + /** @example /customers */ + endpointPath?: string; + /** @example API schema not found. */ + errorReason?: string; + /** + * @example GET + * @enum {string} + */ + httpMethod?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD"; + /** @example 10bd7f2dc40ab7aede7f0d60e5c3a783 */ + id?: string; + /** @example script.js */ + implementationScript?: string; + /** @example getLoyaltyInfo */ + operationId?: string; + /** + * @example ShopperToken + * @enum {string} + */ + securityScheme?: "ShopperToken" | "AmOAuth2"; + /** @example schema.yaml */ + schemaFile?: string; + siteId?: components["schemas"]["SiteId"]; + status?: components["schemas"]["EndpointStatus"]; + }; + CustomApiEndpointResult: { + filter?: components["schemas"]["CustomApiEndpointFilter"]; + data?: components["schemas"]["CustomApiEndpoint"][]; + /** @example version1 */ + activeCodeVersion?: string; + } & components["schemas"]["ResultBase"]; + ErrorResponse: { + /** + * @description A short, human-readable summary of the problem + * type. It will not change from occurrence to occurrence of the + * problem, except for purposes of localization + * @example You do not have enough credit + */ + title: string; + /** + * @description A URI reference [RFC3986] that identifies the + * problem type. This specification encourages that, when + * dereferenced, it provide human-readable documentation for the + * problem type (e.g., using HTML [W3C.REC-html5-20141028]). When + * this member is not present, its value is assumed to be + * "about:blank". It accepts relative URIs; this means + * that they must be resolved relative to the document's base URI, as + * per [RFC3986], Section 5. + * @example NotEnoughMoney + */ + type: string; + /** + * @description A human-readable explanation specific to this occurrence of the problem. + * @example Your current balance is 30, but that costs 50 + */ + detail: string; + /** + * @description A URI reference that identifies the specific + * occurrence of the problem. It may or may not yield further + * information if dereferenced. It accepts relative URIs; this means + * that they must be resolved relative to the document's base URI, as + * per [RFC3986], Section 5. + * @example /account/12345/msgs/abc + */ + instance?: string; + } & { + [key: string]: unknown; + }; + }; + responses: never; + parameters: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["schemas"]["OrganizationId"]; + status: components["schemas"]["EndpointStatus"]; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getEndpoints: { + parameters: { + query?: { + status?: components["parameters"]["status"]; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomApiEndpointResult"]; + }; + }; + /** @description Invalid or malformed filter parameter */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/custom-apis.ts b/packages/b2c-tooling-sdk/src/clients/custom-apis.ts new file mode 100644 index 00000000..d334c264 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/custom-apis.ts @@ -0,0 +1,176 @@ +/* + * 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 + */ +/** + * Custom APIs DX API client for B2C Commerce. + * + * Provides a fully typed client for Custom APIs DX API operations using + * openapi-fetch with OAuth authentication middleware. Used for retrieving + * the status of deployed Custom API endpoints. + * + * @module clients/custom-apis + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import type {paths, components} from './custom-apis.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; + +/** + * Re-export generated types for external use. + */ +export type {paths, components}; + +/** + * The typed Custom APIs client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createCustomApisClient} for instantiation + */ +export type CustomApisClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type CustomApisResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Standard Custom APIs error response structure. + */ +export type CustomApisError = components['schemas']['ErrorResponse']; + +/** Default OAuth scopes required for Custom APIs (read-only) */ +export const CUSTOM_APIS_DEFAULT_SCOPES = ['sfcc.custom-apis']; + +/** + * Configuration for creating a Custom APIs client. + */ +export interface CustomApisClientConfig { + /** + * The short code for the SCAPI instance. + * This is typically a 4-8 character alphanumeric code. + * @example "kv7kzm78" + */ + shortCode: string; + + /** + * The tenant ID (with or without f_ecom_ prefix). + * Used to build the organizationId path parameter and tenant-specific OAuth scope. + * @example "zzxy_prd" or "f_ecom_zzxy_prd" + */ + tenantId: string; + + /** + * Optional scope override. If not provided, defaults to domain scope + * (sfcc.custom-apis) plus tenant-specific scope (SALESFORCE_COMMERCE_API:{tenant}). + */ + scopes?: string[]; +} + +/** + * Creates a typed Custom APIs DX API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * The client automatically handles OAuth scope requirements: + * - Domain scope: `sfcc.custom-apis` (or custom via config.scopes) + * - Tenant scope: `SALESFORCE_COMMERCE_API:{tenantId}` + * + * @param config - Custom APIs client configuration including shortCode and tenantId + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Custom APIs client - scopes are handled automatically + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createCustomApisClient( + * { shortCode: 'kv7kzm78', tenantId: 'zzxy_prd' }, + * oauthStrategy + * ); + * + * // Get all Custom API endpoints + * const { data, error } = await client.GET('/organizations/{organizationId}/endpoints', { + * params: { + * path: { organizationId: toOrganizationId('zzxy_prd') } + * } + * }); + */ +export function createCustomApisClient(config: CustomApisClientConfig, auth: AuthStrategy): CustomApisClient { + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/custom-apis/v1`, + }); + + // Build required scopes: domain scope + tenant-specific scope + const requiredScopes = config.scopes ?? [...CUSTOM_APIS_DEFAULT_SCOPES, buildTenantScope(config.tenantId)]; + + // If OAuth strategy, add required scopes; otherwise use as-is + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + // Middleware order: auth → logging (logging sees fully modified request) + client.use(createAuthMiddleware(scopedAuth)); + client.use(createLoggingMiddleware('CUSTOM-APIS')); + + return client; +} + +/** Prefix required for SCAPI organizationId */ +export const ORGANIZATION_ID_PREFIX = 'f_ecom_'; + +/** Prefix for tenant-specific SCAPI OAuth scopes */ +export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:'; + +/** + * Ensures a tenant ID has the required f_ecom_ prefix for use as an SCAPI organizationId. + * If the value already has the prefix, it's returned as-is. + * + * @param tenantId - The tenant ID (e.g., "zzxy_prd" or "f_ecom_zzxy_prd") + * @returns The organization ID with the f_ecom_ prefix (e.g., "f_ecom_zzxy_prd") + * + * @example + * toOrganizationId('zzxy_prd') // Returns 'f_ecom_zzxy_prd' + * toOrganizationId('f_ecom_zzxy_prd') // Returns 'f_ecom_zzxy_prd' (unchanged) + */ +export function toOrganizationId(tenantId: string): string { + if (tenantId.startsWith(ORGANIZATION_ID_PREFIX)) { + return tenantId; + } + return `${ORGANIZATION_ID_PREFIX}${tenantId}`; +} + +/** + * Extracts the raw tenant ID by stripping the f_ecom_ prefix if present. + * + * @param value - The tenant ID or organization ID (e.g., "zzxy_prd" or "f_ecom_zzxy_prd") + * @returns The raw tenant ID without the prefix (e.g., "zzxy_prd") + * + * @example + * toTenantId('f_ecom_zzxy_prd') // Returns 'zzxy_prd' + * toTenantId('zzxy_prd') // Returns 'zzxy_prd' (unchanged) + */ +export function toTenantId(value: string): string { + if (value.startsWith(ORGANIZATION_ID_PREFIX)) { + return value.slice(ORGANIZATION_ID_PREFIX.length); + } + return value; +} + +/** + * Builds the tenant-specific OAuth scope required for SCAPI APIs. + * + * @param tenantId - The tenant ID (with or without f_ecom_ prefix) + * @returns The tenant-specific scope (e.g., "SALESFORCE_COMMERCE_API:zzxy_prd") + * + * @example + * buildTenantScope('zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd' + * buildTenantScope('f_ecom_zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd' + */ +export function buildTenantScope(tenantId: string): string { + return `${SCAPI_TENANT_SCOPE_PREFIX}${toTenantId(tenantId)}`; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 5e02d43e..929a51dc 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -15,6 +15,7 @@ * - {@link OcapiClient} - Data API operations via OCAPI (openapi-fetch Client) * - {@link SlasClient} - SLAS Admin API for managing tenants and clients * - {@link OdsClient} - On-Demand Sandbox API for managing developer sandboxes + * - {@link CustomApisClient} - Custom APIs DX API for retrieving endpoint status * * ## Usage * @@ -156,3 +157,21 @@ export type { paths as MrtPaths, components as MrtComponents, } from './mrt.js'; + +export { + createCustomApisClient, + toOrganizationId, + toTenantId, + buildTenantScope, + ORGANIZATION_ID_PREFIX, + SCAPI_TENANT_SCOPE_PREFIX, + CUSTOM_APIS_DEFAULT_SCOPES, +} from './custom-apis.js'; +export type { + CustomApisClient, + CustomApisClientConfig, + CustomApisError, + CustomApisResponse, + paths as CustomApisPaths, + components as CustomApisComponents, +} from './custom-apis.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index d6bcf87f..f68f363e 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -57,6 +57,13 @@ export { createExtraParamsMiddleware, createSlasClient, createOdsClient, + createCustomApisClient, + toOrganizationId, + toTenantId, + buildTenantScope, + ORGANIZATION_ID_PREFIX, + SCAPI_TENANT_SCOPE_PREFIX, + CUSTOM_APIS_DEFAULT_SCOPES, } from './clients/index.js'; export type { PropfindEntry, @@ -78,6 +85,12 @@ export type { OdsResponse, OdsPaths, OdsComponents, + CustomApisClient, + CustomApisClientConfig, + CustomApisError, + CustomApisResponse, + CustomApisPaths, + CustomApisComponents, } from './clients/index.js'; // Context Layer - Platform diff --git a/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts new file mode 100644 index 00000000..262f61b7 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts @@ -0,0 +1,169 @@ +/* + * 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 {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createCustomApisClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const SHORT_CODE = 'kv7kzm78'; +const TENANT_ID = 'zzxy_prd'; +const BASE_URL = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/dx/custom-apis/v1`; + +describe('clients/custom-apis', () => { + describe('createCustomApisClient', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + it('creates a client with the correct base URL', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/endpoints`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({ + limit: 10, + total: 0, + data: [], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + }); + + expect(error).to.be.undefined; + expect(data?.data).to.deep.equal([]); + }); + + it('fetches endpoints with active code version', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/endpoints`, () => { + return HttpResponse.json({ + limit: 10, + total: 2, + activeCodeVersion: 'version1', + data: [ + { + apiName: 'loyalty-info', + apiVersion: 'v1', + cartridgeName: 'my_cartridge', + endpointPath: '/customers', + httpMethod: 'GET', + status: 'active', + securityScheme: 'ShopperToken', + id: '10bd7f2dc40ab7aede7f0d60e5c3a783', + }, + { + apiName: 'loyalty-info', + apiVersion: 'v1', + cartridgeName: 'my_cartridge_2', + endpointPath: '/customers', + httpMethod: 'POST', + status: 'not_registered', + errorReason: "Cartridge 'my_cartridge_2' not found.", + id: '20bd7f2dc40ab7aede7f0d60e5c3a784', + }, + ], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + }); + + expect(data?.total).to.equal(2); + expect(data?.activeCodeVersion).to.equal('version1'); + expect(data?.data).to.have.length(2); + expect(data?.data?.[0]?.status).to.equal('active'); + expect(data?.data?.[1]?.status).to.equal('not_registered'); + expect(data?.data?.[1]?.errorReason).to.equal("Cartridge 'my_cartridge_2' not found."); + }); + + it('filters endpoints by status', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/endpoints`, ({request}) => { + const url = new URL(request.url); + const statusFilter = url.searchParams.get('status'); + + expect(statusFilter).to.equal('active'); + + return HttpResponse.json({ + limit: 10, + total: 1, + filter: {status: 'active'}, + data: [ + { + apiName: 'loyalty-info', + apiVersion: 'v1', + cartridgeName: 'my_cartridge', + endpointPath: '/customers', + httpMethod: 'GET', + status: 'active', + }, + ], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/endpoints', { + params: { + path: {organizationId: 'f_ecom_zzxy_prd'}, + query: {status: 'active'}, + }, + }); + + expect(data?.filter?.status).to.equal('active'); + expect(data?.data).to.have.length(1); + }); + + it('handles API errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/endpoints`, () => { + return HttpResponse.json( + { + title: 'Bad Request', + type: 'https://api.commercecloud.salesforce.com/documentation/error/v1/errors/bad-request', + detail: "Invalid value 'foo' for filter parameter 'status'", + }, + {status: 400}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.property('title', 'Bad Request'); + expect(error).to.have.property('detail'); + }); + }); +}); diff --git a/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md b/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md new file mode 100644 index 00000000..16a98786 --- /dev/null +++ b/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md @@ -0,0 +1,69 @@ +--- +name: b2c-scapi-custom +description: Salesforce B2C Commerce Custom API endpoint management Skill +--- + +# B2C SCAPI Custom APIs Skill + +Use the `b2c` CLI plugin to manage SCAPI Custom API endpoints and check their registration status. + +## Examples + +### Get Custom API Endpoint Status + +```bash +# list all Custom API endpoints for an organization +b2c scapi custom status --tenant-id f_ecom_zzxy_prd + +# list with JSON output +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --json +``` + +### Filter by Status + +```bash +# list only active endpoints +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status active + +# list only endpoints that failed to register +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered +``` + +### Group by Type or Site + +```bash +# group endpoints by API type (Admin vs Shopper) +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by type + +# group endpoints by site +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by site +``` + +### Customize Output Columns + +```bash +# show extended columns (includes error reasons, sites, etc.) +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --extended + +# select specific columns to display +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --columns type,apiName,status,sites + +# available columns: type, apiName, apiVersion, cartridgeName, endpointPath, httpMethod, status, sites, securityScheme, operationId, schemaFile, implementationScript, errorReason, id +``` + +### Debug Failed Registrations + +```bash +# quickly find and diagnose failed Custom API registrations +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered --columns type,apiName,endpointPath,errorReason +``` + +### Configuration + +The tenant ID and short code can be set via environment variables: +- `SFCC_TENANT_ID`: Organization/tenant ID +- `SFCC_SHORTCODE`: SCAPI short code + +### More Commands + +See `b2c scapi custom --help` for a full list of available commands and options. From 743396203e2b334627125587f5b253bb233cdcd3 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 11:40:50 -0500 Subject: [PATCH 2/8] doc updates for custom APIs --- docs/.vitepress/config.mts | 1 + docs/cli/custom-apis.md | 145 +++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 docs/cli/custom-apis.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 933ce039..264f0d59 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -23,6 +23,7 @@ const guideSidebar = [ { text: 'ODS Commands', link: '/cli/ods' }, { text: 'MRT Commands', link: '/cli/mrt' }, { text: 'SLAS Commands', link: '/cli/slas' }, + { text: 'Custom APIs', link: '/cli/custom-apis' }, { text: 'Auth Commands', link: '/cli/auth' }, { text: 'Logging', link: '/cli/logging' }, ], diff --git a/docs/cli/custom-apis.md b/docs/cli/custom-apis.md new file mode 100644 index 00000000..783d45d6 --- /dev/null +++ b/docs/cli/custom-apis.md @@ -0,0 +1,145 @@ +# Custom APIs + +Commands for managing SCAPI Custom API endpoints. + +## Global Custom APIs Flags + +These flags are available on all Custom APIs commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--tenant-id` | `SFCC_TENANT_ID` | (Required) Organization/tenant ID | +| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | + +## Authentication + +Custom APIs commands require an Account Manager API Client with OAuth credentials. + +### Required Scopes + +The following scopes are automatically requested by the CLI: + +| Scope | Description | +|-------|-------------| +| `sfcc.custom-apis` | Access to Custom APIs endpoints | +| `SALESFORCE_COMMERCE_API:` | Tenant-specific access scope | + +### Configuration + +```bash +# Set credentials via environment variables +export SFCC_CLIENT_ID=my-client +export SFCC_CLIENT_SECRET=my-secret +export SFCC_TENANT_ID=zzxy_prd +export SFCC_SHORTCODE=kv7kzm78 + +# Or provide via flags +b2c scapi custom status --client-id xxx --client-secret xxx --tenant-id zzxy_prd +``` + +--- + +## b2c scapi custom status + +Get the status of Custom API endpoints for an organization. Shows which endpoints are active and which failed to register. + +### Usage + +```bash +b2c scapi custom status --tenant-id +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--tenant-id` | (Required) Organization/tenant ID | | +| `--status`, `-s` | Filter by endpoint status (`active`, `not_registered`) | | +| `--group-by`, `-g` | Group output by field (`type` or `site`) | | +| `--columns`, `-c` | Columns to display (comma-separated) | | +| `--extended`, `-x` | Show all columns including extended fields | `false` | +| `--json` | Output results as JSON | `false` | + +### Available Columns + +Default columns: `type`, `apiName`, `endpointPath`, `httpMethod`, `status` + +Extended columns (shown with `--extended`): `sites`, `securityScheme`, `operationId`, `schemaFile`, `implementationScript`, `errorReason`, `id`, `apiVersion`, `cartridgeName` + +### API Types + +The `type` column shows a human-readable API type based on the security scheme: + +| Security Scheme | Type | +|-----------------|------| +| `AmOAuth2` | Admin | +| `ShopperToken` | Shopper | + +### Examples + +```bash +# List all Custom API endpoints +b2c scapi custom status --tenant-id f_ecom_zzxy_prd + +# Filter by status +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status active +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered + +# Group by API type (Admin/Shopper) +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by type + +# Group by site +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by site + +# Show extended columns +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --extended + +# Custom columns +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --columns type,apiName,status,sites + +# Debug failed registrations +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered --columns type,apiName,endpointPath,errorReason + +# Output as JSON +b2c scapi custom status --tenant-id f_ecom_zzxy_prd --json +``` + +### Output + +Default table output: + +``` +Active code version: version1 +Found 5 endpoint(s): + +Type API Name Path Method Status +─────────────────────────────────────────────────────────── +Shopper loyalty-info /customers GET active +Shopper loyalty-info /points GET active +Admin inventory /stock GET active +Admin inventory /stock PUT active +Shopper wishlist /items POST not_registered +``` + +Grouped by type: + +``` +Admin APIs: +API Name Path Method Status +───────────────────────────────────────── +inventory /stock GET active +inventory /stock PUT active + +Shopper APIs: +API Name Path Method Status +───────────────────────────────────────────── +loyalty-info /customers GET active +loyalty-info /points GET active +wishlist /items POST not_registered +``` + +### Notes + +- Endpoints are rolled up by site: if the same endpoint is active on multiple sites, the sites are combined into a comma-separated list (visible with `--extended` or `--columns sites`) +- The `errorReason` column (extended) shows why an endpoint failed to register +- Use `--group-by site` to see which endpoints are deployed to each site From e6e4149c3161578ac9555342baec1d60e7bc9fa1 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 11:48:23 -0500 Subject: [PATCH 3/8] skill specific to api client development concerns for scapi --- .../skills/api-client-development/SKILL.md | 397 ++++++++++++++++++ .../skills/sdk-module-development/SKILL.md | 2 + AGENTS.md | 7 +- 3 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/api-client-development/SKILL.md diff --git a/.claude/skills/api-client-development/SKILL.md b/.claude/skills/api-client-development/SKILL.md new file mode 100644 index 00000000..c61118b8 --- /dev/null +++ b/.claude/skills/api-client-development/SKILL.md @@ -0,0 +1,397 @@ +--- +name: api-client-development +description: Creating API clients with OpenAPI specs, authentication, and OAuth scopes for SCAPI and similar APIs +--- + +# API Client Development + +This skill covers creating typed API clients using OpenAPI specifications, with proper authentication and OAuth scope handling. It builds on the patterns in [SDK Module Development](../sdk-module-development/SKILL.md). + +## Overview + +API clients in this project use: +- **openapi-fetch**: Type-safe HTTP client generated from OpenAPI specs +- **openapi-typescript**: Generates TypeScript types from OpenAPI specs +- **Middleware pattern**: Auth and logging injected via openapi-fetch middleware + +## Creating a New API Client + +### 1. Add the OpenAPI Spec + +Place the spec in `packages/b2c-tooling-sdk/specs/`: + +``` +specs/ +├── custom-apis-v1.yaml # YAML or JSON +├── slas-admin-v1.yaml +└── ods-api-v1.json +``` + +### 2. Update Type Generation Script + +In `packages/b2c-tooling-sdk/package.json`, add to the generate script: + +```json +{ + "scripts": { + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/newapi-v1.yaml -o src/clients/newapi.generated.ts" + } +} +``` + +Run generation: + +```bash +pnpm --filter @salesforce/b2c-tooling-sdk run generate:types +``` + +### 3. Create the Client Module + +```typescript +// src/clients/newapi.ts +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './newapi.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; + +// Re-export generated types for consumers +export type {paths, components}; + +// Client type alias +export type NewApiClient = Client; + +// Config interface +export interface NewApiClientConfig { + hostname: string; + // Add API-specific config here +} + +// Factory function +export function createNewApiClient( + config: NewApiClientConfig, + auth: AuthStrategy +): NewApiClient { + const client = createClient({ + baseUrl: `https://${config.hostname}/api/v1`, + }); + + // Middleware order: auth first (runs last), logging last (sees complete request) + client.use(createAuthMiddleware(auth)); + client.use(createLoggingMiddleware('NEWAPI')); + + return client; +} +``` + +### 4. Export from Clients Barrel + +```typescript +// src/clients/index.ts +export {createNewApiClient, type NewApiClient, type NewApiClientConfig} from './newapi.js'; +export type {paths as NewApiPaths, components as NewApiComponents} from './newapi.js'; +``` + +--- + +## SCAPI Client Pattern (OAuth Scope Injection) + +SCAPI APIs require specific OAuth scopes. Instead of requiring CLI commands to manage scopes, **encapsulate scope logic in the client factory**. + +### The Problem + +Without encapsulation, CLI commands leak auth implementation details: + +```typescript +// BAD: CLI command manages scopes +class MyCommand extends OAuthCommand { + protected override loadConfiguration(): ResolvedConfig { + const config = super.loadConfiguration(); + config.scopes = ['sfcc.custom-apis', `SALESFORCE_COMMERCE_API:${tenantId}`]; + return config; + } +} +``` + +### The Solution + +Use `OAuthStrategy.withAdditionalScopes()` in the client factory: + +```typescript +// GOOD: Client encapsulates scope requirements +import {OAuthStrategy} from '../auth/oauth.js'; +import type {AuthStrategy} from '../auth/types.js'; + +/** Default OAuth scopes required for this API */ +export const MY_API_DEFAULT_SCOPES = ['sfcc.my-api']; + +export interface MyApiClientConfig { + shortCode: string; + tenantId: string; // Required for tenant-specific scope + scopes?: string[]; // Optional override +} + +export function createMyApiClient( + config: MyApiClientConfig, + auth: AuthStrategy +): MyApiClient { + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/my-api/v1`, + }); + + // Build required scopes: domain scope + tenant-specific scope + const requiredScopes = config.scopes ?? [ + ...MY_API_DEFAULT_SCOPES, + buildTenantScope(config.tenantId), + ]; + + // If OAuth strategy, add required scopes; otherwise use as-is (e.g., for testing) + const scopedAuth = auth instanceof OAuthStrategy + ? auth.withAdditionalScopes(requiredScopes) + : auth; + + client.use(createAuthMiddleware(scopedAuth)); + client.use(createLoggingMiddleware('MY-API')); + + return client; +} +``` + +This pattern: +1. Keeps scope knowledge in the SDK, not the CLI +2. Allows scope override for special cases via `config.scopes` +3. Works with non-OAuth auth strategies (for testing/mocking) +4. CLI commands just pass the auth strategy through unchanged + +--- + +## SCAPI Tenant ID Utilities + +SCAPI APIs use an `organizationId` path parameter with the `f_ecom_` prefix, but OAuth scopes use the raw tenant ID. Use these utilities: + +```typescript +// From @salesforce/b2c-tooling-sdk (or clients/custom-apis.ts) +import {toOrganizationId, toTenantId, buildTenantScope} from '@salesforce/b2c-tooling-sdk'; + +// Convert tenant ID to organization ID (adds f_ecom_ prefix) +toOrganizationId('zzxy_prd') // Returns 'f_ecom_zzxy_prd' +toOrganizationId('f_ecom_zzxy_prd') // Returns 'f_ecom_zzxy_prd' (unchanged) + +// Extract raw tenant ID (strips f_ecom_ prefix) +toTenantId('f_ecom_zzxy_prd') // Returns 'zzxy_prd' +toTenantId('zzxy_prd') // Returns 'zzxy_prd' (unchanged) + +// Build tenant-specific OAuth scope +buildTenantScope('zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd' +buildTenantScope('f_ecom_zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd' +``` + +### Constants + +```typescript +/** Prefix required for SCAPI organizationId path parameter */ +export const ORGANIZATION_ID_PREFIX = 'f_ecom_'; + +/** Prefix for tenant-specific SCAPI OAuth scopes */ +export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:'; +``` + +--- + +## OAuthStrategy.withAdditionalScopes() + +The `OAuthStrategy` class has a method for scope injection: + +```typescript +// Creates a new OAuthStrategy with merged scopes +const scopedAuth = auth.withAdditionalScopes(['sfcc.custom-apis', 'SALESFORCE_COMMERCE_API:zzxy_prd']); +``` + +Key behaviors: +- Returns a **new** `OAuthStrategy` instance (immutable pattern) +- Merges scopes with deduplication (uses `Set`) +- The new strategy shares token cache with the original (keyed by clientId) +- If cached token doesn't have required scopes, it re-authenticates + +--- + +## Complete SCAPI Client Example + +Reference implementation: `packages/b2c-tooling-sdk/src/clients/custom-apis.ts` + +```typescript +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import type {paths, components} from './custom-apis.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; + +export type {paths, components}; +export type CustomApisClient = Client; + +/** Default OAuth scopes required for Custom APIs */ +export const CUSTOM_APIS_DEFAULT_SCOPES = ['sfcc.custom-apis']; + +export interface CustomApisClientConfig { + shortCode: string; + tenantId: string; + scopes?: string[]; +} + +export function createCustomApisClient( + config: CustomApisClientConfig, + auth: AuthStrategy +): CustomApisClient { + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/custom-apis/v1`, + }); + + // Build required scopes: domain scope + tenant-specific scope + const requiredScopes = config.scopes ?? [ + ...CUSTOM_APIS_DEFAULT_SCOPES, + buildTenantScope(config.tenantId), + ]; + + // If OAuth strategy, add required scopes; otherwise use as-is + const scopedAuth = auth instanceof OAuthStrategy + ? auth.withAdditionalScopes(requiredScopes) + : auth; + + client.use(createAuthMiddleware(scopedAuth)); + client.use(createLoggingMiddleware('CUSTOM-APIS')); + + return client; +} + +// Tenant ID utilities +export const ORGANIZATION_ID_PREFIX = 'f_ecom_'; +export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:'; + +export function toOrganizationId(tenantId: string): string { + return tenantId.startsWith(ORGANIZATION_ID_PREFIX) + ? tenantId + : `${ORGANIZATION_ID_PREFIX}${tenantId}`; +} + +export function toTenantId(value: string): string { + return value.startsWith(ORGANIZATION_ID_PREFIX) + ? value.slice(ORGANIZATION_ID_PREFIX.length) + : value; +} + +export function buildTenantScope(tenantId: string): string { + return `${SCAPI_TENANT_SCOPE_PREFIX}${toTenantId(tenantId)}`; +} +``` + +--- + +## CLI Command Integration + +With scope encapsulation in the client, CLI commands become simple: + +```typescript +// packages/b2c-cli/src/commands/scapi/custom/status.ts +import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {createCustomApisClient, toOrganizationId} from '@salesforce/b2c-tooling-sdk'; + +export default class ScapiCustomStatus extends OAuthCommand { + static flags = { + ...OAuthCommand.baseFlags, + 'tenant-id': Flags.string({ + description: 'Organization/tenant ID', + env: 'SFCC_TENANT_ID', + required: true, + }), + }; + + async run() { + this.requireOAuthCredentials(); + + const {'tenant-id': tenantId} = this.flags; + const {shortCode} = this.resolvedConfig; + + // Auth strategy from base class - no scope configuration needed! + const oauthStrategy = this.getOAuthStrategy(); + + // Client handles scope injection internally + const client = createCustomApisClient({shortCode, tenantId}, oauthStrategy); + + const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', { + params: { + path: {organizationId: toOrganizationId(tenantId)}, + }, + }); + + // Handle response... + } +} +``` + +--- + +## Testing API Clients + +Use MSW (Mock Service Worker) to mock API responses: + +```typescript +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createCustomApisClient} from '@salesforce/b2c-tooling-sdk'; + +const mockAuth: AuthStrategy = { + async fetch(url, init) { + return fetch(url, init); + }, + async getAuthorizationHeader() { + return 'Bearer mock-token'; + }, +}; + +const server = setupServer( + http.get('https://test.api.commercecloud.salesforce.com/dx/custom-apis/v1/organizations/*/endpoints', () => { + return HttpResponse.json({ + data: [{apiName: 'test', status: 'active'}], + total: 1, + limit: 10, + }); + }) +); + +beforeAll(() => server.listen()); +afterAll(() => server.close()); + +it('fetches endpoints', async () => { + const client = createCustomApisClient( + {shortCode: 'test', tenantId: 'zzxy_prd'}, + mockAuth + ); + + const {data} = await client.GET('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + }); + + expect(data?.data).toHaveLength(1); +}); +``` + +--- + +## Checklist: New SCAPI Client + +1. Add OpenAPI spec to `specs/` +2. Update `generate:types` script in `package.json` +3. Run `pnpm --filter @salesforce/b2c-tooling-sdk run generate:types` +4. Create client module with: + - Config interface including `tenantId` + - Default scopes constant + - Factory function with scope injection pattern + - Tenant ID utilities (or import from existing) +5. Export from `src/clients/index.ts` +6. Add to main `src/index.ts` if needed +7. Write tests with MSW mocks +8. Build: `pnpm --filter @salesforce/b2c-tooling-sdk run build` +9. Test: `pnpm --filter @salesforce/b2c-tooling-sdk run test` diff --git a/.claude/skills/sdk-module-development/SKILL.md b/.claude/skills/sdk-module-development/SKILL.md index 6d8427d8..3fa03e1a 100644 --- a/.claude/skills/sdk-module-development/SKILL.md +++ b/.claude/skills/sdk-module-development/SKILL.md @@ -144,6 +144,8 @@ export { createNewApiClient } from './newapi.js'; export type { NewApiClient, paths as NewApiPaths, components as NewApiComponents } from './newapi.js'; ``` +For SCAPI clients with OAuth scope requirements, see [API Client Development](../api-client-development/SKILL.md) for advanced patterns including scope injection and tenant ID handling. + ## OpenAPI Type Generation For APIs with OpenAPI specs, generate TypeScript types: diff --git a/AGENTS.md b/AGENTS.md index e3de37b9..b3acadf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,12 @@ Use `createTable` from `@salesforce/b2c-tooling-sdk/cli` for tabular output. See **User-facing skills** (for CLI users): `./plugins/b2c-cli/skills/` - update when modifying CLI commands. -**Developer skills** (for contributors): `./.claude/skills/` - covers CLI development, SDK modules, testing, and documentation. +**Developer skills** (for contributors): `./.claude/skills/` - covers: +- [CLI command development](./.claude/skills/cli-command-development/SKILL.md) - oclif commands, flags, table output +- [SDK module development](./.claude/skills/sdk-module-development/SKILL.md) - modules, exports, barrel files +- [API client development](./.claude/skills/api-client-development/SKILL.md) - OpenAPI clients, OAuth scopes, SCAPI patterns +- [Testing](./.claude/skills/testing/SKILL.md) - Mocha, Chai, MSW patterns +- [Documentation](./.claude/skills/documentation/SKILL.md) - user guides, CLI reference, API docs ## Testing From 1afc1cfba32f474580c15fe52040e635bf46ee0f Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 12:05:59 -0500 Subject: [PATCH 4/8] adding new b2c plugin with custom api development skill --- .claude-plugin/marketplace.json | 11 + plugins/README.md | 16 + .../b2c-custom-api-development/SKILL.md | 317 ++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 plugins/b2c/skills/b2c-custom-api-development/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a62b81cc..adb99075 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -17,6 +17,17 @@ "source": "./plugins/b2c-cli", "category": "productivity", "strict": false + }, + { + "name": "b2c", + "description": "B2C Commerce development skills including Custom API development guides.", + "author": { + "name": "Salesforce" + }, + "license": "Apache-2.0", + "source": "./plugins/b2c", + "category": "productivity", + "strict": false } ] } diff --git a/plugins/README.md b/plugins/README.md index a296edf6..958f32d0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -9,6 +9,7 @@ These skills follow the [Agent Skills](https://agentskills.io/home) format and c | Plugin | Description | |--------|-------------| | `b2c-cli` | Skills for Salesforce Commerce Cloud B2C CLI operations | +| `b2c` | B2C Commerce development skills including Custom API development guides | ## Installation @@ -68,6 +69,21 @@ plugins/b2c-cli/skills/ └── b2c-webdav/SKILL.md # WebDAV commands ``` +## Plugin: b2c + +The `b2c` plugin provides skills for B2C Commerce development practices and patterns. When installed, Claude can help you with: + +- **Custom API Development** (`b2c-custom-api-development`) - Build SCAPI Custom APIs with contracts, implementations, and mappings + +### Skills + +Each skill is defined in the `b2c/skills/` directory: + +``` +plugins/b2c/skills/ +└── b2c-custom-api-development/SKILL.md # Custom API development guide +``` + ## For Contributors When modifying CLI commands, update the corresponding skill in `plugins/b2c-cli/skills/b2c-/SKILL.md` to keep documentation in sync. diff --git a/plugins/b2c/skills/b2c-custom-api-development/SKILL.md b/plugins/b2c/skills/b2c-custom-api-development/SKILL.md new file mode 100644 index 00000000..0c7b4eb1 --- /dev/null +++ b/plugins/b2c/skills/b2c-custom-api-development/SKILL.md @@ -0,0 +1,317 @@ +--- +name: b2c-custom-api-development +description: Guide for developing SCAPI Custom APIs on Salesforce B2C Commerce +--- + +# Custom API Development Skill + +This skill guides you through developing Custom APIs for Salesforce B2C Commerce. Custom APIs let you expose custom script code as REST endpoints under the SCAPI framework. + +## Overview + +A Custom API URL has this structure: + +``` +https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}/organizations/{organizationId}/{endpointPath} +``` + +Three components are required to create a Custom API: + +1. **API Contract** - An OAS 3.0 schema file (YAML) +2. **API Implementation** - A script using the B2C Commerce Script API +3. **API Mapping** - An `api.json` file binding endpoints to implementations + +## Cartridge Structure + +Custom APIs are defined within cartridges. Create a `rest-apis` folder in the cartridge directory with subdirectories for each API: + +``` +/my-cartridge + /cartridge + package.json + /rest-apis + /my-api-name # API name (lowercase alphanumeric and hyphens only) + api.json # Mapping file + schema.yaml # OAS 3.0 contract + script.js # Implementation + /scripts + /controllers +``` + +**Important:** API directory names can only contain alphanumeric lowercase characters and hyphens. + +## Component 1: API Contract (schema.yaml) + +The API contract defines endpoints using OAS 3.0 format: + +```yaml +openapi: 3.0.0 +info: + version: 1.0.0 # API version (1.0.0 becomes v1 in URL) + title: My Custom API +components: + securitySchemes: + ShopperToken: # For Shopper APIs (requires siteId) + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://{shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/{organizationId}/oauth2/token + scopes: + c_my_scope: Description of my scope + AmOAuth2: # For Admin APIs (no siteId) + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + c_my_admin_scope: Description of my admin scope + parameters: + siteId: + name: siteId + in: query + required: true + schema: + type: string + minLength: 1 + locale: + name: locale + in: query + required: false + schema: + type: string + minLength: 1 +paths: + /my-endpoint: + get: + summary: Get something + operationId: getMyData # Must match function name in script + parameters: + - $ref: '#/components/parameters/siteId' + - in: query + name: c_my_param # Custom params must start with c_ + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + type: object +security: + - ShopperToken: ['c_my_scope'] # Global security (or per-operation) +``` + +### Contract Requirements + +- **Version:** Defined in `info.version`, transformed to URL version (e.g., `1.0.1` becomes `v1`) +- **Security Scheme:** Use `ShopperToken` for Shopper APIs or `AmOAuth2` for Admin APIs +- **Custom Scopes:** Must start with `c_`, contain only alphanumeric/hyphen/period/underscore, max 25 chars +- **Parameters:** All request parameters must be defined; custom params must have `c_` prefix +- **System Parameters:** `siteId` and `locale` must have `type: string` and `minLength: 1` +- **No additionalProperties:** The `additionalProperties` attribute is not allowed in request body schemas + +### Shopper vs Admin APIs + +| Aspect | Shopper API | Admin API | +|--------|-------------|-----------| +| Security Scheme | `ShopperToken` | `AmOAuth2` | +| `siteId` Parameter | Required | Must omit | +| Max Runtime | 10 seconds | 60 seconds | +| Max Request Body | 5 MiB | 20 MB | +| Activity Type | STOREFRONT | BUSINESS_MANAGER | + +## Component 2: Implementation (script.js) + +The implementation script exports functions matching `operationId` values: + +```javascript +var RESTResponseMgr = require('dw/system/RESTResponseMgr'); + +exports.getMyData = function() { + // Get query parameters + var myParam = request.getHttpParameterMap().get('c_my_param').getStringValue(); + + // Get path parameters (for paths like /items/{itemId}) + var itemId = request.getSCAPIPathParameters().get('itemId'); + + // Get request body (for POST/PUT/PATCH) + var requestBody = JSON.parse(request.httpParameterMap.requestBodyAsString); + + // Business logic here... + var result = { + data: 'my data', + param: myParam + }; + + // Return success response + RESTResponseMgr.createSuccess(result).render(); +}; +exports.getMyData.public = true; // Required: mark function as public + +// Error response example +exports.getMyDataWithError = function() { + RESTResponseMgr + .createError(404, 'not-found', 'Resource Not Found', 'The requested resource was not found.') + .render(); +}; +exports.getMyDataWithError.public = true; +``` + +### Implementation Best Practices + +- Always return JSON format responses +- Use RFC 9457 error format with at least the `type` field +- Mark all exported functions with `.public = true` +- Handle errors gracefully to avoid circuit breaker activation +- GET requests cannot commit transactions + +### Caching Responses + +Enable Page Caching for the site, then use: + +```javascript +// Cache for 60 seconds +response.setExpires(Date.now() + 60000); + +// Personalized caching +response.setVaryBy('price_promotion'); +``` + +### Remote Includes + +Include responses from other SCAPI endpoints: + +```javascript +var include = dw.system.RESTResponseMgr.createScapiRemoteInclude( + 'custom', 'other-api', 'v1', 'endpointPath', + dw.web.URLParameter('siteId', 'MySite') +); + +var response = { + data: 'my data', + included: [include] +}; +RESTResponseMgr.createSuccess(response).render(); +``` + +## Component 3: Mapping (api.json) + +The mapping file binds endpoints to implementations: + +```json +{ + "endpoints": [ + { + "endpoint": "getMyData", + "schema": "schema.yaml", + "implementation": "script" + }, + { + "endpoint": "getMyDataV2", + "schema": "schema_v2.yaml", + "implementation": "script_v2" + } + ] +} +``` + +**Important:** +- Implementation name must NOT include file extension +- Schema and implementation files must be in the same folder as api.json +- No relative paths allowed + +## Endpoint Registration + +Endpoints are registered when **activating the code version** containing the API definitions. After uploading your cartridge: + +1. **Upload the cartridge** to your B2C instance +2. **Activate the code version** to trigger registration +3. **Check registration status** to verify endpoints are active + +For Shopper APIs, the cartridge must be in the site's cartridge path. For Admin APIs, the cartridge must be in the Business Manager site's cartridge path. + +## Circuit Breaker Protection + +Custom APIs have a circuit breaker that blocks requests when error rate exceeds 50%: + +1. Circuit opens after 50+ errors in 100 requests +2. Requests return 503 for 60 seconds +3. Circuit enters half-open state, testing next 10 requests +4. If >5 fail, circuit reopens; otherwise closes + +**Prevention:** Write robust code with error handling and avoid long-running remote calls. + +## Troubleshooting + +When endpoints return 404 or fail to register: + +1. **Check registration status** using the Custom API status report +2. **Review error reasons** in the status report for specific guidance +3. **Verify cartridge structure:** `rest-apis/{api-name}/` contains all files +4. **Check code version:** Ensure the active version contains your API +5. **Verify site assignment:** Cartridge must be in site's cartridge path +6. **Review logs** in Log Center with LCQL filter `CustomApiRegistry` + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| 400 Bad Request | Contract violation (unknown/invalid params) | Define all params in schema | +| 401 Unauthorized | Invalid/missing token | Check token validity and header | +| 403 Forbidden | Missing scope | Verify scope in token matches contract | +| 404 Not Found | Endpoint not registered | Check status report, verify structure | +| 500 Internal Error | Script error | Check logs for `CustomApiInvocationException` | +| 503 Service Unavailable | Circuit breaker open | Fix script errors, wait for reset | + +## Authentication Setup + +### For Shopper APIs (ShopperToken) + +1. Configure custom scope in SLAS Admin UI +2. Obtain token via Shopper Login (SLAS) +3. Include `siteId` in requests + +### For Admin APIs (AmOAuth2) + +1. Configure custom scope in Account Manager +2. Obtain token via Account Manager OAuth +3. Omit `siteId` from requests + +### Custom API Status Report Access + +To query the Custom API status report, use an Account Manager token with scope: +- `sfcc.custom-apis` (read-only) +- `sfcc.custom-apis.rw` (read-write) + +## Development Workflow + +1. **Create cartridge** with `rest-apis/{api-name}/` structure +2. **Define contract** (schema.yaml) with endpoints and security +3. **Implement logic** (script.js) with exported functions +4. **Create mapping** (api.json) binding endpoints to implementation +5. **Upload cartridge** to your B2C instance +6. **Activate code version** to register endpoints +7. **Check status** to verify registration +8. **Test endpoints** with appropriate authentication +9. **Monitor logs** for errors during development +10. **Iterate** on implementation as needed + +## HTTP Methods Supported + +- GET (no transaction commits) +- POST +- PUT +- PATCH +- DELETE +- HEAD +- OPTIONS + +## Limitations + +- Maximum 50 remote includes per request +- Schema attribute `additionalProperties` is not allowed +- Only local `$ref` references supported in schemas (no remote/URL refs) +- Custom parameters must have `c_` prefix +- Custom scope names max 25 characters From 8b57306e23d0dc832b08749c200d7d3d1320d212 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 12:06:06 -0500 Subject: [PATCH 5/8] code coverage for custom APIs --- .../b2c-tooling-sdk/test/auth/oauth.test.ts | 82 +++++++++++++++++++ .../test/clients/custom-apis.test.ts | 62 +++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/b2c-tooling-sdk/test/auth/oauth.test.ts diff --git a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts new file mode 100644 index 00000000..e76c9198 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/oauth.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 {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +describe('auth/oauth', () => { + describe('OAuthStrategy', () => { + describe('withAdditionalScopes', () => { + it('creates new strategy with additional scopes', () => { + const original = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['scope1'], + }); + + const extended = original.withAdditionalScopes(['scope2', 'scope3']); + + // Should be a different instance + expect(extended).to.not.equal(original); + expect(extended).to.be.instanceOf(OAuthStrategy); + }); + + it('merges scopes without duplicates', () => { + const original = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['scope1', 'scope2'], + }); + + const extended = original.withAdditionalScopes(['scope2', 'scope3']); + + // Access internal config via another withAdditionalScopes to verify + const doubleExtended = extended.withAdditionalScopes([]); + // The scopes should be deduplicated + expect(doubleExtended).to.be.instanceOf(OAuthStrategy); + }); + + it('handles empty original scopes', () => { + const original = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + }); + + const extended = original.withAdditionalScopes(['scope1', 'scope2']); + + expect(extended).to.be.instanceOf(OAuthStrategy); + }); + + it('handles empty additional scopes', () => { + const original = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['scope1'], + }); + + const extended = original.withAdditionalScopes([]); + + expect(extended).to.be.instanceOf(OAuthStrategy); + expect(extended).to.not.equal(original); + }); + + it('preserves other config options', () => { + const customHost = 'custom.auth.host.com'; + const original = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['scope1'], + accountManagerHost: customHost, + }); + + const extended = original.withAdditionalScopes(['scope2']); + + // The new strategy should preserve the custom host + // We can't directly access private fields, but we can verify it's a valid strategy + expect(extended).to.be.instanceOf(OAuthStrategy); + }); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts index 262f61b7..d2b22ceb 100644 --- a/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts @@ -6,7 +6,14 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -import {createCustomApisClient} from '@salesforce/b2c-tooling-sdk/clients'; +import { + createCustomApisClient, + toOrganizationId, + toTenantId, + buildTenantScope, + ORGANIZATION_ID_PREFIX, + SCAPI_TENANT_SCOPE_PREFIX, +} from '@salesforce/b2c-tooling-sdk/clients'; import {MockAuthStrategy} from '../helpers/mock-auth.js'; const SHORT_CODE = 'kv7kzm78'; @@ -166,4 +173,57 @@ describe('clients/custom-apis', () => { expect(error).to.have.property('detail'); }); }); + + describe('toOrganizationId', () => { + it('adds f_ecom_ prefix to tenant ID', () => { + expect(toOrganizationId('zzxy_prd')).to.equal('f_ecom_zzxy_prd'); + }); + + it('returns unchanged if already has f_ecom_ prefix', () => { + expect(toOrganizationId('f_ecom_zzxy_prd')).to.equal('f_ecom_zzxy_prd'); + }); + + it('handles various tenant ID formats', () => { + expect(toOrganizationId('abcd_001')).to.equal('f_ecom_abcd_001'); + expect(toOrganizationId('test')).to.equal('f_ecom_test'); + }); + + it('uses ORGANIZATION_ID_PREFIX constant', () => { + expect(ORGANIZATION_ID_PREFIX).to.equal('f_ecom_'); + }); + }); + + describe('toTenantId', () => { + it('strips f_ecom_ prefix from organization ID', () => { + expect(toTenantId('f_ecom_zzxy_prd')).to.equal('zzxy_prd'); + }); + + it('returns unchanged if no f_ecom_ prefix', () => { + expect(toTenantId('zzxy_prd')).to.equal('zzxy_prd'); + }); + + it('handles various formats', () => { + expect(toTenantId('f_ecom_abcd_001')).to.equal('abcd_001'); + expect(toTenantId('f_ecom_test')).to.equal('test'); + }); + }); + + describe('buildTenantScope', () => { + it('builds scope from tenant ID', () => { + expect(buildTenantScope('zzxy_prd')).to.equal('SALESFORCE_COMMERCE_API:zzxy_prd'); + }); + + it('strips f_ecom_ prefix before building scope', () => { + expect(buildTenantScope('f_ecom_zzxy_prd')).to.equal('SALESFORCE_COMMERCE_API:zzxy_prd'); + }); + + it('uses SCAPI_TENANT_SCOPE_PREFIX constant', () => { + expect(SCAPI_TENANT_SCOPE_PREFIX).to.equal('SALESFORCE_COMMERCE_API:'); + }); + + it('handles various tenant ID formats', () => { + expect(buildTenantScope('abcd_001')).to.equal('SALESFORCE_COMMERCE_API:abcd_001'); + expect(buildTenantScope('f_ecom_test')).to.equal('SALESFORCE_COMMERCE_API:test'); + }); + }); }); From 1955b22288749df4266e7464444bbaa20dcdafd1 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 12:22:33 -0500 Subject: [PATCH 6/8] update agent skills documentation --- docs/.vitepress/config.mts | 2 +- docs/guide/agent-skills.md | 181 +++++++++++++++++++++++++++++++ docs/guide/claude-code-plugin.md | 159 --------------------------- 3 files changed, 182 insertions(+), 160 deletions(-) create mode 100644 docs/guide/agent-skills.md delete mode 100644 docs/guide/claude-code-plugin.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 264f0d59..4dc263d2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -9,7 +9,7 @@ const guideSidebar = [ { text: 'Installation', link: '/guide/installation' }, { text: 'Authentication Setup', link: '/guide/authentication' }, { text: 'Configuration', link: '/guide/configuration' }, - { text: 'Agent Skills & Plugins', link: '/guide/claude-code-plugin' }, + { text: 'Agent Skills & Plugins', link: '/guide/agent-skills' }, ], }, { diff --git a/docs/guide/agent-skills.md b/docs/guide/agent-skills.md new file mode 100644 index 00000000..1c3378a9 --- /dev/null +++ b/docs/guide/agent-skills.md @@ -0,0 +1,181 @@ +# Agent Skills & Plugins + +The B2C Developer Tooling project provides agent skills that enhance the AI-assisted development experience when working with Salesforce B2C Commerce projects. + +These skills follow the [Agent Skills](https://agentskills.io/home) standard and can be used with multiple agentic IDEs including [Claude Code](https://claude.ai/code), Cursor, GitHub Copilot, and OpenAI Codex. + +## Overview + +When installed, the skills teach AI assistants about B2C Commerce development, CLI commands, and best practices, enabling them to help you with: + +- **CLI Operations** - Deploying cartridges, running jobs, managing sandboxes, WebDAV operations +- **Custom API Development** - Building SCAPI Custom APIs with contracts, implementations, and mappings +- **Best Practices** - Authentication patterns, troubleshooting workflows, and development practices + +## Available Plugins + +| Plugin | Description | +|--------|-------------| +| `b2c-cli` | Skills for B2C CLI commands and operations | +| `b2c` | Skills for B2C Commerce development practices | + +### Plugin: b2c-cli + +Skills for using the B2C CLI to manage your Commerce Cloud instances: + +| Skill | Description | +|-------|-------------| +| `b2c-code` | Code version deployment and management | +| `b2c-job` | Job execution and site archive import/export (IMPEX) | +| `b2c-sites` | Storefront sites listing and inspection | +| `b2c-webdav` | WebDAV file operations (ls, get, put, rm, zip, unzip) | +| `b2c-ods` | On-Demand Sandbox management | +| `b2c-mrt` | Managed Runtime project and deployment management | +| `b2c-slas` | SLAS client management | +| `b2c-scapi-custom` | Custom API endpoint status and management | + +### Plugin: b2c + +Skills for B2C Commerce development practices and patterns: + +| Skill | Description | +|-------|-------------| +| `b2c-custom-api-development` | Comprehensive guide for building SCAPI Custom APIs | + +The Custom API development skill covers: +- The three required components (contract, implementation, mapping) +- OAS 3.0 schema authoring with security schemes +- Implementation patterns with RESTResponseMgr +- Shopper vs Admin API differences +- Caching and remote includes +- Circuit breaker protection +- Troubleshooting workflows + +## Installation with Claude Code + +### Prerequisites + +- [Claude Code](https://claude.ai/code) installed and configured + +### Add the Marketplace + +First, add the B2C Developer Tooling marketplace: + +```bash +claude plugin marketplace add SalesforceCommerceCloud/b2c-developer-tooling +``` + +### Install Plugins + +Install the plugins at your preferred scope: + +::: code-group + +```bash [Project Scope] +# Available only in the current project +claude plugin install b2c-cli --scope project +claude plugin install b2c --scope project +``` + +```bash [User Scope] +# Available in all your projects +claude plugin install b2c-cli --scope user +claude plugin install b2c --scope user +``` + +::: + +### Verify Installation + +```bash +claude plugin list +``` + +You should see `b2c-cli@b2c-developer-tooling` and `b2c@b2c-developer-tooling` in the list. + +### Updating Plugins + +To get the latest plugin updates: + +```bash +claude plugin marketplace update +claude plugin update b2c-cli@b2c-developer-tooling +claude plugin update b2c@b2c-developer-tooling +``` + +### Uninstalling + +To remove the plugins: + +```bash +claude plugin uninstall b2c-cli@b2c-developer-tooling +claude plugin uninstall b2c@b2c-developer-tooling +``` + +To remove the marketplace: + +```bash +claude plugin marketplace remove b2c-developer-tooling +``` + +## Installation with Other IDEs + +The B2C skills follow the [Agent Skills](https://agentskills.io/home) standard and can be used with other AI-powered development tools. + +### CLI Setup Command + +::: warning Coming Soon +The `b2c setup skills` command is not yet available. Use the manual method below for now. +::: + +```bash +# Configure skills for your IDE (coming soon) +b2c setup skills --ide cursor +b2c setup skills --ide copilot +``` + +### Manual Setup + +You can manually copy skill files from the GitHub repository to your IDE's configuration: + +- **b2c-cli skills**: [plugins/b2c-cli/skills/](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/main/plugins/b2c-cli/skills) +- **b2c skills**: [plugins/b2c/skills/](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/main/plugins/b2c/skills) + +#### Cursor + +Copy the `SKILL.md` files to `.cursor/rules/` in your project or configure globally in Cursor settings. + +#### GitHub Copilot + +Append the skill content to `.github/copilot-instructions.md` in your repository. + +#### OpenAI Codex + +Configure per the OpenAI Codex documentation for custom instructions. + +## Usage Examples + +Once installed, you can ask your AI assistant to help with B2C Commerce tasks: + +**Deploy code:** +> "Deploy the cartridges in ./cartridges to my sandbox" + +**Check code versions:** +> "List all code versions on my instance and show which one is active" + +**Run a job:** +> "Run the reindex job on my sandbox" + +**Manage files:** +> "Download the latest log files from my instance" + +**Create a sandbox:** +> "Create a new On-Demand Sandbox with TTL of 48 hours" + +**Check Custom API status:** +> "Show me the status of my Custom API endpoints" + +**Build a Custom API:** +> "Help me create a Custom API for loyalty information" + +The AI will use the appropriate skills and CLI commands based on your request. diff --git a/docs/guide/claude-code-plugin.md b/docs/guide/claude-code-plugin.md deleted file mode 100644 index 85f421b9..00000000 --- a/docs/guide/claude-code-plugin.md +++ /dev/null @@ -1,159 +0,0 @@ -# Agent Skills & Claude Code Plugin - -The B2C CLI provides agent skills that enhance the AI-assisted development experience when working with Salesforce B2C Commerce projects. - -These skills follow the [Agent Skills](https://agentskills.io/home) standard and can be used with multiple agentic IDEs including [Claude Code](https://claude.ai/code), Cursor, GitHub Copilot, and OpenAI Codex. - -## Overview - -When installed, the skills teach AI assistants about B2C Commerce CLI commands and best practices, enabling them to help you with: - -- Deploying cartridges and managing code versions -- Running jobs and importing/exporting site archives -- Managing On-Demand Sandboxes and Managed Runtime environments -- WebDAV file operations -- SLAS client configuration - -## Installation - -### Prerequisites - -- [Claude Code](https://claude.ai/code) installed and configured - -### Add the Marketplace - -First, add the B2C Developer Tooling marketplace: - -```bash -claude plugin marketplace add SalesforceCommerceCloud/b2c-developer-tooling -``` - -### Install the Plugin - -Install the `b2c-cli` plugin at your preferred scope: - -::: code-group - -```bash [Project Scope] -# Available only in the current project -claude plugin install b2c-cli --scope project -``` - -```bash [User Scope] -# Available in all your projects -claude plugin install b2c-cli --scope user -``` - -::: - -### Verify Installation - -```bash -claude plugin list -``` - -You should see `b2c-cli@b2c-developer-tooling` in the list. - -## Available Skills - -The plugin includes skills for each major CLI topic: - -| Skill | Description | -|-------|-------------| -| `b2c-code` | Code version deployment and management | -| `b2c-job` | Job execution and site archive import/export (IMPEX) | -| `b2c-sites` | Storefront sites listing and inspection | -| `b2c-webdav` | WebDAV file operations (ls, get, put, rm, zip, unzip) | -| `b2c-ods` | On-Demand Sandbox management | -| `b2c-mrt` | Managed Runtime project and deployment management | -| `b2c-slas` | SLAS client management | - -## Usage Examples - -Once installed, you can ask Claude to help with B2C Commerce tasks: - -**Deploy code:** -> "Deploy the cartridges in ./cartridges to my sandbox" - -**Check code versions:** -> "List all code versions on my instance and show which one is active" - -**Run a job:** -> "Run the reindex job on my sandbox" - -**Manage files:** -> "Download the latest log files from my instance" - -**Create a sandbox:** -> "Create a new On-Demand Sandbox with TTL of 48 hours" - -Claude will use the appropriate `b2c` CLI commands based on your request and the skills it has learned. - -## Updating the Plugin - -To get the latest plugin updates: - -```bash -claude plugin marketplace update -claude plugin update b2c-cli@b2c-developer-tooling -``` - -## Uninstalling - -To remove the plugin: - -```bash -claude plugin uninstall b2c-cli@b2c-developer-tooling -``` - -To remove the marketplace: - -```bash -claude plugin marketplace remove b2c-developer-tooling -``` - -## Using Skills with Other IDEs - -The B2C CLI skills follow the [Agent Skills](https://agentskills.io/home) standard and can be used with other AI-powered development tools. - -### CLI Setup Command - -::: warning Coming Soon -The `b2c setup skills` command is not yet available. Use the manual method below for now. -::: - -```bash -# Configure skills for your IDE (coming soon) -b2c setup skills --ide cursor -b2c setup skills --ide copilot -``` - -### Manual Setup - -You can manually copy skill files from the [plugins/b2c-cli/skills/](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/main/plugins/b2c-cli/skills) directory to your IDE's configuration: - -#### Cursor - -Copy the skill SKILL.md files to `.cursor/rules/` in your project or configure globally in Cursor settings. - -#### GitHub Copilot - -Append the skill content to `.github/copilot-instructions.md` in your repository. - -#### OpenAI Codex - -Configure per the OpenAI Codex documentation for custom instructions. - -### Skill Files - -Each skill is a Markdown file containing instructions and examples: - -| Skill | File | -|-------|------| -| Code Deployment | `b2c-code/SKILL.md` | -| Job Execution | `b2c-job/SKILL.md` | -| Site Management | `b2c-sites/SKILL.md` | -| WebDAV Operations | `b2c-webdav/SKILL.md` | -| Sandbox Management | `b2c-ods/SKILL.md` | -| MRT Management | `b2c-mrt/SKILL.md` | -| SLAS Configuration | `b2c-slas/SKILL.md` | From 6de07ec8d05c27f935ceb59f15d4bdaf660fba09 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 9 Jan 2026 13:01:29 -0500 Subject: [PATCH 7/8] notes about tenant --- .../b2c-cli/skills/b2c-scapi-custom/SKILL.md | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md b/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md index 16a98786..75087aa9 100644 --- a/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md +++ b/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md @@ -7,46 +7,66 @@ description: Salesforce B2C Commerce Custom API endpoint management Skill Use the `b2c` CLI plugin to manage SCAPI Custom API endpoints and check their registration status. +## Required: Tenant ID + +The `--tenant-id` flag is **required** for all commands. The tenant ID identifies your B2C Commerce instance. + +**Important:** The tenant ID is NOT the same as the organization ID: +- **Tenant ID**: `zzxy_prd` (used with this command) +- **Organization ID**: `zzxy_prd` (used in SCAPI URLs, has `f_ecom_` prefix) + +### Deriving Tenant ID from Hostname + +For sandbox instances, you can derive the tenant ID from the hostname by replacing hyphens with underscores: + +| Hostname | Tenant ID | +|----------|-----------| +| `zzpq-013.dx.commercecloud.salesforce.com` | `zzpq_013` | +| `zzxy-001.dx.commercecloud.salesforce.com` | `zzxy_001` | +| `abcd-dev.dx.commercecloud.salesforce.com` | `abcd_dev` | + +For production instances, use your realm and instance identifier (e.g., `zzxy_prd`). + ## Examples ### Get Custom API Endpoint Status ```bash # list all Custom API endpoints for an organization -b2c scapi custom status --tenant-id f_ecom_zzxy_prd +b2c scapi custom status --tenant-id zzxy_prd # list with JSON output -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --json +b2c scapi custom status --tenant-id zzxy_prd --json ``` ### Filter by Status ```bash # list only active endpoints -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status active +b2c scapi custom status --tenant-id zzxy_prd --status active # list only endpoints that failed to register -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered +b2c scapi custom status --tenant-id zzxy_prd --status not_registered ``` ### Group by Type or Site ```bash # group endpoints by API type (Admin vs Shopper) -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by type +b2c scapi custom status --tenant-id zzxy_prd --group-by type # group endpoints by site -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by site +b2c scapi custom status --tenant-id zzxy_prd --group-by site ``` ### Customize Output Columns ```bash # show extended columns (includes error reasons, sites, etc.) -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --extended +b2c scapi custom status --tenant-id zzxy_prd --extended # select specific columns to display -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --columns type,apiName,status,sites +b2c scapi custom status --tenant-id zzxy_prd --columns type,apiName,status,sites # available columns: type, apiName, apiVersion, cartridgeName, endpointPath, httpMethod, status, sites, securityScheme, operationId, schemaFile, implementationScript, errorReason, id ``` @@ -55,13 +75,13 @@ b2c scapi custom status --tenant-id f_ecom_zzxy_prd --columns type,apiName,statu ```bash # quickly find and diagnose failed Custom API registrations -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered --columns type,apiName,endpointPath,errorReason +b2c scapi custom status --tenant-id zzxy_prd --status not_registered --columns type,apiName,endpointPath,errorReason ``` ### Configuration The tenant ID and short code can be set via environment variables: -- `SFCC_TENANT_ID`: Organization/tenant ID +- `SFCC_TENANT_ID`: Tenant ID (e.g., `zzxy_prd`, not the organization ID) - `SFCC_SHORTCODE`: SCAPI short code ### More Commands From 64527c86cd33b4a2666da385d77b07ee829a29ca Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sat, 10 Jan 2026 11:49:54 -0500 Subject: [PATCH 8/8] examples and org id fix --- docs/cli/custom-apis.md | 35 ++++++++++++++----- .../b2c-cli/skills/b2c-scapi-custom/SKILL.md | 4 +-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/cli/custom-apis.md b/docs/cli/custom-apis.md index 783d45d6..d0ea0198 100644 --- a/docs/cli/custom-apis.md +++ b/docs/cli/custom-apis.md @@ -79,29 +79,29 @@ The `type` column shows a human-readable API type based on the security scheme: ```bash # List all Custom API endpoints -b2c scapi custom status --tenant-id f_ecom_zzxy_prd +b2c scapi custom status --tenant-id zzxy_prd # Filter by status -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status active -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered +b2c scapi custom status --tenant-id zzxy_prd --status active +b2c scapi custom status --tenant-id zzxy_prd --status not_registered # Group by API type (Admin/Shopper) -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by type +b2c scapi custom status --tenant-id zzxy_prd --group-by type # Group by site -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --group-by site +b2c scapi custom status --tenant-id zzxy_prd --group-by site # Show extended columns -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --extended +b2c scapi custom status --tenant-id zzxy_prd --extended # Custom columns -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --columns type,apiName,status,sites +b2c scapi custom status --tenant-id zzxy_prd --columns type,apiName,status,sites # Debug failed registrations -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --status not_registered --columns type,apiName,endpointPath,errorReason +b2c scapi custom status --tenant-id zzxy_prd --status not_registered --columns type,apiName,endpointPath,errorReason # Output as JSON -b2c scapi custom status --tenant-id f_ecom_zzxy_prd --json +b2c scapi custom status --tenant-id zzxy_prd --json ``` ### Output @@ -138,6 +138,23 @@ loyalty-info /points GET active wishlist /items POST not_registered ``` +Grouped by site: + +``` +Site: RefArch +Type API Name Path Method Status +─────────────────────────────────────────────────────────── +Shopper loyalty-info /customers GET active +Shopper loyalty-info /points GET active +Admin inventory /stock GET active + +Site: SiteGenesis +Type API Name Path Method Status +───────────────────────────────────────────────── +Admin inventory /stock GET active +Admin inventory /stock PUT active +``` + ### Notes - Endpoints are rolled up by site: if the same endpoint is active on multiple sites, the sites are combined into a comma-separated list (visible with `--extended` or `--columns sites`) diff --git a/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md b/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md index 75087aa9..0a4e2fa6 100644 --- a/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md +++ b/plugins/b2c-cli/skills/b2c-scapi-custom/SKILL.md @@ -12,8 +12,8 @@ Use the `b2c` CLI plugin to manage SCAPI Custom API endpoints and check their re The `--tenant-id` flag is **required** for all commands. The tenant ID identifies your B2C Commerce instance. **Important:** The tenant ID is NOT the same as the organization ID: -- **Tenant ID**: `zzxy_prd` (used with this command) -- **Organization ID**: `zzxy_prd` (used in SCAPI URLs, has `f_ecom_` prefix) +- **Tenant ID**: `zzxy_prd` (used with commands that require `--tenant-id`) +- **Organization ID**: `f_ecom_zzxy_prd` (used in SCAPI URLs, has `f_ecom_` prefix) ### Deriving Tenant ID from Hostname