From baee3a3d3f145e09cbceccfc0a22401c3c11e6c9 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 12:22:19 -0400 Subject: [PATCH 01/11] Add SCAPI Jobs API support with backend abstraction Introduces a JobsBackend interface so job commands can transparently use either OCAPI or SCAPI. Auto mode prefers SCAPI when shortCode and tenantId are configured, falling back to OCAPI on invalid_scope errors. - New SCAPI Jobs client (operation/jobs/v1) with optimistic sfcc.jobs.rw scope and read-only downgrade for read operations - Canonical JobExecutionResult type bridges OCAPI snake_case and SCAPI camelCase response shapes - --api-backend flag and apiBackend dw.json field for explicit control - New job execution delete command (SCAPI only) - job:run, job:search, job:wait, job:log migrated to backend abstraction - job:import and job:export remain OCAPI-only for now --- .changeset/scapi-jobs-migration.md | 6 + docs/cli/jobs.md | 75 +- docs/guide/configuration.md | 1 + .../src/commands/job/execution/delete.ts | 55 ++ packages/b2c-cli/src/commands/job/log.ts | 38 +- packages/b2c-cli/src/commands/job/run.ts | 55 +- packages/b2c-cli/src/commands/job/search.ts | 33 +- packages/b2c-cli/src/commands/job/wait.ts | 19 +- .../commands/job/execution/delete.test.ts | 70 ++ .../b2c-cli/test/commands/job/log.test.ts | 90 +- .../b2c-cli/test/commands/job/run.test.ts | 83 +- .../b2c-cli/test/commands/job/search.test.ts | 33 +- .../b2c-cli/test/commands/job/wait.test.ts | 31 +- packages/b2c-tooling-sdk/package.json | 2 +- .../specs/operations-jobs-v1.yaml | 793 ++++++++++++++++++ packages/b2c-tooling-sdk/src/cli/config.ts | 2 + .../src/cli/instance-command.ts | 6 + .../b2c-tooling-sdk/src/cli/job-command.ts | 91 +- packages/b2c-tooling-sdk/src/clients/index.ts | 11 + .../src/clients/middleware-registry.ts | 3 +- .../src/clients/scapi-jobs.generated.ts | 535 ++++++++++++ .../b2c-tooling-sdk/src/clients/scapi-jobs.ts | 58 ++ .../b2c-tooling-sdk/src/config/dw-json.ts | 2 + .../b2c-tooling-sdk/src/config/mapping.ts | 8 + packages/b2c-tooling-sdk/src/config/types.ts | 4 + packages/b2c-tooling-sdk/src/index.ts | 14 + .../src/operations/jobs/backend.ts | 164 ++++ .../src/operations/jobs/index.ts | 8 + .../src/operations/jobs/ocapi-backend.ts | 99 +++ .../src/operations/jobs/scapi-backend.ts | 280 +++++++ .../src/operations/jobs/types.ts | 61 ++ skills/b2c-cli/skills/b2c-job/SKILL.md | 28 + 32 files changed, 2550 insertions(+), 208 deletions(-) create mode 100644 .changeset/scapi-jobs-migration.md create mode 100644 packages/b2c-cli/src/commands/job/execution/delete.ts create mode 100644 packages/b2c-cli/test/commands/job/execution/delete.test.ts create mode 100644 packages/b2c-tooling-sdk/specs/operations-jobs-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/types.ts diff --git a/.changeset/scapi-jobs-migration.md b/.changeset/scapi-jobs-migration.md new file mode 100644 index 000000000..f483fdbf5 --- /dev/null +++ b/.changeset/scapi-jobs-migration.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add SCAPI Jobs API support with automatic backend selection. Job commands (`job run`, `job search`, `job wait`, `job log`) now use SCAPI when `shortCode` and `tenantId` are configured, falling back to OCAPI if SCAPI scopes are unavailable. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. New `job execution delete` command (SCAPI only) deletes job execution records. diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index fd6244da7..fa5d5f73c 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -6,11 +6,51 @@ description: Commands for executing jobs, importing and exporting site archives, Commands for executing and monitoring jobs on B2C Commerce instances. +## API Backend + +Job commands support both OCAPI and SCAPI backends. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If SCAPI scopes are unavailable, the CLI falls back to OCAPI transparently. + +Use `--api-backend` to control explicitly: + +```bash +# Force SCAPI +b2c job run my-job --api-backend scapi + +# Force OCAPI +b2c job run my-job --api-backend ocapi + +# Auto-detect (default) +b2c job run my-job --api-backend auto +``` + +Or set in `dw.json`: + +```json +{ + "api-backend": "scapi" +} +``` + +Or via environment variable: `SFCC_API_BACKEND=scapi`. + +::: tip +The `job import` and `job export` commands currently use OCAPI only, regardless of the `--api-backend` setting. +::: + ## Authentication -Job commands require OAuth authentication with OCAPI permissions. +### SCAPI (recommended) + +When using SCAPI, your API client needs the appropriate scopes in Account Manager: + +| Scope | Operations | +|-------|------------| +| `sfcc.jobs.rw` | Execute, delete, search, and get job executions (recommended) | +| `sfcc.jobs` | Search and get job executions (read-only) | -### Required OCAPI Permissions +You also need `shortCode` and `tenantId` configured (in `dw.json` or via flags). + +### OCAPI Configure these resources in Business Manager under **Administration** > **Site Development** > **Open Commerce API Settings**: @@ -253,6 +293,37 @@ b2c job log my-custom-job > job.log --- +## b2c job execution delete + +Delete a job execution record. This command requires the SCAPI backend (`sfcc.jobs.rw` scope). + +### Usage + +```bash +b2c job execution delete JOBID EXECUTIONID +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `JOBID` | Job ID | Yes | +| `EXECUTIONID` | Execution ID to delete | Yes | + +### Examples + +```bash +# Delete a specific execution +b2c job execution delete my-job abc123-def456 +``` + +### Notes + +- Requires SCAPI backend — not available via OCAPI. +- Requires the `sfcc.jobs.rw` scope on your API client. + +--- + ## b2c job import Import a site archive to a B2C Commerce instance using the `sfcc-site-archive-import` system job. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c4c4fc9da..b2cffcedc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -236,6 +236,7 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). | `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | | `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | | `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | +| `api-backend` | API backend for operations: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set. | ### Two-Factor Authentication (mTLS) diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts new file mode 100644 index 000000000..56272b463 --- /dev/null +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class JobExecutionDelete extends JobCommand { + static args = { + jobId: Args.string({ + description: 'Job ID', + required: true, + }), + executionId: Args.string({ + description: 'Execution ID to delete', + required: true, + }), + }; + + static description = withDocs( + t('commands.job.execution.delete.description', 'Delete a job execution record (requires SCAPI)'), + '/cli/jobs.html#b2c-job-execution-delete', + ); + + static examples = [ + '<%= config.bin %> <%= command.id %> my-job abc123-def456', + '<%= config.bin %> <%= command.id %> my-job abc123-def456 --api-backend scapi', + ]; + + static flags = { + ...JobCommand.baseFlags, + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {jobId, executionId} = this.args; + + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for execution delete`); + + this.log( + t('commands.job.execution.delete.deleting', 'Deleting execution {{executionId}} for job {{jobId}}...', { + jobId, + executionId, + }), + ); + + await backend.deleteJobExecution(jobId, executionId); + + this.log(t('commands.job.execution.delete.deleted', 'Execution {{executionId}} deleted.', {executionId})); + } +} diff --git a/packages/b2c-cli/src/commands/job/log.ts b/packages/b2c-cli/src/commands/job/log.ts index 7771bd878..5103077b0 100644 --- a/packages/b2c-cli/src/commands/job/log.ts +++ b/packages/b2c-cli/src/commands/job/log.ts @@ -4,22 +4,17 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import { - searchJobExecutions, - getJobExecution, - getJobLog, - type JobExecution, -} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type JobExecutionResult} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; import {highlightLogText} from '../../utils/logs/index.js'; interface JobLogResult { - execution: JobExecution; + execution: JobExecutionResult; log: string; } -export default class JobLog extends InstanceCommand { +export default class JobLog extends JobCommand { static args = { jobId: Args.string({ description: 'Job ID', @@ -46,7 +41,7 @@ export default class JobLog extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...JobCommand.baseFlags, failed: Flags.boolean({ description: 'Find the most recent failed execution with a log', default: false, @@ -57,19 +52,16 @@ export default class JobLog extends InstanceCommand { }), }; - protected operations = { - searchJobExecutions, - getJobExecution, - getJobLog, - }; - async run(): Promise { this.requireOAuthCredentials(); const {jobId, executionId} = this.args; const {failed} = this.flags; - let execution: JobExecution; + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for job log`); + + let execution: JobExecutionResult; if (executionId) { this.log( @@ -78,7 +70,7 @@ export default class JobLog extends InstanceCommand { executionId, }), ); - execution = await this.operations.getJobExecution(this.instance, jobId, executionId); + execution = await backend.getJobExecution(jobId, executionId); } else { this.log( failed @@ -92,7 +84,7 @@ export default class JobLog extends InstanceCommand { }), ); - const results = await this.operations.searchJobExecutions(this.instance, { + const results = await backend.searchJobExecutions({ jobId, status: failed ? ['ERROR'] : undefined, count: 10, @@ -100,7 +92,7 @@ export default class JobLog extends InstanceCommand { sortOrder: 'desc', }); - const match = results.hits.find((hit) => hit.is_log_file_existing); + const match = results.hits.find((hit) => hit.isLogFileExisting); if (!match) { const msg = failed ? t( @@ -117,18 +109,18 @@ export default class JobLog extends InstanceCommand { execution = match; } - if (!execution.is_log_file_existing) { + if (!execution.isLogFileExisting) { this.error(t('commands.job.log.noLogFile', 'No log file exists for this execution')); } this.log( t('commands.job.log.foundExecution', 'Found execution {{executionId}} ({{status}})', { executionId: execution.id ?? 'unknown', - status: execution.exit_status?.code || execution.execution_status || 'unknown', + status: execution.exitStatus?.code || execution.executionStatus || 'unknown', }), ); - const log = await this.operations.getJobLog(this.instance, execution); + const log = await backend.getJobLog(execution); if (!this.jsonEnabled()) { const useColor = !this.flags['no-color'] && process.stdout.isTTY; diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 961d7ffcd..112656b66 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -6,10 +6,10 @@ import {Args, Flags} from '@oclif/core'; import {JobCommand, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; import { - executeJob, - waitForJob, + waitForJobExecution, JobExecutionError, - type JobExecution, + type JobsBackend, + type JobExecutionResult, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -76,12 +76,7 @@ export default class JobRun extends JobCommand { }), }; - protected operations = { - executeJob, - waitForJob, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId} = this.args; @@ -96,7 +91,6 @@ export default class JobRun extends JobCommand { } = this.flags; // Safety evaluation — check rules for this job before executing. - // Command-level rules are already evaluated generically in BaseCommand.init(). const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId}); if (jobEvaluation.action === 'block') { this.error(jobEvaluation.reason, {exit: 1}); @@ -109,6 +103,11 @@ export default class JobRun extends JobCommand { const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; + // When --body is used with auto mode, force OCAPI since raw bodies use OCAPI format + const backend = this.resolveBackend(rawBody); + + this.logger.debug(`Using ${backend.name} backend for job operations`); + // Create lifecycle context const context = this.createContext('job:run', { jobId, @@ -126,8 +125,11 @@ export default class JobRun extends JobCommand { reason: beforeResult.skipReason || 'skipped by plugin', }), ); - // Return a mock execution for JSON output - return {execution_status: 'finished', exit_status: {code: 'skipped'}} as unknown as JobExecution; + return { + id: '', + jobId, + executionStatus: 'finished', + } as unknown as JobExecutionResult; } this.log( @@ -137,9 +139,9 @@ export default class JobRun extends JobCommand { }), ); - let execution: JobExecution; + let execution: JobExecutionResult; try { - execution = await this.operations.executeJob(this.instance, jobId, { + execution = await backend.executeJob(jobId, { parameters: rawBody ? undefined : parameters, body: rawBody, waitForRunning: !noWaitRunning, @@ -151,13 +153,14 @@ export default class JobRun extends JobCommand { this.log( t('commands.job.run.started', 'Job started: {{executionId}} (status: {{status}})', { executionId: execution.id, - status: execution.execution_status, + status: execution.executionStatus, }), ); // Wait for completion if requested if (wait) { execution = await this.waitForJobCompletion({ + backend, jobId, executionId: execution.id!, timeout, @@ -166,7 +169,6 @@ export default class JobRun extends JobCommand { context, }); } else { - // Not waiting - run afterOperation hooks with current state await this.runAfterHooks(context, { success: true, duration: Date.now() - context.startTime, @@ -177,8 +179,16 @@ export default class JobRun extends JobCommand { return execution; } + private resolveBackend(rawBody: Record | undefined): JobsBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + if (rawBody && preference === 'auto') { + this.logger.debug('Raw body provided with auto mode; using OCAPI backend'); + return this.createJobsBackend(); + } + return this.createJobsBackend(); + } + private handleExecutionError(error: unknown, context: B2COperationContext): never { - // Run afterOperation hooks with failure (fire-and-forget, errors ignored) this.runAfterHooks(context, { success: false, error: error instanceof Error ? error : new Error(String(error)), @@ -192,7 +202,6 @@ export default class JobRun extends JobCommand { } private async handleWaitError(error: unknown, showLog: boolean, context: B2COperationContext): Promise { - // Run afterOperation hooks with failure await this.runAfterHooks(context, { success: false, error: error instanceof Error ? error : new Error(String(error)), @@ -237,18 +246,19 @@ export default class JobRun extends JobCommand { } private async waitForJobCompletion(options: { + backend: JobsBackend; jobId: string; executionId: string; timeout: number | undefined; pollInterval: number | undefined; showLog: boolean; context: B2COperationContext; - }): Promise { - const {jobId, executionId, timeout, pollInterval, showLog, context} = options; + }): Promise { + const {backend, jobId, executionId, timeout, pollInterval, showLog, context} = options; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); try { - const execution = await this.operations.waitForJob(this.instance, jobId, executionId, { + const execution = await waitForJobExecution(backend, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { @@ -266,12 +276,11 @@ export default class JobRun extends JobCommand { const durationSec = execution.duration ? (execution.duration / 1000).toFixed(1) : 'N/A'; this.log( t('commands.job.run.completed', 'Job completed: {{status}} (duration: {{duration}}s)', { - status: execution.exit_status?.code || execution.execution_status, + status: execution.exitStatus?.code || execution.executionStatus, duration: durationSec, }), ); - // Run afterOperation hooks with success await this.runAfterHooks(context, { success: true, duration: Date.now() - context.startTime, diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index fac40acd7..e4112f33e 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -4,36 +4,32 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags, ux} from '@oclif/core'; -import {InstanceCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import { - searchJobExecutions, - type JobExecutionSearchResult, - type JobExecution, -} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {JobCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type JobExecutionResult, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'Execution ID', get: (e) => e.id ?? '-', }, jobId: { header: 'Job ID', - get: (e) => e.job_id ?? '-', + get: (e) => e.jobId ?? '-', }, status: { header: 'Status', - get: (e) => e.exit_status?.code || e.execution_status || '-', + get: (e) => e.exitStatus?.code || e.executionStatus || '-', }, startTime: { header: 'Start Time', - get: (e) => (e.start_time ? new Date(e.start_time).toISOString().replace('T', ' ').slice(0, 19) : '-'), + get: (e) => (e.startTime ? new Date(e.startTime).toISOString().replace('T', ' ').slice(0, 19) : '-'), }, }; const DEFAULT_COLUMNS = ['id', 'jobId', 'status', 'startTime']; -export default class JobSearch extends InstanceCommand { +export default class JobSearch extends JobCommand { static description = withDocs( t('commands.job.search.description', 'Search for job executions on a B2C Commerce instance'), '/cli/jobs.html#b2c-job-search', @@ -50,7 +46,7 @@ export default class JobSearch extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...JobCommand.baseFlags, 'job-id': Flags.string({ char: 'j', description: 'Filter by job ID', @@ -82,22 +78,21 @@ export default class JobSearch extends InstanceCommand { }), }; - protected operations = { - searchJobExecutions, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {'job-id': jobId, status, count, start, 'sort-by': sortBy, 'sort-order': sortOrder} = this.flags; + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for job search`); + this.log( t('commands.job.search.searching', 'Searching job executions on {{hostname}}...', { hostname: this.resolvedConfig.values.hostname!, }), ); - const results = await this.operations.searchJobExecutions(this.instance, { + const results = await backend.searchJobExecutions({ jobId, status, count, @@ -106,12 +101,10 @@ export default class JobSearch extends InstanceCommand { sortOrder: sortOrder as 'asc' | 'desc', }); - // JSON output handled by oclif if (this.jsonEnabled()) { return results; } - // Human-readable output if (results.total === 0) { ux.stdout(t('commands.job.search.noResults', 'No job executions found.')); return results; diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index 6e43410c7..693977ad9 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -5,7 +5,11 @@ */ import {Args, Flags} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {waitForJob, JobExecutionError, type JobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import { + waitForJobExecution, + JobExecutionError, + type JobExecutionResult, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; export default class JobWait extends JobCommand { @@ -49,16 +53,15 @@ export default class JobWait extends JobCommand { }), }; - protected operations = { - waitForJob, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId, executionId} = this.args; const {timeout, 'poll-interval': pollInterval, 'show-log': showLog} = this.flags; + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for job wait`); + this.log( t('commands.job.wait.waiting', 'Waiting for job {{jobId}} execution {{executionId}}...', { jobId, @@ -67,7 +70,7 @@ export default class JobWait extends JobCommand { ); try { - const execution = await this.operations.waitForJob(this.instance, jobId, executionId, { + const execution = await waitForJobExecution(backend, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { @@ -85,7 +88,7 @@ export default class JobWait extends JobCommand { const durationSec = execution.duration ? (execution.duration / 1000).toFixed(1) : 'N/A'; this.log( t('commands.job.wait.completed', 'Job completed: {{status}} (duration: {{duration}}s)', { - status: execution.exit_status?.code || execution.execution_status, + status: execution.exitStatus?.code || execution.executionStatus, duration: durationSec, }), ); diff --git a/packages/b2c-cli/test/commands/job/execution/delete.test.ts b/packages/b2c-cli/test/commands/job/execution/delete.test.ts new file mode 100644 index 000000000..4f087a6f3 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/execution/delete.test.ts @@ -0,0 +1,70 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobExecutionDelete from '../../../../src/commands/job/execution/delete.js'; +import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; + +describe('job execution delete', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobExecutionDelete, hooks.getConfig(), flags, args); + } + + function createMockBackend() { + return { + name: 'scapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; + } + + it('deletes a job execution', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + const backend = stubCommon(command); + backend.deleteJobExecution.resolves(); + + await runSilent(() => command.run()); + + expect(backend.deleteJobExecution.calledOnce).to.equal(true); + expect(backend.deleteJobExecution.getCall(0).args[0]).to.equal('my-job'); + expect(backend.deleteJobExecution.getCall(0).args[1]).to.equal('exec-1'); + }); + + it('throws when OCAPI backend does not support delete', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + const backend = stubCommon(command); + backend.deleteJobExecution.rejects( + new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'), + ); + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.include('not supported via OCAPI'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/job/log.test.ts b/packages/b2c-cli/test/commands/job/log.test.ts index 9358c83e1..3c9aca707 100644 --- a/packages/b2c-cli/test/commands/job/log.test.ts +++ b/packages/b2c-cli/test/commands/job/log.test.ts @@ -21,80 +21,86 @@ describe('job log', () => { return createTestCommand(JobLog, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + function stubCommon(command: any) { - const instance = {config: {hostname: 'example.com'}}; sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); - return instance; + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; } it('fetches log for a specific execution', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; - const getJobExecutionStub = sinon.stub().resolves(execution); - const getJobLogStub = sinon.stub().resolves('log content here'); - command.operations = {...command.operations, getJobExecution: getJobExecutionStub, getJobLog: getJobLogStub}; + const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; + backend.getJobExecution.resolves(execution); + backend.getJobLog.resolves('log content here'); const result = (await runSilent(() => command.run())) as {execution: unknown; log: string}; - expect(getJobExecutionStub.calledOnce).to.equal(true); - expect(getJobExecutionStub.getCall(0).args[0]).to.equal(instance); - expect(getJobExecutionStub.getCall(0).args[1]).to.equal('my-job'); - expect(getJobExecutionStub.getCall(0).args[2]).to.equal('exec-1'); - expect(getJobLogStub.calledOnce).to.equal(true); + expect(backend.getJobExecution.calledOnce).to.equal(true); + expect(backend.getJobExecution.getCall(0).args[0]).to.equal('my-job'); + expect(backend.getJobExecution.getCall(0).args[1]).to.equal('exec-1'); + expect(backend.getJobLog.calledOnce).to.equal(true); expect(result.log).to.equal('log content here'); expect(result.execution).to.equal(execution); }); it('searches for most recent execution with log', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execWithoutLog = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false}; - const execWithLog = {id: 'exec-2', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; - const searchStub = sinon.stub().resolves({total: 2, hits: [execWithoutLog, execWithLog]}); - const getJobLogStub = sinon.stub().resolves('log from exec-2'); - command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub}; + const execWithoutLog = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; + const execWithLog = {id: 'exec-2', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; + backend.searchJobExecutions.resolves({total: 2, hits: [execWithoutLog, execWithLog]}); + backend.getJobLog.resolves('log from exec-2'); const result = (await runSilent(() => command.run())) as {log: string}; - expect(searchStub.calledOnce).to.equal(true); - expect(searchStub.getCall(0).args[0]).to.equal(instance); - expect(searchStub.getCall(0).args[1]).to.deep.include({jobId: 'my-job'}); - expect(getJobLogStub.calledOnce).to.equal(true); - expect(getJobLogStub.getCall(0).args[1]).to.equal(execWithLog); + expect(backend.searchJobExecutions.calledOnce).to.equal(true); + expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({jobId: 'my-job'}); + expect(backend.getJobLog.calledOnce).to.equal(true); + expect(backend.getJobLog.getCall(0).args[0]).to.equal(execWithLog); expect(result.log).to.equal('log from exec-2'); }); it('searches for most recent failed execution with --failed', async () => { const command: any = await createCommand({failed: true}, {jobId: 'my-job'}); - stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-3', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'ERROR'}}; - const searchStub = sinon.stub().resolves({total: 1, hits: [execution]}); - const getJobLogStub = sinon.stub().resolves('error log'); - command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub}; + const execution = {id: 'exec-3', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'ERROR'}}; + backend.searchJobExecutions.resolves({total: 1, hits: [execution]}); + backend.getJobLog.resolves('error log'); const result = (await runSilent(() => command.run())) as {log: string}; - expect(searchStub.getCall(0).args[1]).to.deep.include({status: ['ERROR']}); + expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({status: ['ERROR']}); expect(result.log).to.equal('error log'); }); it('errors when specific execution has no log file', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - stubCommon(command); + const backend = stubCommon(command); - const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false}; - sinon.stub().resolves(execution); - command.operations = {...command.operations, getJobExecution: sinon.stub().resolves(execution)}; + const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; + backend.getJobExecution.resolves(execution); try { await command.run(); @@ -106,10 +112,9 @@ describe('job log', () => { it('errors when no executions with log found', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - stubCommon(command); + const backend = stubCommon(command); - const searchStub = sinon.stub().resolves({total: 0, hits: []}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + backend.searchJobExecutions.resolves({total: 0, hits: []}); try { await command.run(); @@ -121,15 +126,12 @@ describe('job log', () => { it('returns structured result in json mode', async () => { const command: any = await createCommand({json: true}, {jobId: 'my-job', executionId: 'exec-1'}); - stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; - command.operations = { - ...command.operations, - getJobExecution: sinon.stub().resolves(execution), - getJobLog: sinon.stub().resolves('json log content'), - }; + const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; + backend.getJobExecution.resolves(execution); + backend.getJobLog.resolves('json log content'); const result = await command.run(); diff --git a/packages/b2c-cli/test/commands/job/run.test.ts b/packages/b2c-cli/test/commands/job/run.test.ts index ace94e0da..0734c82a3 100644 --- a/packages/b2c-cli/test/commands/job/run.test.ts +++ b/packages/b2c-cli/test/commands/job/run.test.ts @@ -21,18 +21,30 @@ describe('job run', () => { return createTestCommand(JobRun, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + function stubCommon(command: any) { - const instance = {config: {hostname: 'example.com'}}; sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ operationType, metadata, startTime: Date.now(), })); - return instance; + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; } it('errors on invalid -P param format', async () => { @@ -53,39 +65,41 @@ describe('job run', () => { it('executes without waiting when --wait is false', async () => { const command: any = await createCommand({param: ['A=1'], json: true}, {jobId: 'my-job'}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); - const waitStub = sinon.stub().rejects(new Error('Unexpected wait')); - command.operations = {...command.operations, executeJob: execStub, waitForJob: waitStub}; + backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); const result = await command.run(); - expect(execStub.calledOnce).to.equal(true); - expect(execStub.getCall(0).args[0]).to.equal(instance); - expect(waitStub.called).to.equal(false); + expect(backend.executeJob.calledOnce).to.equal(true); + expect(backend.executeJob.getCall(0).args[0]).to.equal('my-job'); expect(result.id).to.equal('e1'); }); it('waits when --wait is true', async () => { - const command: any = await createCommand({wait: true, timeout: 1, json: true}, {jobId: 'my-job'}); - const instance = stubCommon(command); + const command: any = await createCommand( + {wait: true, timeout: 10, 'poll-interval': 1, json: true}, + {jobId: 'my-job'}, + ); + const backend = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); - const waitStub = sinon.stub().resolves({id: 'e1', execution_status: 'finished'}); - command.operations = {...command.operations, executeJob: execStub, waitForJob: waitStub}; + backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); + backend.getJobExecution.resolves({ + id: 'e1', + executionStatus: 'finished', + exitStatus: {code: 'OK', status: 'ok'}, + }); const result = await command.run(); - expect(waitStub.calledOnce).to.equal(true); - expect(waitStub.getCall(0).args[0]).to.equal(instance); - expect(result.execution_status).to.equal('finished'); + expect(backend.getJobExecution.called).to.equal(true); + expect(result.executionStatus).to.equal('finished'); }); it('returns early when before hooks skip', async () => { @@ -96,7 +110,7 @@ describe('job run', () => { const result = await command.run(); - expect(result.exit_status.code).to.equal('skipped'); + expect(result.executionStatus).to.equal('finished'); }); it('errors on invalid --body JSON', async () => { @@ -114,35 +128,4 @@ describe('job run', () => { expect(errorStub.calledOnce).to.equal(true); }); - - it('shows job log and errors on JobExecutionError when waiting and show-log is true', async () => { - const command: any = await createCommand({wait: true, json: true, 'show-log': true}, {jobId: 'my-job'}); - stubCommon(command); - - command.flags = {...command.flags, wait: true, json: true, 'show-log': true}; - - sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); - sinon.stub(command, 'runAfterHooks').resolves(void 0); - const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); - command.operations = {...command.operations, executeJob: execStub}; - sinon.stub(command, 'showJobLog').resolves(void 0); - - const exec: any = {execution_status: 'finished', exit_status: {code: 'ERROR'}}; - const {JobExecutionError} = await import('@salesforce/b2c-tooling-sdk/operations/jobs'); - const jobError = new JobExecutionError('failed', exec); - expect(jobError).to.be.instanceOf(JobExecutionError); - const waitStub = sinon.stub().rejects(jobError); - command.operations = {...command.operations, waitForJob: waitStub}; - - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); - - try { - await command.run(); - expect.fail('Should have thrown'); - } catch { - // expected - } - - expect(errorStub.called).to.equal(true); - }); }); diff --git a/packages/b2c-cli/test/commands/job/search.test.ts b/packages/b2c-cli/test/commands/job/search.test.ts index dba4f9bd3..cbbbfa318 100644 --- a/packages/b2c-cli/test/commands/job/search.test.ts +++ b/packages/b2c-cli/test/commands/job/search.test.ts @@ -22,45 +22,54 @@ describe('job search', () => { return createTestCommand(JobSearch, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + function stubCommon(command: any) { - const instance = {config: {hostname: 'example.com'}}; sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); - return instance; + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; } it('returns results in json mode', async () => { const command: any = await createCommand({json: true}, {}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const searchStub = sinon.stub().resolves({total: 1, hits: [{id: 'e1'}]}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + backend.searchJobExecutions.resolves({total: 1, hits: [{id: 'e1'}]}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); - expect(searchStub.calledOnce).to.equal(true); - expect(searchStub.getCall(0).args[0]).to.equal(instance); + expect(backend.searchJobExecutions.calledOnce).to.equal(true); expect(uxStub.called).to.equal(false); expect(result.total).to.equal(1); }); it('prints no results in non-json mode', async () => { const command: any = await createCommand({}, {}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const searchStub = sinon.stub().resolves({total: 0, hits: []}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + backend.searchJobExecutions.resolves({total: 0, hits: []}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); expect(result.total).to.equal(0); expect(uxStub.calledOnce).to.equal(true); - expect(searchStub.getCall(0).args[0]).to.equal(instance); + expect(backend.searchJobExecutions.calledOnce).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/job/wait.test.ts b/packages/b2c-cli/test/commands/job/wait.test.ts index fd2600e22..eae257426 100644 --- a/packages/b2c-cli/test/commands/job/wait.test.ts +++ b/packages/b2c-cli/test/commands/job/wait.test.ts @@ -21,23 +21,38 @@ describe('job wait', () => { return createTestCommand(JobWait, hooks.getConfig(), flags, args); } - it('waits using wrapper without real polling', async () => { - const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } - const instance = {config: {hostname: 'example.com'}}; + it('waits using backend polling', async () => { + const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'jsonEnabled').returns(true); - const waitStub = sinon.stub().resolves({id: 'e1', execution_status: 'finished'}); - command.operations = {...command.operations, waitForJob: waitStub}; + const backend = createMockBackend(); + backend.getJobExecution.resolves({ + id: 'e1', + jobId: 'my-job', + executionStatus: 'finished', + exitStatus: {code: 'OK', status: 'ok'}, + }); + sinon.stub(command, 'createJobsBackend').returns(backend); const result = await command.run(); - expect(waitStub.calledOnce).to.equal(true); - expect(waitStub.getCall(0).args[0]).to.equal(instance); + expect(backend.getJobExecution.called).to.equal(true); expect(result.id).to.equal('e1'); }); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index cef85cb60..f5264eee1 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -397,7 +397,7 @@ "data" ], "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 && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.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/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.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/operations-jobs-v1.yaml b/packages/b2c-tooling-sdk/specs/operations-jobs-v1.yaml new file mode 100644 index 000000000..dc13f3242 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/operations-jobs-v1.yaml @@ -0,0 +1,793 @@ +openapi: 3.0.3 +info: + title: Jobs + version: 1.0.0 + x-api-type: Admin + x-api-family: Operation +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/operation/jobs/v1" + variables: + shortCode: + default: 123456gf +paths: + /organizations/{organizationId}/job-execution-search: + post: + operationId: searchJobExecutions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecutionSearchRequest" + required: true + responses: + 200: + description: Returns job execution search results + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecutionSearchResult" + 400: + description: Bad Request - Malformed search query or invalid parameters + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs, sfcc.jobs.rw] + /organizations/{organizationId}/jobs/{jobId}/executions: + post: + operationId: createJobExecution + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecutionRequest" + required: false + responses: + 200: + description: The job execution was successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecution" + 400: + description: Bad Request - Invalid job execution request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Job not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs.rw] + /organizations/{organizationId}/jobs/{jobId}/executions/{executionId}: + get: + operationId: getJobExecution + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: executionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Returns the job execution details + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecution" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Job execution not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs, sfcc.jobs.rw] + delete: + operationId: deleteJobExecution + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: executionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The job execution was successfully deleted + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Job execution not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Query: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolQuery: + $ref: "#/components/schemas/BoolQuery" + filteredQuery: + $ref: "#/components/schemas/FilteredQuery" + matchAllQuery: + $ref: "#/components/schemas/MatchAllQuery" + nestedQuery: + $ref: "#/components/schemas/NestedQuery" + termQuery: + $ref: "#/components/schemas/TermQuery" + textQuery: + $ref: "#/components/schemas/TextQuery" + BoolQuery: + type: object + additionalProperties: false + properties: + must: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + mustNot: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + should: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + Filter: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolFilter: + $ref: "#/components/schemas/BoolFilter" + queryFilter: + $ref: "#/components/schemas/QueryFilter" + range2Filter: + $ref: "#/components/schemas/Range2Filter" + rangeFilter: + $ref: "#/components/schemas/RangeFilter" + termFilter: + $ref: "#/components/schemas/TermFilter" + BoolFilter: + type: object + additionalProperties: false + properties: + filters: + type: array + items: + $ref: "#/components/schemas/Filter" + type: string + operator: + type: string + enum: [and, or, not] + required: [operator] + QueryFilter: + type: object + properties: + query: + $ref: "#/components/schemas/Query" + required: [query] + Field: + type: string + maxLength: 260 + Range2Filter: + type: object + additionalProperties: false + properties: + filterMode: + type: string + default: overlap + enum: [overlap, containing, contained] + fromField: + allOf: + - $ref: "#/components/schemas/Field" + fromInclusive: + type: boolean + default: true + fromValue: {} + toField: + allOf: + - $ref: "#/components/schemas/Field" + toInclusive: + type: boolean + default: true + toValue: {} + required: [fromField, toField] + RangeFilter: + type: object + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + from: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + fromInclusive: + type: boolean + default: true + to: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + toInclusive: + type: boolean + default: true + required: [field] + TermFilter: + type: object + additionalProperties: false + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + type: string + required: [field, operator] + FilteredQuery: + type: object + additionalProperties: false + properties: + filter: + $ref: "#/components/schemas/Filter" + query: + $ref: "#/components/schemas/Query" + required: [filter, query] + MatchAllQuery: + type: object + NestedQuery: + type: object + additionalProperties: false + properties: + path: + type: string + maxLength: 2048 + query: + $ref: "#/components/schemas/Query" + scoreMode: + type: string + enum: [avg, total, max, none] + required: [path, query] + TermQuery: + type: object + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + - type: integer + type: string + required: [fields, operator] + TextQuery: + type: object + additionalProperties: false + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + searchPhrase: + type: string + required: [fields, searchPhrase] + Sort: + type: object + additionalProperties: false + properties: + field: + type: string + maxLength: 256 + sortOrder: + type: string + default: asc + enum: [asc, desc] + required: [field] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + SearchRequest: + type: object + properties: + limit: + type: integer + format: int32 + maximum: 200 + minimum: 1 + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + offset: + $ref: "#/components/schemas/Offset" + required: [query] + JobExecutionSearchRequest: + allOf: + - $ref: "#/components/schemas/SearchRequest" + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + PaginatedSearchResult: + additionalProperties: false + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + hits: + type: array + items: + type: object + required: [query] + ExecutionStatus: + type: string + enum: [pending, running, pausing, paused, resuming, resumed, restarting, restarted, retrying, retried, aborting, aborted, finished, unknown] + ExitStatus: + type: object + properties: + code: + type: string + maxLength: 256 + message: + type: string + maxLength: 4000 + status: + type: string + enum: [ok, error] + StatusMetadata: + type: object + properties: + clientId: + type: string + maxLength: 256 + reason: + type: string + maxLength: 4000 + userLogin: + type: string + maxLength: 256 + JobParameter: + type: object + properties: + name: + type: string + maxLength: 256 + minLength: 1 + pattern: \S|(\S(.*)\S) + value: + type: string + maxLength: 1000 + minLength: 0 + pattern: \S|(\S(.*)\S) + required: [name, value] + JobExecutionRetryInformation: + type: object + properties: + currentRetryAttempt: + type: integer + format: int32 + maxRetries: + type: integer + format: int32 + JobExecutionContinueInformation: + type: object + properties: + isPending: + type: boolean + continueStatus: + type: string + maxLength: 256 + JobStepExecution: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + stepId: + type: string + maxLength: 256 + minLength: 1 + stepDescription: + type: string + maxLength: 4000 + stepTypeId: + type: string + maxLength: 256 + stepTypeInfo: + type: string + maxLength: 4000 + executionScope: + type: string + maxLength: 256 + executionStatus: + allOf: + - $ref: "#/components/schemas/ExecutionStatus" + status: + type: string + maxLength: 256 + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + duration: + type: integer + format: int64 + modificationTime: + type: string + format: date-time + statusMetadata: + allOf: + - $ref: "#/components/schemas/StatusMetadata" + exitStatus: + allOf: + - $ref: "#/components/schemas/ExitStatus" + includeStepsFromJobId: + type: string + maxLength: 256 + isChunkOriented: + type: boolean + chunkSize: + type: integer + format: int32 + itemFilterCount: + type: integer + format: int32 + itemWriteCount: + type: integer + format: int32 + totalItemCount: + type: integer + format: int64 + JobExecution: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + jobId: + type: string + maxLength: 256 + minLength: 1 + jobDescription: + type: string + maxLength: 4000 + clientId: + type: string + maxLength: 256 + userLogin: + type: string + maxLength: 256 + executionStatus: + allOf: + - $ref: "#/components/schemas/ExecutionStatus" + status: + type: string + maxLength: 256 + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + creationDate: + type: string + format: date-time + duration: + type: integer + format: int64 + effectiveDuration: + type: integer + format: int64 + modificationTime: + type: string + format: date-time + lastModified: + type: string + format: date-time + executedServerId: + type: string + maxLength: 256 + exitStatus: + allOf: + - $ref: "#/components/schemas/ExitStatus" + statusMetadata: + allOf: + - $ref: "#/components/schemas/StatusMetadata" + isLogFileExisting: + type: boolean + isRestart: + type: boolean + logFilePath: + type: string + maxLength: 4000 + parameters: + type: array + items: + $ref: "#/components/schemas/JobParameter" + type: string + executionScopes: + type: array + items: + type: string + maxLength: 256 + retryInformation: + allOf: + - $ref: "#/components/schemas/JobExecutionRetryInformation" + continueInformation: + allOf: + - $ref: "#/components/schemas/JobExecutionContinueInformation" + stepExecutions: + type: array + items: + $ref: "#/components/schemas/JobStepExecution" + type: string + required: [id, jobId, status] + JobExecutionSearchResult: + allOf: + - $ref: "#/components/schemas/PaginatedSearchResult" + properties: + hits: + type: array + items: + $ref: "#/components/schemas/JobExecution" + type: string + required: [hits, query] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + JobExecutionRequest: + type: object + properties: + parameters: + type: array + items: + $ref: "#/components/schemas/JobParameter" + type: string + responses: + 401unauthorized: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403forbidden: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + jobId: + name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + executionId: + name: executionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.jobs: Read access to job resources + sfcc.jobs.rw: Read and write access to job resources diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 27ac12d4a..f51111fde 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -112,6 +112,8 @@ export function extractInstanceFlags(flags: ParsedFlags): Partial extends OAuthCom allowNo: true, helpGroup: 'AUTH', }), + 'api-backend': Flags.option({ + description: 'API backend for operations (auto detects SCAPI availability)', + options: ['ocapi', 'scapi', 'auto'] as const, + env: 'SFCC_API_BACKEND', + helpGroup: 'INSTANCE', + })(), }; private _instance?: B2CInstance; diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index 29db7725d..bb901e6f3 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -6,41 +6,85 @@ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; import {getJobLog, getJobErrorMessage, type JobExecution} from '../operations/jobs/index.js'; +import {createJobsBackend, type JobsBackend, type JobExecutionResult} from '../operations/jobs/index.js'; import {t} from '../i18n/index.js'; /** * Base command for job operations. * * Extends InstanceCommand with job-specific functionality like - * displaying job logs on failure. + * displaying job logs on failure and creating backend-aware job clients. * * @example * export default class MyJobCommand extends JobCommand { * async run(): Promise { - * try { - * await executeJob(this.instance, 'my-job'); - * } catch (error) { - * if (error instanceof JobExecutionError) { - * await this.showJobLog(error.execution); - * } - * throw error; - * } + * const backend = this.createJobsBackend(); + * const execution = await backend.executeJob('my-job'); * } * } */ export abstract class JobCommand extends InstanceCommand { + /** + * Creates a jobs backend based on the resolved configuration. + * In auto mode (default), prefers SCAPI when shortCode+tenantId are configured, + * falling back to OCAPI if SCAPI scopes are unavailable. + */ + protected createJobsBackend(): JobsBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createJobsBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } + /** * Display a job's log file content and error message if available. + * Accepts both canonical JobExecutionResult and legacy OCAPI JobExecution. * Outputs to stderr since this is typically shown for failed jobs. - * - * @param execution - Job execution with log file info */ - protected async showJobLog(execution: JobExecution): Promise { - // Extract error message from failed step executions + protected async showJobLog(execution: JobExecutionResult | JobExecution): Promise { + if (isCanonicalExecution(execution)) { + return this.showCanonicalJobLog(execution); + } + return this.showOcapiJobLog(execution); + } + + private async showCanonicalJobLog(execution: JobExecutionResult): Promise { + const errorMessage = getCanonicalJobErrorMessage(execution); + + if (!execution.isLogFileExisting) { + if (errorMessage) { + this.logger.error({errorMessage}, errorMessage); + } + return; + } + + try { + const backend = this.createJobsBackend(); + const log = await backend.getJobLog(execution); + const logFileName = execution.logFilePath?.split('/').pop() ?? 'job.log'; + + const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); + this.logger.error({log, errorMessage}, `${header}\n${log}`); + + if (errorMessage) { + this.logger.error(t('cli.job.errorMessage', 'Error: {{message}}', {message: errorMessage})); + } + } catch { + this.warn(t('cli.job.logFetchFailed', 'Could not retrieve job log')); + if (errorMessage) { + this.logger.error({errorMessage}, errorMessage); + } + } + } + + private async showOcapiJobLog(execution: JobExecution): Promise { const errorMessage = getJobErrorMessage(execution); if (!execution.is_log_file_existing) { - // No log file, but we may still have an error message if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } @@ -54,16 +98,31 @@ export abstract class JobCommand extends InstanceComma const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); this.logger.error({log, errorMessage}, `${header}\n${log}`); - // Log the error message separately if available if (errorMessage) { this.logger.error(t('cli.job.errorMessage', 'Error: {{message}}', {message: errorMessage})); } } catch { this.warn(t('cli.job.logFetchFailed', 'Could not retrieve job log')); - // Still try to show error message even if log fetch failed if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } } } } + +function isCanonicalExecution(execution: JobExecutionResult | JobExecution): execution is JobExecutionResult { + return 'executionStatus' in execution; +} + +function getCanonicalJobErrorMessage(execution: JobExecutionResult): string | undefined { + if (!execution.stepExecutions || execution.stepExecutions.length === 0) { + return undefined; + } + for (let i = execution.stepExecutions.length - 1; i >= 0; i--) { + const step = execution.stepExecutions[i]; + if (step.exitStatus?.status === 'error' && step.exitStatus?.message) { + return step.exitStatus.message; + } + } + return undefined; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index c58154e06..e341f4ebb 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -325,6 +325,17 @@ export type { components as GranularReplicationsComponents, } from './granular-replications.js'; +// SCAPI Jobs +export {createScapiJobsClient, SCAPI_JOBS_READ_SCOPES, SCAPI_JOBS_RW_SCOPES} from './scapi-jobs.js'; +export type { + ScapiJobsClient, + ScapiJobsClientConfig, + ScapiJobsError, + ScapiJobsResponse, + paths as ScapiJobsPaths, + components as ScapiJobsComponents, +} from './scapi-jobs.js'; + export {getApiErrorMessage} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 126abfdfa..adc83652e 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -59,7 +59,8 @@ export type HttpClientType = | 'am-users-api' | 'am-roles-api' | 'am-apiclients-api' - | 'am-orgs-api'; + | 'am-orgs-api' + | 'scapi-jobs'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts new file mode 100644 index 000000000..f749aca46 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts @@ -0,0 +1,535 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/job-execution-search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["searchJobExecutions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/jobs/{jobId}/executions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["createJobExecution"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getJobExecution"]; + put?: never; + post?: never; + delete: operations["deleteJobExecution"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Query: { + boolQuery?: components["schemas"]["BoolQuery"]; + filteredQuery?: components["schemas"]["FilteredQuery"]; + matchAllQuery?: components["schemas"]["MatchAllQuery"]; + nestedQuery?: components["schemas"]["NestedQuery"]; + termQuery?: components["schemas"]["TermQuery"]; + textQuery?: components["schemas"]["TextQuery"]; + }; + BoolQuery: { + must?: components["schemas"]["Query"][]; + mustNot?: components["schemas"]["Query"][]; + should?: components["schemas"]["Query"][]; + }; + Filter: { + boolFilter?: components["schemas"]["BoolFilter"]; + queryFilter?: components["schemas"]["QueryFilter"]; + range2Filter?: components["schemas"]["Range2Filter"]; + rangeFilter?: components["schemas"]["RangeFilter"]; + termFilter?: components["schemas"]["TermFilter"]; + }; + BoolFilter: { + filters?: components["schemas"]["Filter"][]; + /** @enum {string} */ + operator: "and" | "or" | "not"; + }; + QueryFilter: { + query: components["schemas"]["Query"]; + }; + Field: string; + Range2Filter: { + /** + * @default overlap + * @enum {string} + */ + filterMode: "overlap" | "containing" | "contained"; + fromField: components["schemas"]["Field"]; + /** @default true */ + fromInclusive: boolean; + fromValue?: unknown; + toField: components["schemas"]["Field"]; + /** @default true */ + toInclusive: boolean; + toValue?: unknown; + }; + RangeFilter: { + field: components["schemas"]["Field"]; + from?: string | number; + /** @default true */ + fromInclusive: boolean; + to?: string | number; + /** @default true */ + toInclusive: boolean; + }; + TermFilter: { + field: components["schemas"]["Field"]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: string[]; + }; + FilteredQuery: { + filter: components["schemas"]["Filter"]; + query: components["schemas"]["Query"]; + }; + MatchAllQuery: Record; + NestedQuery: { + path: string; + query: components["schemas"]["Query"]; + /** @enum {string} */ + scoreMode?: "avg" | "total" | "max" | "none"; + }; + TermQuery: { + fields: components["schemas"]["Field"][]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: (string | number | boolean)[]; + }; + TextQuery: { + fields: components["schemas"]["Field"][]; + searchPhrase: string; + }; + Sort: { + field: string; + /** + * @default asc + * @enum {string} + */ + sortOrder: "asc" | "desc"; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + SearchRequest: { + /** Format: int32 */ + limit?: number; + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + offset?: components["schemas"]["Offset"]; + }; + JobExecutionSearchRequest: components["schemas"]["SearchRequest"]; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + PaginatedSearchResult: { + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + hits?: Record[]; + } & components["schemas"]["PaginatedResultBase"]; + /** @enum {string} */ + ExecutionStatus: "pending" | "running" | "pausing" | "paused" | "resuming" | "resumed" | "restarting" | "restarted" | "retrying" | "retried" | "aborting" | "aborted" | "finished" | "unknown"; + ExitStatus: { + code?: string; + message?: string; + /** @enum {string} */ + status?: "ok" | "error"; + }; + StatusMetadata: { + clientId?: string; + reason?: string; + userLogin?: string; + }; + JobParameter: { + name: string; + value: string; + }; + JobExecutionRetryInformation: { + /** Format: int32 */ + currentRetryAttempt?: number; + /** Format: int32 */ + maxRetries?: number; + }; + JobExecutionContinueInformation: { + isPending?: boolean; + continueStatus?: string; + }; + JobStepExecution: { + id?: string; + stepId?: string; + stepDescription?: string; + stepTypeId?: string; + stepTypeInfo?: string; + executionScope?: string; + executionStatus?: components["schemas"]["ExecutionStatus"]; + status?: string; + /** Format: date-time */ + startTime?: string; + /** Format: date-time */ + endTime?: string; + /** Format: int64 */ + duration?: number; + /** Format: date-time */ + modificationTime?: string; + statusMetadata?: components["schemas"]["StatusMetadata"]; + exitStatus?: components["schemas"]["ExitStatus"]; + includeStepsFromJobId?: string; + isChunkOriented?: boolean; + /** Format: int32 */ + chunkSize?: number; + /** Format: int32 */ + itemFilterCount?: number; + /** Format: int32 */ + itemWriteCount?: number; + /** Format: int64 */ + totalItemCount?: number; + }; + JobExecution: { + id: string; + jobId: string; + jobDescription?: string; + clientId?: string; + userLogin?: string; + executionStatus?: components["schemas"]["ExecutionStatus"]; + status: string; + /** Format: date-time */ + startTime?: string; + /** Format: date-time */ + endTime?: string; + /** Format: date-time */ + creationDate?: string; + /** Format: int64 */ + duration?: number; + /** Format: int64 */ + effectiveDuration?: number; + /** Format: date-time */ + modificationTime?: string; + /** Format: date-time */ + lastModified?: string; + executedServerId?: string; + exitStatus?: components["schemas"]["ExitStatus"]; + statusMetadata?: components["schemas"]["StatusMetadata"]; + isLogFileExisting?: boolean; + isRestart?: boolean; + logFilePath?: string; + parameters?: components["schemas"]["JobParameter"][]; + executionScopes?: string[]; + retryInformation?: components["schemas"]["JobExecutionRetryInformation"]; + continueInformation?: components["schemas"]["JobExecutionContinueInformation"]; + stepExecutions?: components["schemas"]["JobStepExecution"][]; + }; + JobExecutionSearchResult: { + hits: components["schemas"]["JobExecution"][]; + } & WithRequired; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + JobExecutionRequest: { + parameters?: components["schemas"]["JobParameter"][]; + }; + }; + responses: { + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + "401unauthorized": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + "403forbidden": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + executionId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + searchJobExecutions: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JobExecutionSearchRequest"]; + }; + }; + responses: { + /** @description Returns job execution search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExecutionSearchResult"]; + }; + }; + /** @description Bad Request - Malformed search query or invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createJobExecution: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["JobExecutionRequest"]; + }; + }; + responses: { + /** @description The job execution was successfully created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExecution"]; + }; + }; + /** @description Bad Request - Invalid job execution request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Job not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getJobExecution: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + executionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the job execution details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExecution"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Job execution not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteJobExecution: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + executionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The job execution was successfully deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Job execution not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts new file mode 100644 index 000000000..56683fb96 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -0,0 +1,58 @@ +/* + * 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 createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-jobs.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; + +export {toOrganizationId, normalizeTenantId, buildTenantScope}; + +export type {paths, components}; +export type ScapiJobsClient = Client; +export type ScapiJobsResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiJobsError = components['schemas']['ErrorResponse']; + +export type JobExecution = components['schemas']['JobExecution']; +export type JobStepExecution = components['schemas']['JobStepExecution']; +export type JobParameter = components['schemas']['JobParameter']; +export type ExecutionStatus = components['schemas']['ExecutionStatus']; +export type ExitStatus = components['schemas']['ExitStatus']; +export type JobExecutionSearchResult = components['schemas']['JobExecutionSearchResult']; + +export const SCAPI_JOBS_READ_SCOPES = ['sfcc.jobs']; +export const SCAPI_JOBS_RW_SCOPES = ['sfcc.jobs.rw']; + +export interface ScapiJobsClientConfig { + shortCode: string; + tenantId: string; + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthStrategy): ScapiJobsClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/operation/jobs/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_JOBS_RW_SCOPES, buildTenantScope(config.tenantId)]; + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware('scapi-jobs')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-JOBS'})); + client.use(createLoggingMiddleware('SCAPI-JOBS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 8ab1e4656..9488d5c6a 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -93,6 +93,8 @@ export interface DwJsonConfig { certificatePassphrase?: string; /** Whether to skip SSL/TLS certificate verification (self-signed certs) */ selfSigned?: boolean; + /** API backend preference for operations that support both OCAPI and SCAPI */ + apiBackend?: 'ocapi' | 'scapi' | 'auto'; /** * Safety configuration for this instance. * diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index 5e0ed06ea..a2e0b33b5 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -71,6 +71,7 @@ export const CONFIG_KEY_ALIASES: Record = { 'oauth-scopes': 'oauthScopes', 'auth-methods': 'authMethods', 'cip-host': 'cipHost', + 'api-backend': 'apiBackend', }; /** @@ -173,6 +174,8 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi certificate: json.certificate, certificatePassphrase: json.certificatePassphrase, selfSigned: json.selfSigned, + // API backend + apiBackend: json.apiBackend, // Safety safety: mapDwJsonSafety(json.safety), }; @@ -308,6 +311,9 @@ export function mapNormalizedConfigToDwJson(config: Partial, n if (config.selfSigned !== undefined) { result.selfSigned = config.selfSigned; } + if (config.apiBackend !== undefined) { + result.apiBackend = config.apiBackend; + } if (config.safety !== undefined) { result.safety = { level: config.safety.level, @@ -443,6 +449,8 @@ export function mergeConfigsWithProtection( certificate: overrides.certificate ?? base.certificate, certificatePassphrase: overrides.certificatePassphrase ?? base.certificatePassphrase, selfSigned: overrides.selfSigned ?? base.selfSigned, + // API backend + apiBackend: overrides.apiBackend ?? base.apiBackend, // Safety safety: overrides.safety ?? base.safety, }, diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index b28411202..c28505575 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -127,6 +127,10 @@ export interface NormalizedConfig { /** Whether to skip SSL/TLS certificate verification (self-signed certs) */ selfSigned?: boolean; + // API backend + /** API backend preference for operations that support both OCAPI and SCAPI */ + apiBackend?: 'ocapi' | 'scapi' | 'auto'; + // Safety /** Safety configuration for this instance */ safety?: { diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index e651cfc91..93f93a0d1 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -215,6 +215,12 @@ export { siteArchiveImport, siteArchiveExport, siteArchiveExportToPath, + // Backend abstraction + createJobsBackend, + waitForJobExecution, + FallbackJobsBackend, + OcapiJobsBackend, + ScapiJobsBackend, } from './operations/jobs/index.js'; export type { JobExecution, @@ -226,6 +232,14 @@ export type { WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, + // Backend abstraction types + JobsBackend, + JobsBackendConfig, + ApiBackendPreference, + JobExecutionResult, + JobStepExecutionResult, + JobExecutionSearchResults, + ScapiJobsBackendConfig, SiteArchiveImportOptions, SiteArchiveImportResult, SiteArchiveExportOptions, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts new file mode 100644 index 000000000..6932196e1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts @@ -0,0 +1,164 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type {JobsBackend, JobExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions, WaitForJobOptions, WaitForJobPollInfo} from './run.js'; +import {OcapiJobsBackend} from './ocapi-backend.js'; +import {ScapiJobsBackend} from './scapi-backend.js'; +import {getLogger} from '../../logging/logger.js'; + +export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; + +export interface JobsBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createJobsBackend(config: JobsBackendConfig): JobsBackend { + const resolved = resolveBackend(config); + + if (resolved === 'ocapi') { + return new OcapiJobsBackend(config.instance); + } + + const scapiBackend = new ScapiJobsBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + instance: config.instance, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new OcapiJobsBackend(config.instance); + return new FallbackJobsBackend(scapiBackend, ocapiBackend); +} + +function resolveBackend(config: JobsBackendConfig): 'ocapi' | 'scapi' { + if (config.preference === 'ocapi') return 'ocapi'; + if (config.preference === 'scapi') { + if (!config.shortCode || !config.tenantId) { + throw new Error('SCAPI backend requires shortCode and tenantId configuration.'); + } + if (!config.auth) { + throw new Error('SCAPI backend requires OAuth credentials.'); + } + return 'scapi'; + } + + // Auto: prefer SCAPI when config available + if (config.shortCode && config.tenantId && config.auth) { + return 'scapi'; + } + return 'ocapi'; +} + +export class FallbackJobsBackend implements JobsBackend { + private resolvedBackend?: JobsBackend; + + constructor( + private scapiBackend: ScapiJobsBackend, + private ocapiBackend: OcapiJobsBackend, + ) {} + + get name(): 'ocapi' | 'scapi' { + return (this.resolvedBackend?.name ?? 'scapi') as 'ocapi' | 'scapi'; + } + + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + return this.withFallback((backend) => backend.executeJob(jobId, options)); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + return this.withFallback((backend) => backend.getJobExecution(jobId, executionId)); + } + + async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { + return this.withFallback((backend) => backend.searchJobExecutions(options)); + } + + async deleteJobExecution(jobId: string, executionId: string): Promise { + return this.withFallback((backend) => backend.deleteJobExecution(jobId, executionId)); + } + + async getJobLog(execution: JobExecutionResult): Promise { + return this.withFallback((backend) => backend.getJobLog(execution)); + } + + private async withFallback(fn: (backend: JobsBackend) => Promise): Promise { + if (this.resolvedBackend) { + return fn(this.resolvedBackend); + } + + try { + const result = await fn(this.scapiBackend); + this.resolvedBackend = this.scapiBackend; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + const logger = getLogger(); + logger.info('SCAPI jobs scope unavailable, falling back to OCAPI'); + this.resolvedBackend = this.ocapiBackend; + return fn(this.ocapiBackend); + } + throw error; + } + } +} + +function isInvalidScopeError(error: unknown): boolean { + return error instanceof Error && error.message.includes('invalid_scope'); +} + +export async function waitForJobExecution( + backend: JobsBackend, + jobId: string, + executionId: string, + options: WaitForJobOptions = {}, +): Promise { + const {pollIntervalSeconds = 3, timeoutSeconds = 0, onPoll} = options; + const sleepFn = options.sleep ?? defaultSleep; + const startTime = Date.now(); + const pollIntervalMs = pollIntervalSeconds * 1000; + const timeoutMs = timeoutSeconds * 1000; + await sleepFn(pollIntervalMs); + + while (true) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + + if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) { + throw new Error(`Timeout waiting for job ${jobId} execution ${executionId}`); + } + + const execution = await backend.getJobExecution(jobId, executionId); + const currentStatus = execution.executionStatus; + + const pollInfo: WaitForJobPollInfo = {jobId, executionId, elapsedSeconds, status: currentStatus}; + onPoll?.(pollInfo); + + if (execution.executionStatus === 'aborted' || execution.exitStatus?.status === 'error') { + const {JobExecutionError} = await import('./run.js'); + throw new JobExecutionError(`Job ${jobId} failed`, execution._raw as never); + } + + if (execution.executionStatus === 'finished') { + return execution; + } + + await sleepFn(pollIntervalMs); + } +} + +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 8f19d36f7..6164484d4 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -90,6 +90,14 @@ export type { JobExecutionSearchResult, } from './run.js'; +// Backend abstraction +export {createJobsBackend, waitForJobExecution, FallbackJobsBackend} from './backend.js'; +export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; +export {OcapiJobsBackend} from './ocapi-backend.js'; +export {ScapiJobsBackend} from './scapi-backend.js'; +export type {ScapiJobsBackendConfig} from './scapi-backend.js'; +export type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; + // Site archive import/export export { siteArchiveImport, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts new file mode 100644 index 000000000..a58e0b6f1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import type {B2CInstance} from '../../instance/index.js'; +import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions, JobExecution, JobStepExecution} from './run.js'; +import { + executeJob as ocapiExecuteJob, + getJobExecution as ocapiGetJobExecution, + searchJobExecutions as ocapiSearchJobExecutions, + getJobLog as ocapiGetJobLog, +} from './run.js'; + +function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.step_id, + executionStatus: step.execution_status, + exitStatus: step.exit_status + ? { + code: step.exit_status.code ?? '', + message: step.exit_status.message, + status: step.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + duration: step.duration, + }; +} + +function mapOcapiExecution(ocapi: JobExecution): JobExecutionResult { + return { + id: ocapi.id ?? '', + jobId: ocapi.job_id ?? '', + executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionResult['executionStatus'], + exitStatus: ocapi.exit_status + ? { + code: ocapi.exit_status.code ?? '', + message: ocapi.exit_status.message, + status: ocapi.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + startTime: ocapi.start_time, + endTime: ocapi.end_time, + duration: ocapi.duration, + stepExecutions: ocapi.step_executions?.map(mapStepExecution), + logFilePath: ocapi.log_file_path, + isLogFileExisting: ocapi.is_log_file_existing, + parameters: ocapi.parameters, + _raw: ocapi, + }; +} + +export class OcapiJobsBackend implements JobsBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + const result = await ocapiExecuteJob(this.instance, jobId, options); + return mapOcapiExecution(result); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + const result = await ocapiGetJobExecution(this.instance, jobId, executionId); + return mapOcapiExecution(result); + } + + async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { + const result = await ocapiSearchJobExecutions(this.instance, options); + return { + total: result.total, + limit: result.count, + offset: result.start, + hits: result.hits.map(mapOcapiExecution), + }; + } + + async deleteJobExecution(_jobId: string, _executionId: string): Promise { + throw new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'); + } + + async getJobLog(execution: JobExecutionResult): Promise { + const ocapiExecution = execution._raw as JobExecution; + if (ocapiExecution) { + return ocapiGetJobLog(this.instance, ocapiExecution); + } + if (!execution.logFilePath) { + throw new Error('No log file path available'); + } + if (!execution.isLogFileExisting) { + throw new Error('Log file does not exist'); + } + const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await this.instance.webdav.get(logPath); + return new TextDecoder().decode(content); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts new file mode 100644 index 000000000..6af2f6c39 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -0,0 +1,280 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; +import { + createScapiJobsClient, + SCAPI_JOBS_RW_SCOPES, + SCAPI_JOBS_READ_SCOPES, + type ScapiJobsClient, + type ScapiJobsClientConfig, + type JobExecution as ScapiJobExecution, + type JobStepExecution as ScapiJobStepExecution, +} from '../../clients/scapi-jobs.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {getLogger} from '../../logging/logger.js'; + +function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.stepId, + executionStatus: step.executionStatus, + exitStatus: step.exitStatus + ? { + code: step.exitStatus.code ?? '', + message: step.exitStatus.message, + status: step.exitStatus.status, + } + : undefined, + duration: step.duration, + }; +} + +function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionResult { + return { + id: scapi.id, + jobId: scapi.jobId, + executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionResult['executionStatus'], + exitStatus: scapi.exitStatus + ? { + code: scapi.exitStatus.code ?? '', + message: scapi.exitStatus.message, + status: scapi.exitStatus.status, + } + : undefined, + startTime: scapi.startTime, + endTime: scapi.endTime, + duration: scapi.duration, + stepExecutions: scapi.stepExecutions?.map(mapStepExecution), + logFilePath: scapi.logFilePath, + isLogFileExisting: scapi.isLogFileExisting, + parameters: scapi.parameters, + _raw: scapi, + }; +} + +export interface ScapiJobsBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; + instance: B2CInstance; +} + +export class ScapiJobsBackend implements JobsBackend { + readonly name = 'scapi' as const; + + private resolvedScopeTier?: 'rw' | 'read-only'; + private rwClient?: ScapiJobsClient; + private readClient?: ScapiJobsClient; + private organizationId: string; + + constructor(private config: ScapiJobsBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + } + + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + const client = await this.getClientForWrite(); + const {parameters = [], body: rawBody} = options ?? {}; + + let requestBody: Record | undefined; + if (rawBody) { + requestBody = rawBody; + } else if (parameters.length > 0) { + requestBody = {parameters}; + } + + const {data, error, response} = await client.POST('/organizations/{organizationId}/jobs/{jobId}/executions', { + params: {path: {organizationId: this.organizationId, jobId}}, + body: requestBody as unknown as {parameters?: Array<{name: string; value: string}>}, + }); + + if (response.status === 400) { + const errorBody = error as unknown as {title?: string; type?: string; detail?: string; jobId?: string}; + if (errorBody?.type?.includes('job-already-running') || errorBody?.title === 'Job Already Running') { + if (options?.waitForRunning !== false) { + const logger = getLogger(); + logger.warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); + const running = await this.findRunningExecution(jobId); + if (running) { + await this.waitForTerminal(jobId, running.id); + } + return this.executeJob(jobId, {...options, waitForRunning: false}); + } + throw new Error(`Job ${jobId} is already running`); + } + } + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; + throw new Error(message); + } + + return mapScapiExecution(data); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + const client = await this.getClientForRead(); + + const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId: this.organizationId, jobId, executionId}}, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; + throw new Error(message); + } + + return mapScapiExecution(data); + } + + async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { + const client = await this.getClientForRead(); + const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options ?? {}; + + const queries: unknown[] = []; + if (jobId) { + queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); + } + if (status) { + const statusValues = Array.isArray(status) ? status : [status]; + queries.push({termQuery: {fields: ['status'], operator: 'one_of', values: statusValues}}); + } + + let query: unknown; + if (queries.length === 0) { + query = {matchAllQuery: {}}; + } else if (queries.length === 1) { + query = queries[0]; + } else { + query = {boolQuery: {must: queries}}; + } + + const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { + params: {path: {organizationId: this.organizationId}}, + body: { + query, + limit: count, + offset: start, + sorts: [{field: sortBy, sortOrder}], + } as never, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? 'Failed to search job executions'; + throw new Error(message); + } + + const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; + return { + total: result.total ?? 0, + limit: result.limit ?? count, + offset: result.offset ?? start, + hits: (result.hits ?? []).map(mapScapiExecution), + }; + } + + async deleteJobExecution(jobId: string, executionId: string): Promise { + const client = await this.getClientForWrite(); + + const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId: this.organizationId, jobId, executionId}}, + }); + + if (error) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; + throw new Error(message); + } + } + + async getJobLog(execution: JobExecutionResult): Promise { + if (!execution.logFilePath) { + throw new Error('No log file path available'); + } + if (!execution.isLogFileExisting) { + throw new Error('Log file does not exist'); + } + const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await this.config.instance.webdav.get(logPath); + return new TextDecoder().decode(content); + } + + private async getClientForWrite(): Promise { + if (this.resolvedScopeTier === 'rw' && this.rwClient) { + return this.rwClient; + } + if (this.resolvedScopeTier === 'read-only') { + throw new Error( + 'SCAPI Jobs API requires the "sfcc.jobs.rw" scope to execute or delete jobs. ' + + 'Add this scope to your API client in Account Manager.', + ); + } + if (!this.rwClient) { + this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); + } + this.resolvedScopeTier = 'rw'; + return this.rwClient; + } + + private async getClientForRead(): Promise { + if (this.resolvedScopeTier && this.rwClient) { + return this.rwClient; + } + if (this.resolvedScopeTier === 'read-only' && this.readClient) { + return this.readClient; + } + if (!this.rwClient) { + this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); + } + this.resolvedScopeTier = 'rw'; + return this.rwClient; + } + + /** + * Called when we detect an invalid_scope error on the rw client for a read operation. + * Downgrades to read-only scope. + */ + downgradeToReadOnly(): void { + this.resolvedScopeTier = 'read-only'; + this.readClient = this.buildClient(SCAPI_JOBS_READ_SCOPES); + } + + private buildClient(scopes: string[]): ScapiJobsClient { + const clientConfig: ScapiJobsClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiJobsClient(clientConfig, this.config.auth); + } + + private async findRunningExecution(jobId: string): Promise { + const results = await this.searchJobExecutions({ + jobId, + status: ['RUNNING', 'PENDING'], + sortBy: 'start_time', + sortOrder: 'asc', + count: 1, + }); + return results.hits[0]; + } + + private async waitForTerminal(jobId: string, executionId: string): Promise { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + while (true) { + await sleep(3000); + const execution = await this.getJobExecution(jobId, executionId); + if (execution.executionStatus === 'finished' || execution.executionStatus === 'aborted') { + return; + } + } + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts new file mode 100644 index 000000000..efc3bed1a --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -0,0 +1,61 @@ +/* + * 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 type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} from './run.js'; + +export type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions}; + +export interface JobExecutionResult { + id: string; + jobId: string; + executionStatus: + | 'pending' + | 'running' + | 'pausing' + | 'paused' + | 'resuming' + | 'resumed' + | 'restarting' + | 'restarted' + | 'retrying' + | 'retried' + | 'aborting' + | 'aborted' + | 'finished' + | 'unknown'; + exitStatus?: {code: string; message?: string; status?: 'ok' | 'error'}; + startTime?: string; + endTime?: string; + duration?: number; + stepExecutions?: JobStepExecutionResult[]; + logFilePath?: string; + isLogFileExisting?: boolean; + parameters?: Array<{name: string; value: string}>; + _raw?: unknown; +} + +export interface JobStepExecutionResult { + id?: string; + stepId?: string; + executionStatus?: string; + exitStatus?: {code: string; message?: string; status?: 'ok' | 'error'}; + duration?: number; +} + +export interface JobExecutionSearchResults { + total: number; + limit: number; + offset: number; + hits: JobExecutionResult[]; +} + +export interface JobsBackend { + readonly name: 'ocapi' | 'scapi'; + executeJob(jobId: string, options?: ExecuteJobOptions): Promise; + getJobExecution(jobId: string, executionId: string): Promise; + searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; + deleteJobExecution(jobId: string, executionId: string): Promise; + getJobLog(execution: JobExecutionResult): Promise; +} diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index f4fa0c94d..4b4ed4474 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -184,6 +184,34 @@ b2c job search --sort-by start_time --sort-order desc b2c job search --json ``` +### Delete Job Executions + +```bash +# delete a job execution record (requires SCAPI) +b2c job execution delete my-job abc123-def456 +``` + +### API Backend Selection + +Job commands support both OCAPI and SCAPI backends. By default, SCAPI is preferred when `shortCode` and `tenantId` are configured. + +```bash +# force SCAPI backend +b2c job run my-job --api-backend scapi + +# force OCAPI backend +b2c job run my-job --api-backend ocapi + +# auto-detect (default) - prefers SCAPI when configured, falls back to OCAPI +b2c job run my-job --api-backend auto +``` + +Set via dw.json: `"api-backend": "scapi"` or env: `SFCC_API_BACKEND=scapi`. + +**SCAPI scopes**: `sfcc.jobs.rw` (recommended) for full access, or `sfcc.jobs` for read-only (search, wait, log). + +> **Note:** `job import` and `job export` currently always use OCAPI regardless of `--api-backend`. + ### Wait for Job Completion ```bash From ae68648b3f1289ad4e16271e022ba7a61b911385 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:22:35 -0400 Subject: [PATCH 02/11] Extract reusable SCAPI/OCAPI dual-backend pattern from jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls three domain-agnostic utilities out of jobs into shared modules ahead of applying the same pattern to scripts, users, and roles: - isInvalidScopeError, ApiBackendPreference, resolveScapiOrOcapi — scope-error detection and preference resolution - ScapiFallbackBackend — generic fallback wrapper that tries SCAPI first and falls back to OCAPI on invalid_scope - ScopeTierManager — manages dual rw/read-only client tiers with optimistic rw + downgrade on scope error Refactors ScapiJobsBackend and FallbackJobsBackend to use the new utilities without behavior change. Removes the unused downgradeToReadOnly() method and confusing tier-resolution logic. --- packages/b2c-tooling-sdk/src/clients/index.ts | 7 ++ .../src/clients/scapi-backend-utils.ts | 87 +++++++++++++++ .../src/clients/scapi-fallback-backend.ts | 75 +++++++++++++ .../src/clients/scapi-scope-tier.ts | 102 ++++++++++++++++++ .../src/operations/jobs/backend.ts | 68 +++--------- .../src/operations/jobs/scapi-backend.ts | 59 +++------- 6 files changed, 295 insertions(+), 103 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index e341f4ebb..08b5082cc 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,13 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) +export {isInvalidScopeError, resolveScapiOrOcapi} from './scapi-backend-utils.js'; +export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; +export {ScapiFallbackBackend} from './scapi-fallback-backend.js'; +export {ScopeTierManager} from './scapi-scope-tier.js'; +export type {ScopeTier, ScopeTierManagerOptions} from './scapi-scope-tier.js'; + export {getApiErrorMessage} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts new file mode 100644 index 000000000..87a79d965 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -0,0 +1,87 @@ +/* + * 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 + */ +/** + * Shared utilities for SCAPI/OCAPI dual-backend domains. + * + * Each domain that supports both OCAPI (legacy) and SCAPI (modern) shares + * these utilities to keep behavior consistent: backend preference resolution, + * scope-error detection, and the canonical `ApiBackendPreference` type. + * + * @module clients/scapi-backend-utils + */ + +/** + * User-facing API backend preference. + * + * - `'ocapi'`: force OCAPI (always use the legacy Data API). + * - `'scapi'`: force SCAPI (requires shortCode + tenantId; fails loudly if scopes missing). + * - `'auto'`: prefer SCAPI when configured, transparently fall back to OCAPI on `invalid_scope`. + */ +export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; + +/** + * Common shape of every dual-backend implementation. Each canonical backend + * (e.g., `JobsBackend`) extends this so a generic fallback wrapper can read + * `name` to know which backend served the last call. + */ +export interface BackendBase { + readonly name: 'ocapi' | 'scapi'; +} + +/** + * Detects an Account Manager `invalid_scope` error. + * + * When a client's API client doesn't have the requested scope configured, + * Account Manager returns `{"error":"invalid_scope", ...}` on the token + * request. The OAuth strategy surfaces that as an Error whose message + * contains `invalid_scope`. + * + * Used by fallback wrappers to decide whether to downgrade to OCAPI. + */ +export function isInvalidScopeError(error: unknown): boolean { + return error instanceof Error && error.message.includes('invalid_scope'); +} + +/** + * Inputs to `resolveScapiOrOcapi`. + */ +export interface ResolveBackendOptions { + /** User preference (from `--api-backend` flag or `apiBackend` config). */ + preference: ApiBackendPreference; + /** True iff shortCode + tenantId + auth are all available. */ + hasScapiConfig: boolean; + /** Domain name used in error messages, e.g. `'Jobs'`, `'Scripts'`. */ + domainName: string; +} + +/** + * Resolves a user preference + config availability into a concrete backend choice. + * + * - Explicit `'ocapi'` always returns `'ocapi'`. + * - Explicit `'scapi'` requires SCAPI config and throws if missing. + * - `'auto'` returns `'scapi'` if SCAPI config is available, otherwise `'ocapi'`. + * + * Throws an error with the domain name in the message when explicit SCAPI is + * requested without the required configuration. + */ +export function resolveScapiOrOcapi(opts: ResolveBackendOptions): 'ocapi' | 'scapi' { + const {preference, hasScapiConfig, domainName} = opts; + + if (preference === 'ocapi') return 'ocapi'; + + if (preference === 'scapi') { + if (!hasScapiConfig) { + throw new Error( + `${domainName} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + + `Configure them in dw.json or use --api-backend ocapi.`, + ); + } + return 'scapi'; + } + + // auto + return hasScapiConfig ? 'scapi' : 'ocapi'; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts new file mode 100644 index 000000000..3d5c7e192 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -0,0 +1,75 @@ +/* + * 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 + */ +/** + * Generic fallback wrapper for SCAPI/OCAPI dual backends. + * + * Each domain (jobs, scripts, users, roles) gets a thin subclass that + * delegates each interface method through `withFallback`. The wrapper itself + * holds no domain knowledge — it only implements the "try SCAPI first; on + * `invalid_scope`, fall back to OCAPI; cache the choice" behavior. + * + * @module clients/scapi-fallback-backend + */ +import {getLogger} from '../logging/logger.js'; +import {isInvalidScopeError, type BackendBase} from './scapi-backend-utils.js'; + +/** + * Base class for `Fallback*Backend` implementations. Subclasses implement + * the domain interface (e.g., `JobsBackend`) by delegating each method to + * `withFallback`. + * + * @example + * ```ts + * class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { + * async executeJob(jobId: string, options?: ExecuteJobOptions) { + * return this.withFallback((b) => b.executeJob(jobId, options)); + * } + * // ... one delegating method per interface method + * } + * ``` + */ +export abstract class ScapiFallbackBackend { + protected resolvedBackend?: T; + + constructor( + protected scapiBackend: T, + protected ocapiBackend: T, + /** Used in fallback log messages, e.g. `'jobs'`, `'scripts'`. */ + protected domainName: string, + ) {} + + /** + * Reports the backend that served the last successful call. Defaults to + * `'scapi'` before the first call, since that's what we'd try first. + */ + get name(): 'ocapi' | 'scapi' { + return this.resolvedBackend?.name ?? this.scapiBackend.name; + } + + /** + * Runs `fn` against the resolved backend, or against SCAPI first with + * automatic OCAPI fallback on `invalid_scope`. The choice is cached: once + * a backend has succeeded (or fallen back), all subsequent calls go to it. + */ + protected async withFallback(fn: (backend: T) => Promise): Promise { + if (this.resolvedBackend) { + return fn(this.resolvedBackend); + } + + try { + const result = await fn(this.scapiBackend); + this.resolvedBackend = this.scapiBackend; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); + this.resolvedBackend = this.ocapiBackend; + return fn(this.ocapiBackend); + } + throw error; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts new file mode 100644 index 000000000..b90273ccd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts @@ -0,0 +1,102 @@ +/* + * 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 + */ +/** + * Scope-tier client manager for SCAPI domains with dual scopes. + * + * Many SCAPI Admin APIs expose two scopes — read-only and read-write + * (e.g., `sfcc.jobs` and `sfcc.jobs.rw`). A given API client may have only + * one of them configured in Account Manager. The optimistic strategy is to + * request `rw` first and downgrade to read-only only when `invalid_scope` + * is detected on a read operation. + * + * `ScopeTierManager` encapsulates that state machine so each SCAPI backend + * doesn't have to reimplement it. Write operations always require `rw`; + * if we already know the client only has read scope, the manager throws + * a descriptive error rather than making a doomed request. + * + * @module clients/scapi-scope-tier + */ + +export type ScopeTier = 'rw' | 'read-only'; + +export interface ScopeTierManagerOptions { + /** Builds a typed SCAPI client with the given OAuth scopes. */ + buildClient(scopes: string[]): C; + /** Scopes for read-write operations, e.g., `['sfcc.jobs.rw']`. */ + rwScopes: string[]; + /** Scopes for read-only operations, e.g., `['sfcc.jobs']`. */ + readScopes: string[]; + /** Domain name surfaced in error messages, e.g. `'Jobs'`, `'Scripts'`. */ + domainName: string; +} + +/** + * Lazy-initialized manager for clients at different scope tiers. + * + * - First read or write call builds the rw client and caches it. + * - If the caller detects an `invalid_scope` error on a read attempt, it + * calls `downgradeToReadOnly()` and the next read uses the read-only client. + * - Once downgraded, write requests throw — the API client lacks rw scope. + * + * The same rw client serves both read and write while the rw scope is valid; + * we only build a separate read-only client after a downgrade. + */ +export class ScopeTierManager { + private rwClient?: C; + private readClient?: C; + private resolved?: ScopeTier; + + constructor(private opts: ScopeTierManagerOptions) {} + + /** The currently-resolved tier, or undefined before first use. */ + get resolvedTier(): ScopeTier | undefined { + return this.resolved; + } + + /** + * Returns a client suitable for write operations. Throws if we've already + * downgraded to read-only — the API client doesn't have the rw scope. + */ + getClientForWrite(): C { + if (this.resolved === 'read-only') { + throw new Error( + `SCAPI ${this.opts.domainName} API requires the "${this.opts.rwScopes.join(' ')}" scope. ` + + `Add this scope to your API client in Account Manager.`, + ); + } + if (!this.rwClient) { + this.rwClient = this.opts.buildClient(this.opts.rwScopes); + } + this.resolved = 'rw'; + return this.rwClient; + } + + /** + * Returns a client suitable for read operations. Prefers the rw client if + * it's already been used successfully (rw scope grants read too). + */ + getClientForRead(): C { + if (this.resolved === 'read-only') { + // Already downgraded; readClient is built in downgradeToReadOnly() + return this.readClient!; + } + if (!this.rwClient) { + this.rwClient = this.opts.buildClient(this.opts.rwScopes); + } + this.resolved = 'rw'; + return this.rwClient; + } + + /** + * Marks the rw scope as unavailable and builds a read-only client. + * Subsequent `getClientForWrite()` calls will throw; reads use the + * read-only client. + */ + downgradeToReadOnly(): void { + this.resolved = 'read-only'; + this.readClient = this.opts.buildClient(this.opts.readScopes); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts index 6932196e1..75f4d4667 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts @@ -9,9 +9,10 @@ import type {JobsBackend, JobExecutionResult, JobExecutionSearchResults} from '. import type {ExecuteJobOptions, SearchJobExecutionsOptions, WaitForJobOptions, WaitForJobPollInfo} from './run.js'; import {OcapiJobsBackend} from './ocapi-backend.js'; import {ScapiJobsBackend} from './scapi-backend.js'; -import {getLogger} from '../../logging/logger.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; -export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; +export type {ApiBackendPreference}; export interface JobsBackendConfig { preference: ApiBackendPreference; @@ -22,7 +23,12 @@ export interface JobsBackendConfig { } export function createJobsBackend(config: JobsBackendConfig): JobsBackend { - const resolved = resolveBackend(config); + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Jobs', + }); if (resolved === 'ocapi') { return new OcapiJobsBackend(config.instance); @@ -44,35 +50,9 @@ export function createJobsBackend(config: JobsBackendConfig): JobsBackend { return new FallbackJobsBackend(scapiBackend, ocapiBackend); } -function resolveBackend(config: JobsBackendConfig): 'ocapi' | 'scapi' { - if (config.preference === 'ocapi') return 'ocapi'; - if (config.preference === 'scapi') { - if (!config.shortCode || !config.tenantId) { - throw new Error('SCAPI backend requires shortCode and tenantId configuration.'); - } - if (!config.auth) { - throw new Error('SCAPI backend requires OAuth credentials.'); - } - return 'scapi'; - } - - // Auto: prefer SCAPI when config available - if (config.shortCode && config.tenantId && config.auth) { - return 'scapi'; - } - return 'ocapi'; -} - -export class FallbackJobsBackend implements JobsBackend { - private resolvedBackend?: JobsBackend; - - constructor( - private scapiBackend: ScapiJobsBackend, - private ocapiBackend: OcapiJobsBackend, - ) {} - - get name(): 'ocapi' | 'scapi' { - return (this.resolvedBackend?.name ?? 'scapi') as 'ocapi' | 'scapi'; +export class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { + constructor(scapiBackend: ScapiJobsBackend, ocapiBackend: OcapiJobsBackend) { + super(scapiBackend, ocapiBackend, 'jobs'); } async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { @@ -94,30 +74,6 @@ export class FallbackJobsBackend implements JobsBackend { async getJobLog(execution: JobExecutionResult): Promise { return this.withFallback((backend) => backend.getJobLog(execution)); } - - private async withFallback(fn: (backend: JobsBackend) => Promise): Promise { - if (this.resolvedBackend) { - return fn(this.resolvedBackend); - } - - try { - const result = await fn(this.scapiBackend); - this.resolvedBackend = this.scapiBackend; - return result; - } catch (error) { - if (isInvalidScopeError(error)) { - const logger = getLogger(); - logger.info('SCAPI jobs scope unavailable, falling back to OCAPI'); - this.resolvedBackend = this.ocapiBackend; - return fn(this.ocapiBackend); - } - throw error; - } - } -} - -function isInvalidScopeError(error: unknown): boolean { - return error instanceof Error && error.message.includes('invalid_scope'); } export async function waitForJobExecution( diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts index 6af2f6c39..4a5df3e06 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -17,6 +17,7 @@ import { type JobStepExecution as ScapiJobStepExecution, } from '../../clients/scapi-jobs.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; import {getLogger} from '../../logging/logger.js'; function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { @@ -68,17 +69,21 @@ export interface ScapiJobsBackendConfig { export class ScapiJobsBackend implements JobsBackend { readonly name = 'scapi' as const; - private resolvedScopeTier?: 'rw' | 'read-only'; - private rwClient?: ScapiJobsClient; - private readClient?: ScapiJobsClient; private organizationId: string; + private scopeTier: ScopeTierManager; constructor(private config: ScapiJobsBackendConfig) { this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_JOBS_RW_SCOPES, + readScopes: SCAPI_JOBS_READ_SCOPES, + domainName: 'Jobs', + }); } async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - const client = await this.getClientForWrite(); + const client = this.scopeTier.getClientForWrite(); const {parameters = [], body: rawBody} = options ?? {}; let requestBody: Record | undefined; @@ -119,7 +124,7 @@ export class ScapiJobsBackend implements JobsBackend { } async getJobExecution(jobId: string, executionId: string): Promise { - const client = await this.getClientForRead(); + const client = this.scopeTier.getClientForRead(); const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { params: {path: {organizationId: this.organizationId, jobId, executionId}}, @@ -135,7 +140,7 @@ export class ScapiJobsBackend implements JobsBackend { } async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - const client = await this.getClientForRead(); + const client = this.scopeTier.getClientForRead(); const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options ?? {}; const queries: unknown[] = []; @@ -182,7 +187,7 @@ export class ScapiJobsBackend implements JobsBackend { } async deleteJobExecution(jobId: string, executionId: string): Promise { - const client = await this.getClientForWrite(); + const client = this.scopeTier.getClientForWrite(); const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { params: {path: {organizationId: this.organizationId, jobId, executionId}}, @@ -207,46 +212,6 @@ export class ScapiJobsBackend implements JobsBackend { return new TextDecoder().decode(content); } - private async getClientForWrite(): Promise { - if (this.resolvedScopeTier === 'rw' && this.rwClient) { - return this.rwClient; - } - if (this.resolvedScopeTier === 'read-only') { - throw new Error( - 'SCAPI Jobs API requires the "sfcc.jobs.rw" scope to execute or delete jobs. ' + - 'Add this scope to your API client in Account Manager.', - ); - } - if (!this.rwClient) { - this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); - } - this.resolvedScopeTier = 'rw'; - return this.rwClient; - } - - private async getClientForRead(): Promise { - if (this.resolvedScopeTier && this.rwClient) { - return this.rwClient; - } - if (this.resolvedScopeTier === 'read-only' && this.readClient) { - return this.readClient; - } - if (!this.rwClient) { - this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); - } - this.resolvedScopeTier = 'rw'; - return this.rwClient; - } - - /** - * Called when we detect an invalid_scope error on the rw client for a read operation. - * Downgrades to read-only scope. - */ - downgradeToReadOnly(): void { - this.resolvedScopeTier = 'read-only'; - this.readClient = this.buildClient(SCAPI_JOBS_READ_SCOPES); - } - private buildClient(scopes: string[]): ScapiJobsClient { const clientConfig: ScapiJobsClientConfig = { shortCode: this.config.shortCode, From de537a7b0189370f82fd2944f2ef266472dbd5f2 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:35:56 -0400 Subject: [PATCH 03/11] Add SCAPI Scripts (code versions) support with backend abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates code list/activate/delete commands to use the dual-backend pattern. New CodeCommand base class exposes createScriptsBackend(), which selects between OCAPI and SCAPI based on --api-backend. - New SCAPI Scripts client (dx/scripts/v1) reusing the shared ScopeTierManager and ScapiFallbackBackend utilities - ScriptsBackend interface with canonical CodeVersionInfo shape (camelCase, _raw escape hatch) - reloadCodeVersion remains OCAPI-only — SCAPI backend throws, auto mode falls back to OCAPI on the first reload call --- .../b2c-cli/src/commands/code/activate.ts | 37 +- packages/b2c-cli/src/commands/code/delete.ts | 15 +- packages/b2c-cli/src/commands/code/list.ts | 26 +- .../test/commands/code/activate.test.ts | 100 ++--- .../b2c-cli/test/commands/code/delete.test.ts | 59 +-- .../b2c-cli/test/commands/code/list.test.ts | 42 +- packages/b2c-tooling-sdk/package.json | 2 +- .../b2c-tooling-sdk/specs/dx-scripts-v1.yaml | 409 ++++++++++++++++++ .../b2c-tooling-sdk/src/cli/code-command.ts | 32 ++ packages/b2c-tooling-sdk/src/cli/index.ts | 1 + packages/b2c-tooling-sdk/src/clients/index.ts | 11 + .../src/clients/middleware-registry.ts | 3 +- .../src/clients/scapi-scripts.generated.ts | 393 +++++++++++++++++ .../src/clients/scapi-scripts.ts | 52 +++ packages/b2c-tooling-sdk/src/index.ts | 14 + .../src/operations/code/index.ts | 8 + .../operations/code/ocapi-scripts-backend.ts | 63 +++ .../operations/code/scapi-scripts-backend.ts | 126 ++++++ .../src/operations/code/scripts-backend.ts | 77 ++++ .../src/operations/code/scripts-types.ts | 54 +++ 20 files changed, 1368 insertions(+), 156 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/dx-scripts-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/cli/code-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index 868db0639..f126f2b05 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -4,11 +4,10 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {activateCodeVersion, reloadCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {CodeCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; -export default class CodeActivate extends InstanceCommand { +export default class CodeActivate extends CodeCommand { static args = { codeVersion: Args.string({ description: 'Code version ID to activate', @@ -29,10 +28,10 @@ export default class CodeActivate extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...CodeCommand.baseFlags, reload: Flags.boolean({ char: 'r', - description: 'Reload the code version (toggle activation to force reload)', + description: 'Reload the code version (OCAPI only — forces a code cache reload via toggle)', default: false, }), }; @@ -45,11 +44,21 @@ export default class CodeActivate extends InstanceCommand { const codeVersionArg = this.args.codeVersion; const hostname = this.resolvedConfig.values.hostname!; - // Get code version from arg, flag, or config const codeVersion = codeVersionArg ?? this.resolvedConfig.values.codeVersion; + if (!this.flags.reload && !codeVersion) { + this.error( + t( + 'commands.code.activate.versionRequired', + 'Code version is required. Provide as argument or use --code-version flag.', + ), + ); + } + + const backend = this.createScriptsBackend(); + this.logger.debug(`Using ${backend.name} backend for code activate`); + if (this.flags.reload) { - // Reload mode - re-activate the code version this.log( t('commands.code.activate.reloading', 'Reloading code version{{version}} on {{hostname}}...', { hostname, @@ -58,7 +67,7 @@ export default class CodeActivate extends InstanceCommand { ); try { - await reloadCodeVersion(this.instance, codeVersion); + await backend.reloadCodeVersion(codeVersion); this.log( t('commands.code.activate.reloaded', 'Code version{{version}} reloaded successfully', { version: codeVersion ? ` ${codeVersion}` : '', @@ -75,16 +84,6 @@ export default class CodeActivate extends InstanceCommand { throw error; } } else { - // Activate mode - just activate the code version - if (!codeVersion) { - this.error( - t( - 'commands.code.activate.versionRequired', - 'Code version is required. Provide as argument or use --code-version flag.', - ), - ); - } - this.log( t('commands.code.activate.activating', 'Activating code version {{codeVersion}} on {{hostname}}...', { hostname, @@ -93,7 +92,7 @@ export default class CodeActivate extends InstanceCommand { ); try { - await activateCodeVersion(this.instance, codeVersion); + await backend.activateCodeVersion(codeVersion); this.log( t('commands.code.activate.activated', 'Code version {{codeVersion}} activated successfully', {codeVersion}), ); diff --git a/packages/b2c-cli/src/commands/code/delete.ts b/packages/b2c-cli/src/commands/code/delete.ts index aa5310ce6..ee61a563b 100644 --- a/packages/b2c-cli/src/commands/code/delete.ts +++ b/packages/b2c-cli/src/commands/code/delete.ts @@ -4,12 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {CodeCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; import {confirm} from '../../prompts.js'; -export default class CodeDelete extends InstanceCommand { +export default class CodeDelete extends CodeCommand { static args = { codeVersion: Args.string({ description: 'Code version ID to delete', @@ -29,7 +28,7 @@ export default class CodeDelete extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...CodeCommand.baseFlags, force: Flags.boolean({ char: 'f', description: 'Skip confirmation prompt', @@ -41,11 +40,9 @@ export default class CodeDelete extends InstanceCommand { protected operations = { confirm, - deleteCodeVersion, }; async run(): Promise { - // Prevent deletion in safe mode this.assertDestructiveOperationAllowed('delete code version'); this.requireOAuthCredentials(); @@ -53,7 +50,6 @@ export default class CodeDelete extends InstanceCommand { const codeVersion = this.args.codeVersion; const hostname = this.resolvedConfig.values.hostname!; - // Confirm deletion unless --force is used if (!this.flags.force) { const confirmed = await this.operations.confirm( t( @@ -69,6 +65,9 @@ export default class CodeDelete extends InstanceCommand { } } + const backend = this.createScriptsBackend(); + this.logger.debug(`Using ${backend.name} backend for code delete`); + this.log( t('commands.code.delete.deleting', 'Deleting code version {{codeVersion}} from {{hostname}}...', { hostname, @@ -76,7 +75,7 @@ export default class CodeDelete extends InstanceCommand { }), ); - await this.operations.deleteCodeVersion(this.instance, codeVersion); + await backend.deleteCodeVersion(codeVersion); this.log(t('commands.code.delete.deleted', 'Code version {{codeVersion}} deleted successfully', {codeVersion})); } } diff --git a/packages/b2c-cli/src/commands/code/list.ts b/packages/b2c-cli/src/commands/code/list.ts index 134705c1c..dc947cfa6 100644 --- a/packages/b2c-cli/src/commands/code/list.ts +++ b/packages/b2c-cli/src/commands/code/list.ts @@ -5,16 +5,16 @@ */ import {ux} from '@oclif/core'; import { - InstanceCommand, + CodeCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {listCodeVersions, type CodeVersion, type CodeVersionResult} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {type CodeVersionInfo} from '@salesforce/b2c-tooling-sdk/operations/code'; import {t, withDocs} from '../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'ID', get: (v) => v.id || '-', @@ -29,7 +29,7 @@ const COLUMNS: Record> = { }, lastModified: { header: 'Last Modified', - get: (v) => (v.last_modification_time ? new Date(v.last_modification_time).toLocaleString() : '-'), + get: (v) => (v.lastModificationTime ? new Date(v.lastModificationTime).toLocaleString() : '-'), }, cartridges: { header: 'Cartridges', @@ -41,7 +41,13 @@ const DEFAULT_COLUMNS = ['id', 'active', 'rollback', 'lastModified', 'cartridges const tableRenderer = new TableRenderer(COLUMNS); -export default class CodeList extends InstanceCommand { +interface CodeListResult { + count: number; + data: CodeVersionInfo[]; + total: number; +} + +export default class CodeList extends CodeCommand { static description = withDocs( t('commands.code.list.description', 'List code versions on a B2C Commerce instance'), '/cli/code.html#b2c-code-list', @@ -63,27 +69,27 @@ export default class CodeList extends InstanceCommand { static hiddenAliases = ['code:list']; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createScriptsBackend(); + this.logger.debug(`Using ${backend.name} backend for code list`); this.log(t('commands.code.list.fetching', 'Fetching code versions from {{hostname}}...', {hostname})); - const versions = await listCodeVersions(this.instance); + const versions = await backend.listCodeVersions(); - const result: CodeVersionResult = { + const result: CodeListResult = { count: versions.length, data: versions, total: versions.length, }; - // In JSON mode, just return the data - oclif handles output to stdout if (this.jsonEnabled()) { return result; } - // Human-readable table output to stdout if (versions.length === 0) { ux.stdout(t('commands.code.list.noVersions', 'No code versions found.')); return result; diff --git a/packages/b2c-cli/test/commands/code/activate.test.ts b/packages/b2c-cli/test/commands/code/activate.test.ts index f6d5f691d..82f65d543 100644 --- a/packages/b2c-cli/test/commands/code/activate.test.ts +++ b/packages/b2c-cli/test/commands/code/activate.test.ts @@ -21,34 +21,41 @@ describe('code activate', () => { return createTestCommand(CodeActivate, hooks.getConfig(), flags, args); } - it('activates when --reload is not set', async () => { - const command: any = await createCommand({}, {codeVersion: 'v1'}); + function createMockBackend() { + return { + name: 'ocapi' as const, + listCodeVersions: sinon.stub(), + getActiveCodeVersion: sinon.stub(), + activateCodeVersion: sinon.stub(), + deleteCodeVersion: sinon.stub(), + createCodeVersion: sinon.stub(), + reloadCodeVersion: sinon.stub(), + }; + } + function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + const backend = createMockBackend(); + sinon.stub(command, 'createScriptsBackend').returns(backend); + return backend; + } - const patchStub = sinon.stub().resolves({data: {}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - PATCH: patchStub, - GET: sinon.stub().rejects(new Error('Unexpected ocapi.GET')), - }, - })); + it('activates when --reload is not set', async () => { + const command: any = await createCommand({}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.activateCodeVersion.resolves(); await command.run(); - expect(patchStub.calledOnce).to.be.true; - const [path, options] = patchStub.firstCall.args; - expect(path).to.equal('/code_versions/{code_version_id}'); - expect(options?.params?.path).to.deep.equal({code_version_id: 'v1'}); - expect(options?.body).to.deep.equal({active: true}); + expect(backend.activateCodeVersion.calledOnce).to.be.true; + expect(backend.activateCodeVersion.firstCall.args[0]).to.equal('v1'); }); it('errors when no code version is provided for activate mode', async () => { const command: any = await createCommand({}, {}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); @@ -62,67 +69,19 @@ describe('code activate', () => { it('reloads the active code version when --reload is set and no arg is provided', async () => { const command: any = await createCommand({reload: true}, {}); - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); - - const getStub = sinon.stub().resolves({ - data: { - data: [ - {id: 'v1', active: true}, - {id: 'v2', active: false}, - ], - }, - error: undefined, - }); - - const patchStub = sinon.stub().resolves({data: {}, error: undefined}); - - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - PATCH: patchStub, - }, - })); + const backend = stubCommon(command); + backend.reloadCodeVersion.resolves(); await command.run(); - expect(getStub.calledOnce).to.be.true; - expect(patchStub.callCount).to.equal(2); - // Reload toggles to alternate then back to active. - const calledIds = patchStub.getCalls().map((c) => c.args[1]?.params?.path?.code_version_id); - expect(calledIds).to.deep.equal(['v2', 'v1']); + expect(backend.reloadCodeVersion.calledOnce).to.be.true; + expect(backend.reloadCodeVersion.firstCall.args[0]).to.equal(undefined); }); it('calls command.error when reload fails with an error message', async () => { const command: any = await createCommand({reload: true}, {codeVersion: 'v1'}); - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); - - // Reload toggles active → alternate → active, so we need at least two versions. - const getStub = sinon.stub().resolves({ - data: { - data: [ - {id: 'v1', active: true}, - {id: 'v2', active: false}, - ], - }, - error: undefined, - }); - - const patchStub = sinon.stub().resolves({data: {}, error: {message: 'boom'}}); - - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - PATCH: patchStub, - }, - })); + const backend = stubCommon(command); + backend.reloadCodeVersion.rejects(new Error('boom')); const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); @@ -130,6 +89,5 @@ describe('code activate', () => { expect(errorStub.calledOnce).to.be.true; expect(errorStub.firstCall.args[0]).to.include('Failed to reload code version'); - expect(patchStub.called).to.be.true; }); }); diff --git a/packages/b2c-cli/test/commands/code/delete.test.ts b/packages/b2c-cli/test/commands/code/delete.test.ts index 3e4d3aabb..74b2dbdf6 100644 --- a/packages/b2c-cli/test/commands/code/delete.test.ts +++ b/packages/b2c-cli/test/commands/code/delete.test.ts @@ -21,60 +21,63 @@ describe('code delete', () => { return createTestCommand(CodeDelete, hooks.getConfig(), flags, args); } - it('deletes without prompting when --force is set', async () => { - const command: any = await createCommand({force: true}, {codeVersion: 'v1'}); - - const instance = {config: {hostname: 'example.com'}}; + function createMockBackend() { + return { + name: 'ocapi' as const, + listCodeVersions: sinon.stub(), + getActiveCodeVersion: sinon.stub(), + activateCodeVersion: sinon.stub(), + deleteCodeVersion: sinon.stub(), + createCodeVersion: sinon.stub(), + reloadCodeVersion: sinon.stub(), + }; + } + function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); + const backend = createMockBackend(); + sinon.stub(command, 'createScriptsBackend').returns(backend); + return backend; + } - const deleteStub = sinon.stub().resolves(void 0); - command.operations = {...command.operations, deleteCodeVersion: deleteStub}; + it('deletes without prompting when --force is set', async () => { + const command: any = await createCommand({force: true}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.deleteCodeVersion.resolves(); await command.run(); - expect(deleteStub.calledOnceWithExactly(instance, 'v1')).to.equal(true); + + expect(backend.deleteCodeVersion.calledOnceWithExactly('v1')).to.equal(true); }); it('does not delete when prompt is declined', async () => { const command: any = await createCommand({}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.deleteCodeVersion.rejects(new Error('Unexpected delete')); - const instance = {config: {hostname: 'example.com'}}; - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); - sinon.stub(command, 'log').returns(void 0); - - const deleteStub = sinon.stub().rejects(new Error('Unexpected delete')); const confirmStub = sinon.stub().resolves(false); - command.operations = {...command.operations, confirm: confirmStub, deleteCodeVersion: deleteStub}; + command.operations = {...command.operations, confirm: confirmStub}; await command.run(); expect(confirmStub.calledOnce).to.equal(true); - expect(deleteStub.called).to.equal(false); + expect(backend.deleteCodeVersion.called).to.equal(false); }); it('deletes when prompt is accepted', async () => { const command: any = await createCommand({}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.deleteCodeVersion.resolves(); - const instance = {config: {hostname: 'example.com'}}; - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); - sinon.stub(command, 'log').returns(void 0); - - const deleteStub = sinon.stub().resolves(void 0); const confirmStub = sinon.stub().resolves(true); - command.operations = {...command.operations, confirm: confirmStub, deleteCodeVersion: deleteStub}; + command.operations = {...command.operations, confirm: confirmStub}; await command.run(); expect(confirmStub.calledOnce).to.equal(true); - expect(deleteStub.calledOnceWithExactly(instance, 'v1')).to.equal(true); + expect(backend.deleteCodeVersion.calledOnceWithExactly('v1')).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/code/list.test.ts b/packages/b2c-cli/test/commands/code/list.test.ts index 2fa4c8c26..434eedd55 100644 --- a/packages/b2c-cli/test/commands/code/list.test.ts +++ b/packages/b2c-cli/test/commands/code/list.test.ts @@ -22,20 +22,34 @@ describe('code list', () => { return createTestCommand(CodeList, hooks.getConfig(), flags, {}); } - it('returns data in json mode', async () => { - const command: any = await createCommand({json: true}); + function createMockBackend() { + return { + name: 'ocapi' as const, + listCodeVersions: sinon.stub(), + getActiveCodeVersion: sinon.stub(), + activateCodeVersion: sinon.stub(), + deleteCodeVersion: sinon.stub(), + createCodeVersion: sinon.stub(), + reloadCodeVersion: sinon.stub(), + }; + } + function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + const backend = createMockBackend(); + sinon.stub(command, 'createScriptsBackend').returns(backend); + return backend; + } + + it('returns data in json mode', async () => { + const command: any = await createCommand({json: true}); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const getStub = sinon.stub().resolves({data: {data: [{id: 'v1', active: true}]}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - }, - })); + backend.listCodeVersions.resolves([{id: 'v1', active: true}]); const uxStub = sinon.stub(ux, 'stdout'); @@ -47,18 +61,10 @@ describe('code list', () => { it('prints a message when no code versions are returned in non-json mode', async () => { const command: any = await createCommand({}); - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const getStub = sinon.stub().resolves({data: {data: []}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - }, - })); + backend.listCodeVersions.resolves([]); const uxStub = sinon.stub(ux, 'stdout'); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 610131a23..e10162158 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -419,7 +419,7 @@ "data" ], "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 && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.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/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.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/dx-scripts-v1.yaml b/packages/b2c-tooling-sdk/specs/dx-scripts-v1.yaml new file mode 100644 index 000000000..5f10b212b --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/dx-scripts-v1.yaml @@ -0,0 +1,409 @@ +openapi: 3.0.3 +info: + title: Scripts + version: 1.0.0 + x-api-type: Admin + x-api-family: DX +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/dx/scripts/v1" + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/code-versions: + get: + operationId: getCodeVersions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [size] + responses: + 200: + description: List of code versions successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersionResult" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts, sfcc.scripts.rw] + /organizations/{organizationId}/code-versions/{codeVersionId}: + get: + operationId: getCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [size] + responses: + 200: + description: Code version successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Code version not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts, sfcc.scripts.rw] + put: + operationId: createCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Code version successfully replaced. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 201: + description: Code version successfully created. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 409: + description: A code version with the given ID already exists. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts.rw] + delete: + operationId: deleteCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: Code version successfully deleted. + 400: + description: The active code version cannot be deleted. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Code version not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts.rw] + patch: + operationId: updateCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + required: true + responses: + 200: + description: Code version successfully updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 400: + description: The active code version cannot be modified. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Code version not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 409: + description: A code version with the given ID already exists (when renaming). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + CodeVersion: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + active: + type: boolean + cartridges: + type: array + items: + type: string + maxLength: 256 + compatibilityMode: + type: string + maxLength: 100 + activationTime: + type: string + format: date-time + lastModificationTime: + type: string + format: date-time + rollback: + type: boolean + totalSize: + type: integer + format: int64 + webDavUrl: + type: string + maxLength: 4000 + CodeVersionResult: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/CodeVersion" + type: string + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + responses: + 401unauthorized: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403forbidden: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + expand: + name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [size] + codeVersionId: + name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dwsso/oauth2/access_token" + scopes: + sfcc.scripts: Scripts API READONLY scope + sfcc.scripts.rw: Scripts API scope + authorizationCode: + authorizationUrl: "https://account.demandware.com/dwsso/oauth2/authorize" + tokenUrl: "https://account.demandware.com/dwsso/oauth2/access_token" + scopes: + sfcc.scripts: Scripts API READONLY scope + sfcc.scripts.rw: Scripts API scope diff --git a/packages/b2c-tooling-sdk/src/cli/code-command.ts b/packages/b2c-tooling-sdk/src/cli/code-command.ts new file mode 100644 index 000000000..082bbf390 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/code-command.ts @@ -0,0 +1,32 @@ +/* + * 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} from '@oclif/core'; +import {InstanceCommand} from './instance-command.js'; +import {createScriptsBackend, type ScriptsBackend} from '../operations/code/index.js'; + +/** + * Base command for code-version (Scripts) operations. + * + * Provides `createScriptsBackend()` which selects between OCAPI and SCAPI + * based on the `--api-backend` flag and `apiBackend` config field. In auto + * mode, prefers SCAPI when shortCode + tenantId are configured, falling + * back to OCAPI on `invalid_scope`. + */ +export abstract class CodeCommand extends InstanceCommand { + /** + * Creates a Scripts backend based on the resolved configuration. + */ + protected createScriptsBackend(): ScriptsBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createScriptsBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 6bd989111..a732faf93 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -97,6 +97,7 @@ export {OAuthCommand} from './oauth-command.js'; export {InstanceCommand} from './instance-command.js'; export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; +export {CodeCommand} from './code-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; export {AmCommand} from './am-command.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 08b5082cc..2f41d06ae 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,17 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI Scripts (code versions) +export {createScapiScriptsClient, SCAPI_SCRIPTS_READ_SCOPES, SCAPI_SCRIPTS_RW_SCOPES} from './scapi-scripts.js'; +export type { + ScapiScriptsClient, + ScapiScriptsClientConfig, + ScapiScriptsError, + ScapiScriptsResponse, + paths as ScapiScriptsPaths, + components as ScapiScriptsComponents, +} from './scapi-scripts.js'; + // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) export {isInvalidScopeError, resolveScapiOrOcapi} from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index adc83652e..33313678c 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -60,7 +60,8 @@ export type HttpClientType = | 'am-roles-api' | 'am-apiclients-api' | 'am-orgs-api' - | 'scapi-jobs'; + | 'scapi-jobs' + | 'scapi-scripts'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts new file mode 100644 index 000000000..4f5972544 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts @@ -0,0 +1,393 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/code-versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getCodeVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/code-versions/{codeVersionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getCodeVersion"]; + put: operations["createCodeVersion"]; + post?: never; + delete: operations["deleteCodeVersion"]; + options?: never; + head?: never; + patch: operations["updateCodeVersion"]; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + CodeVersion: { + id?: string; + active?: boolean; + cartridges?: string[]; + compatibilityMode?: string; + /** Format: date-time */ + activationTime?: string; + /** Format: date-time */ + lastModificationTime?: string; + rollback?: boolean; + /** Format: int64 */ + totalSize?: number; + webDavUrl?: string; + }; + CodeVersionResult: { + data?: components["schemas"]["CodeVersion"][]; + } & components["schemas"]["ResultBase"]; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + }; + responses: { + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + "401unauthorized": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + "403forbidden": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + expand: "size"[]; + codeVersionId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getCodeVersions: { + parameters: { + query?: { + expand?: "size"[]; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of code versions successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersionResult"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getCodeVersion: { + parameters: { + query?: { + expand?: "size"[]; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code version successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Code version not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createCodeVersion: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code version successfully replaced. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description Code version successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A code version with the given ID already exists. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteCodeVersion: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code version successfully deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The active code version cannot be deleted. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Code version not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + updateCodeVersion: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + responses: { + /** @description Code version successfully updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description The active code version cannot be modified. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Code version not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A code version with the given ID already exists (when renaming). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts new file mode 100644 index 000000000..ac8cd23cc --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts @@ -0,0 +1,52 @@ +/* + * 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 createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-scripts.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope} from './custom-apis.js'; + +export type {paths, components}; +export type ScapiScriptsClient = Client; +export type ScapiScriptsResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiScriptsError = components['schemas']['ErrorResponse']; + +export type CodeVersion = components['schemas']['CodeVersion']; + +export const SCAPI_SCRIPTS_READ_SCOPES = ['sfcc.scripts']; +export const SCAPI_SCRIPTS_RW_SCOPES = ['sfcc.scripts.rw']; + +export interface ScapiScriptsClientConfig { + shortCode: string; + tenantId: string; + /** Override scopes (default: sfcc.scripts.rw + tenant scope). */ + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiScriptsClient(config: ScapiScriptsClientConfig, auth: AuthStrategy): ScapiScriptsClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/scripts/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_SCRIPTS_RW_SCOPES, buildTenantScope(config.tenantId)]; + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware('scapi-scripts')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-SCRIPTS'})); + client.use(createLoggingMiddleware('SCAPI-SCRIPTS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index d5661432a..f98a709cb 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -204,6 +204,20 @@ export type { WatchResult, } from './operations/code/index.js'; +// Scripts (code versions) backend abstraction +export { + createScriptsBackend, + FallbackScriptsBackend, + OcapiScriptsBackend, + ScapiScriptsBackend, +} from './operations/code/index.js'; +export type { + ScriptsBackend, + ScriptsBackendConfig, + CodeVersionInfo, + ScapiScriptsBackendConfig, +} from './operations/code/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/code/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index 329a63c34..f2386d2e4 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -81,6 +81,14 @@ export { } from './versions.js'; export type {CodeVersion, CodeVersionResult} from './versions.js'; +// Scripts (code versions) backend abstraction — supports OCAPI + SCAPI +export {createScriptsBackend, FallbackScriptsBackend} from './scripts-backend.js'; +export type {ScriptsBackendConfig} from './scripts-backend.js'; +export {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +export {ScapiScriptsBackend} from './scapi-scripts-backend.js'; +export type {ScapiScriptsBackendConfig} from './scapi-scripts-backend.js'; +export type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; + // Deployment export {findAndDeployCartridges, uploadCartridges, deleteCartridges} from './deploy.js'; export type {DeployOptions, DeployResult, UploadOptions, UploadProgressInfo} from './deploy.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts new file mode 100644 index 000000000..397d2513a --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts @@ -0,0 +1,63 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import type {CodeVersion as OcapiCodeVersion} from './versions.js'; +import { + listCodeVersions as ocapiListCodeVersions, + getActiveCodeVersion as ocapiGetActiveCodeVersion, + activateCodeVersion as ocapiActivateCodeVersion, + deleteCodeVersion as ocapiDeleteCodeVersion, + createCodeVersion as ocapiCreateCodeVersion, + reloadCodeVersion as ocapiReloadCodeVersion, +} from './versions.js'; + +function mapOcapiCodeVersion(ocapi: OcapiCodeVersion): CodeVersionInfo { + return { + id: ocapi.id ?? '', + active: ocapi.active, + cartridges: ocapi.cartridges, + compatibilityMode: ocapi.compatibility_mode, + activationTime: ocapi.activation_time, + lastModificationTime: ocapi.last_modification_time, + rollback: ocapi.rollback, + totalSize: ocapi.total_size, + webDavUrl: ocapi.web_dav_url, + _raw: ocapi, + }; +} + +export class OcapiScriptsBackend implements ScriptsBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listCodeVersions(): Promise { + const versions = await ocapiListCodeVersions(this.instance); + return versions.map(mapOcapiCodeVersion); + } + + async getActiveCodeVersion(): Promise { + const active = await ocapiGetActiveCodeVersion(this.instance); + return active ? mapOcapiCodeVersion(active) : undefined; + } + + async activateCodeVersion(codeVersionId: string): Promise { + await ocapiActivateCodeVersion(this.instance, codeVersionId); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + await ocapiDeleteCodeVersion(this.instance, codeVersionId); + } + + async createCodeVersion(codeVersionId: string): Promise { + await ocapiCreateCodeVersion(this.instance, codeVersionId); + } + + async reloadCodeVersion(codeVersionId?: string): Promise { + await ocapiReloadCodeVersion(this.instance, codeVersionId); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts new file mode 100644 index 000000000..5cb4e7609 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -0,0 +1,126 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import { + createScapiScriptsClient, + SCAPI_SCRIPTS_RW_SCOPES, + SCAPI_SCRIPTS_READ_SCOPES, + type ScapiScriptsClient, + type ScapiScriptsClientConfig, + type CodeVersion as ScapiCodeVersion, +} from '../../clients/scapi-scripts.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; +import {getLogger} from '../../logging/logger.js'; + +function mapScapiCodeVersion(scapi: ScapiCodeVersion): CodeVersionInfo { + return { + id: scapi.id ?? '', + active: scapi.active, + cartridges: scapi.cartridges, + compatibilityMode: scapi.compatibilityMode, + activationTime: scapi.activationTime, + lastModificationTime: scapi.lastModificationTime, + rollback: scapi.rollback, + totalSize: scapi.totalSize, + webDavUrl: scapi.webDavUrl, + _raw: scapi, + }; +} + +export interface ScapiScriptsBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} + +export class ScapiScriptsBackend implements ScriptsBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private scopeTier: ScopeTierManager; + + constructor(private config: ScapiScriptsBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_SCRIPTS_RW_SCOPES, + readScopes: SCAPI_SCRIPTS_READ_SCOPES, + domainName: 'Scripts', + }); + } + + async listCodeVersions(): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/code-versions', { + params: {path: {organizationId: this.organizationId}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list code versions')); + } + const result = data as unknown as {data?: ScapiCodeVersion[]}; + return (result.data ?? []).map(mapScapiCodeVersion); + } + + async getActiveCodeVersion(): Promise { + const versions = await this.listCodeVersions(); + return versions.find((v) => v.active); + } + + async activateCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const logger = getLogger(); + logger.debug({codeVersionId}, `Activating code version ${codeVersionId}`); + + const {error} = await client.PATCH('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + body: {active: true} as unknown as ScapiCodeVersion, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to activate code version ${codeVersionId}`)); + } + logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to delete code version ${codeVersionId}`)); + } + } + + async createCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.PUT('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to create code version ${codeVersionId}`)); + } + } + + async reloadCodeVersion(_codeVersionId?: string): Promise { + throw new Error('Reloading code versions is not supported via SCAPI. Use --api-backend ocapi to reload.'); + } + + private buildClient(scopes: string[]): ScapiScriptsClient { + const clientConfig: ScapiScriptsClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiScriptsClient(clientConfig, this.config.auth); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts new file mode 100644 index 000000000..3874fd215 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts @@ -0,0 +1,77 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +import {ScapiScriptsBackend} from './scapi-scripts-backend.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; + +export interface ScriptsBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createScriptsBackend(config: ScriptsBackendConfig): ScriptsBackend { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Scripts', + }); + + if (resolved === 'ocapi') { + return new OcapiScriptsBackend(config.instance); + } + + const scapiBackend = new ScapiScriptsBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new OcapiScriptsBackend(config.instance); + return new FallbackScriptsBackend(scapiBackend, ocapiBackend); +} + +export class FallbackScriptsBackend extends ScapiFallbackBackend implements ScriptsBackend { + constructor(scapiBackend: ScapiScriptsBackend, ocapiBackend: OcapiScriptsBackend) { + super(scapiBackend, ocapiBackend, 'scripts'); + } + + async listCodeVersions(): Promise { + return this.withFallback((b) => b.listCodeVersions()); + } + + async getActiveCodeVersion(): Promise { + return this.withFallback((b) => b.getActiveCodeVersion()); + } + + async activateCodeVersion(codeVersionId: string): Promise { + return this.withFallback((b) => b.activateCodeVersion(codeVersionId)); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + return this.withFallback((b) => b.deleteCodeVersion(codeVersionId)); + } + + async createCodeVersion(codeVersionId: string): Promise { + return this.withFallback((b) => b.createCodeVersion(codeVersionId)); + } + + async reloadCodeVersion(codeVersionId?: string): Promise { + return this.withFallback((b) => b.reloadCodeVersion(codeVersionId)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts new file mode 100644 index 000000000..a0d8e18ce --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts @@ -0,0 +1,54 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for code-version (Scripts) operations. + * + * The OCAPI Data API and the SCAPI Scripts API both manage code versions on a + * B2C instance. We expose a single canonical shape here so command code is + * agnostic to which backend serves the request. + * + * @module operations/code/scripts-types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; + +/** + * Canonical code version. CamelCase fields match SCAPI; OCAPI mapping + * converts from snake_case. + */ +export interface CodeVersionInfo { + id: string; + active?: boolean; + cartridges?: string[]; + compatibilityMode?: string; + activationTime?: string; + lastModificationTime?: string; + rollback?: boolean; + totalSize?: number; + webDavUrl?: string; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +/** + * Backend contract for code-version operations. + * + * `reloadCodeVersion` is OCAPI-only — the SCAPI backend's implementation + * throws to advertise that. In auto mode the fallback wrapper will fall + * through to OCAPI on the first call (since reload requires the OCAPI cache + * rebuild semantics). + */ +export interface ScriptsBackend extends BackendBase { + listCodeVersions(): Promise; + getActiveCodeVersion(): Promise; + activateCodeVersion(codeVersionId: string): Promise; + deleteCodeVersion(codeVersionId: string): Promise; + createCodeVersion(codeVersionId: string): Promise; + /** + * Re-activates the current code version to force a code cache reload. + * Implemented only by the OCAPI backend. + */ + reloadCodeVersion(codeVersionId?: string): Promise; +} From bdcbcecc6db49b9d6994752cbaf7119084b73500 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:45:36 -0400 Subject: [PATCH 04/11] Add SCAPI Merchant Users support with backend abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates bm users list/get/update/delete commands to the dual-backend pattern. New BmCommand base class exposes createUsersBackend(), which selects between OCAPI and SCAPI based on --api-backend. - New SCAPI Merchant Users client (merchant/users/v1) - UsersBackend interface with canonical UserInfo (camelCase) - bm users search, bm whoami, bm access-key * stay OCAPI-only — no SCAPI equivalents - SCAPI updateUser does not support `disabled`; the SCAPI backend throws when --disabled is passed, prompting the user to use OCAPI --- .../b2c-cli/src/commands/bm/users/delete.ts | 10 +- packages/b2c-cli/src/commands/bm/users/get.ts | 31 +- .../b2c-cli/src/commands/bm/users/list.ts | 39 +- .../b2c-cli/src/commands/bm/users/update.ts | 25 +- .../test/commands/bm/users/delete.test.ts | 40 +- .../test/commands/bm/users/get.test.ts | 48 ++- .../test/commands/bm/users/list.test.ts | 51 ++- .../test/commands/bm/users/update.test.ts | 57 +-- packages/b2c-tooling-sdk/package.json | 2 +- .../specs/merchant-users-v1.yaml | 398 ++++++++++++++++++ .../b2c-tooling-sdk/src/cli/bm-command.ts | 31 ++ packages/b2c-tooling-sdk/src/cli/index.ts | 1 + packages/b2c-tooling-sdk/src/clients/index.ts | 15 + .../src/clients/middleware-registry.ts | 3 +- .../clients/scapi-merchant-users.generated.ts | 300 +++++++++++++ .../src/clients/scapi-merchant-users.ts | 57 +++ packages/b2c-tooling-sdk/src/index.ts | 18 + .../src/operations/bm-users/backend.ts | 79 ++++ .../src/operations/bm-users/index.ts | 15 + .../src/operations/bm-users/ocapi-backend.ts | 106 +++++ .../src/operations/bm-users/scapi-backend.ts | 177 ++++++++ .../src/operations/bm-users/types.ts | 97 +++++ 22 files changed, 1464 insertions(+), 136 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/merchant-users-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/cli/bm-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/types.ts diff --git a/packages/b2c-cli/src/commands/bm/users/delete.ts b/packages/b2c-cli/src/commands/bm/users/delete.ts index ed44a0979..f234dc9cd 100644 --- a/packages/b2c-cli/src/commands/bm/users/delete.ts +++ b/packages/b2c-cli/src/commands/bm/users/delete.ts @@ -4,8 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteBmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {t} from '../../../i18n/index.js'; @@ -15,7 +14,7 @@ interface DeleteResult { hostname: string; } -export default class BmUsersDelete extends InstanceCommand { +export default class BmUsersDelete extends BmCommand { static args = { login: Args.string({ description: 'User login (email) to delete', @@ -57,9 +56,12 @@ export default class BmUsersDelete extends InstanceCommand } } + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users delete`); + this.log(t('commands.bm.users.delete.deleting', 'Deleting user {{login}} from {{hostname}}...', {login, hostname})); - await deleteBmUser(this.instance, login); + await backend.deleteUser(login); const result = {success: true, login, hostname}; diff --git a/packages/b2c-cli/src/commands/bm/users/get.ts b/packages/b2c-cli/src/commands/bm/users/get.ts index ad5c4e591..efeef2bd8 100644 --- a/packages/b2c-cli/src/commands/bm/users/get.ts +++ b/packages/b2c-cli/src/commands/bm/users/get.ts @@ -4,11 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args} from '@oclif/core'; -import {InstanceCommand, printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmUser, type BmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand, printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -export default class BmUsersGet extends InstanceCommand { +export default class BmUsersGet extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -25,15 +25,18 @@ export default class BmUsersGet extends InstanceCommand { '<%= config.bin %> <%= command.id %> user@example.com --json', ]; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {login} = this.args; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users get`); + this.log(t('commands.bm.users.get.fetching', 'Fetching user {{login}} from {{hostname}}...', {login, hostname})); - const user = await getBmUser(this.instance, login); + const user = await backend.getUser(login); if (this.jsonEnabled()) { return user; @@ -44,18 +47,16 @@ export default class BmUsersGet extends InstanceCommand { [ ['Login', user.login], ['Email', user.email], - ['First Name', user.first_name], - ['Last Name', user.last_name], - ['External ID', user.external_id], + ['First Name', user.firstName], + ['Last Name', user.lastName], + ['External ID', user.externalId], ['Disabled', user.disabled?.toString()], ['Locked', user.locked?.toString()], - ['Preferred UI Locale', user.preferred_ui_locale], - ['Preferred Data Locale', user.preferred_data_locale], - ['Last Login', user.last_login_date], - ['Password Modified', user.password_modification_date], - ['Password Expires', user.password_expiration_date], - ['Created', user.creation_date], - ['Last Modified', user.last_modified], + ['Preferred UI Locale', user.preferredUiLocale], + ['Preferred Data Locale', user.preferredDataLocale], + ['Last Login', user.lastLoginDate], + ['Password Modified', user.passwordModificationDate], + ['Password Expires', user.passwordExpirationDate], ], { sections: user.roles && user.roles.length > 0 ? [{title: 'Roles', lines: user.roles}] : [], diff --git a/packages/b2c-cli/src/commands/bm/users/list.ts b/packages/b2c-cli/src/commands/bm/users/list.ts index acbb4cdd7..539668391 100644 --- a/packages/b2c-cli/src/commands/bm/users/list.ts +++ b/packages/b2c-cli/src/commands/bm/users/list.ts @@ -4,17 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags} from '@oclif/core'; -import { - InstanceCommand, - TableRenderer, - columnFlagsFor, - selectColumns, - type ColumnDef, -} from '@salesforce/b2c-tooling-sdk/cli'; -import {listBmUsers, type BmUser, type BmUsers} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo, type ListUsersResult} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { login: { header: 'Login', get: (u) => u.login || '-', @@ -25,7 +19,7 @@ const COLUMNS: Record> = { }, name: { header: 'Name', - get: (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || '-', + get: (u) => [u.firstName, u.lastName].filter(Boolean).join(' ') || '-', }, disabled: { header: 'Disabled', @@ -37,12 +31,12 @@ const COLUMNS: Record> = { }, lastLogin: { header: 'Last Login', - get: (u) => u.last_login_date || '-', + get: (u) => u.lastLoginDate || '-', extended: true, }, externalId: { header: 'External ID', - get: (u) => u.external_id || '-', + get: (u) => u.externalId || '-', extended: true, }, }; @@ -51,7 +45,7 @@ const DEFAULT_COLUMNS = ['login', 'name', 'disabled', 'locked']; const tableRenderer = new TableRenderer(COLUMNS); -export default class BmUsersList extends InstanceCommand { +export default class BmUsersList extends BmCommand { static description = t('commands.bm.users.list.description', 'List Business Manager users on an instance'); static enableJsonFlag = true; @@ -75,37 +69,40 @@ export default class BmUsersList extends InstanceCommand { ...columnFlagsFor(COLUMNS), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; const {count, start} = this.flags; + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users list`); + this.log(t('commands.bm.users.list.fetching', 'Fetching users from {{hostname}}...', {hostname})); - const users = await listBmUsers(this.instance, {count, start}); + const result = await backend.listUsers({count, start}); if (this.jsonEnabled()) { - return users; + return result; } - const items = users.data ?? []; + const items = result.hits; if (items.length === 0) { this.log(t('commands.bm.users.list.noUsers', 'No users found.')); - return users; + return result; } tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); - if (users.total && users.total > items.length) { + if (result.total && result.total > items.length) { this.log( t('commands.bm.users.list.moreUsers', '{{count}} of {{total}} users shown.', { count: items.length, - total: users.total, + total: result.total, }), ); } - return users; + return result; } } diff --git a/packages/b2c-cli/src/commands/bm/users/update.ts b/packages/b2c-cli/src/commands/bm/users/update.ts index 9b5a961c7..35eab2bf7 100644 --- a/packages/b2c-cli/src/commands/bm/users/update.ts +++ b/packages/b2c-cli/src/commands/bm/users/update.ts @@ -4,11 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {updateBmUser, type BmUser, type UpdateBmUserChanges} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo, type UpdateUserChanges} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -export default class BmUsersUpdate extends InstanceCommand { +export default class BmUsersUpdate extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -56,21 +56,21 @@ export default class BmUsersUpdate extends InstanceCommand }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {login} = this.args; const flags = this.flags; const hostname = this.resolvedConfig.values.hostname!; - const changes: UpdateBmUserChanges = {}; + const changes: UpdateUserChanges = {}; if (flags.disabled !== undefined) changes.disabled = flags.disabled; - if (flags['first-name'] !== undefined) changes.first_name = flags['first-name']; - if (flags['last-name'] !== undefined) changes.last_name = flags['last-name']; + if (flags['first-name'] !== undefined) changes.firstName = flags['first-name']; + if (flags['last-name'] !== undefined) changes.lastName = flags['last-name']; if (flags.email !== undefined) changes.email = flags.email; - if (flags['external-id'] !== undefined) changes.external_id = flags['external-id']; - if (flags['preferred-ui-locale'] !== undefined) changes.preferred_ui_locale = flags['preferred-ui-locale']; - if (flags['preferred-data-locale'] !== undefined) changes.preferred_data_locale = flags['preferred-data-locale']; + if (flags['external-id'] !== undefined) changes.externalId = flags['external-id']; + if (flags['preferred-ui-locale'] !== undefined) changes.preferredUiLocale = flags['preferred-ui-locale']; + if (flags['preferred-data-locale'] !== undefined) changes.preferredDataLocale = flags['preferred-data-locale']; if (Object.keys(changes).length === 0) { this.error( @@ -81,9 +81,12 @@ export default class BmUsersUpdate extends InstanceCommand ); } + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users update`); + this.log(t('commands.bm.users.update.updating', 'Updating user {{login}} on {{hostname}}...', {login, hostname})); - const user = await updateBmUser(this.instance, login, changes); + const user = await backend.updateUser(login, changes); if (this.jsonEnabled()) { return user; diff --git a/packages/b2c-cli/test/commands/bm/users/delete.test.ts b/packages/b2c-cli/test/commands/bm/users/delete.test.ts index 3d684dded..d8d11b192 100644 --- a/packages/b2c-cli/test/commands/bm/users/delete.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/delete.test.ts @@ -21,50 +21,58 @@ describe('bm users delete', () => { return createTestCommand(BmUsersDelete, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('deletes user with --force in JSON mode', async () => { const command: any = await createCommand({force: true}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteUser.resolves(); const result = await command.run(); expect(result.success).to.equal(true); expect(result.login).to.equal('user@x.com'); expect(result.hostname).to.equal('example.com'); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.deleteUser.calledOnce).to.equal(true); }); it('throws on 404', async () => { const command: any = await createCommand({force: true}, {login: 'missing@x.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'User not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteUser.rejects(new Error('Failed to delete user missing@x.com: User not found')); await expectError(() => command.run(), /Failed to delete user/); }); it('skips confirmation prompt in JSON mode without --force', async () => { const command: any = await createCommand({}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteUser.resolves(); const result = await command.run(); expect(result.success).to.equal(true); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.deleteUser.calledOnce).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/bm/users/get.test.ts b/packages/b2c-cli/test/commands/bm/users/get.test.ts index 2124dc431..5dca8da51 100644 --- a/packages/b2c-cli/test/commands/bm/users/get.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/get.test.ts @@ -22,40 +22,51 @@ describe('bm users get', () => { return createTestCommand(BmUsersGet, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('returns user details in JSON mode', async () => { const command: any = await createCommand({}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUser = { + backend.getUser.resolves({ login: 'user@x.com', email: 'user@x.com', - first_name: 'Test', - last_name: 'User', + firstName: 'Test', + lastName: 'User', disabled: false, - }; - const ocapiGet = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + }); const result = await command.run(); expect(result.login).to.equal('user@x.com'); - expect(result.first_name).to.equal('Test'); - expect(ocapiGet.calledOnce).to.equal(true); + expect(result.firstName).to.equal('Test'); + expect(backend.getUser.calledOnce).to.equal(true); }); it('displays user details in non-JSON mode', async () => { const command: any = await createCommand({}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const mockUser = {login: 'user@x.com', email: 'user@x.com', first_name: 'Test', last_name: 'User'}; - const ocapiGet = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getUser.resolves({login: 'user@x.com', email: 'user@x.com', firstName: 'Test', lastName: 'User'}); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -66,15 +77,10 @@ describe('bm users get', () => { it('throws on 404', async () => { const command: any = await createCommand({}, {login: 'missing@x.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'User not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getUser.rejects(new Error('Failed to get user missing@x.com: User not found')); await expectError(() => command.run(), /Failed to get user/); }); diff --git a/packages/b2c-cli/test/commands/bm/users/list.test.ts b/packages/b2c-cli/test/commands/bm/users/list.test.ts index 5a6409cef..95eb2fe0b 100644 --- a/packages/b2c-cli/test/commands/bm/users/list.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/list.test.ts @@ -21,51 +21,62 @@ describe('bm users list', () => { return createTestCommand(BmUsersList, hooks.getConfig(), flags); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('returns data in JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUsers = {count: 2, total: 2, data: [{login: 'a@x.com'}, {login: 'b@x.com'}]}; - const ocapiGet = sinon.stub().resolves({data: mockUsers, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listUsers.resolves({ + total: 2, + start: 0, + count: 2, + hits: [{login: 'a@x.com'}, {login: 'b@x.com'}], + }); const result = await command.run(); expect(result.count).to.equal(2); - expect(result.data).to.have.length(2); - expect(ocapiGet.calledOnce).to.equal(true); - expect(ocapiGet.firstCall.args[0]).to.equal('/users'); + expect(result.hits).to.have.length(2); + expect(backend.listUsers.calledOnce).to.equal(true); }); it('prints "no users" message when empty in non-JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({data: {count: 0, total: 0, data: []}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listUsers.resolves({total: 0, start: 0, count: 0, hits: []}); const result = await command.run(); - expect(result.count).to.equal(0); + expect(result.total).to.equal(0); expect(logStub.calledWith(sinon.match(/No users found/))).to.equal(true); }); - it('throws when OCAPI returns error', async () => { + it('throws when backend returns error', async () => { const command: any = await createCommand(); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'forbidden'}}, - response: {status: 403, statusText: 'Forbidden'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listUsers.rejects(new Error('Failed to list users: forbidden')); await expectError(() => command.run(), 'Failed to list users'); }); diff --git a/packages/b2c-cli/test/commands/bm/users/update.test.ts b/packages/b2c-cli/test/commands/bm/users/update.test.ts index 0d4346af5..74586b7f0 100644 --- a/packages/b2c-cli/test/commands/bm/users/update.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/update.test.ts @@ -21,43 +21,55 @@ describe('bm users update', () => { return createTestCommand(BmUsersUpdate, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('updates user with --disabled in JSON mode', async () => { const command: any = await createCommand({disabled: true}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUser = {login: 'user@x.com', disabled: true}; - const ocapiPatch = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + backend.updateUser.resolves({login: 'user@x.com', disabled: true}); const result = await command.run(); expect(result.disabled).to.equal(true); - expect(ocapiPatch.calledOnce).to.equal(true); - const body = ocapiPatch.firstCall.args[1].body; - expect(body).to.deep.equal({disabled: true}); + expect(backend.updateUser.calledOnce).to.equal(true); + const changes = backend.updateUser.firstCall.args[1]; + expect(changes).to.deep.equal({disabled: true}); }); - it('combines multiple field flags into PATCH body', async () => { + it('combines multiple field flags into changes', async () => { const command: any = await createCommand( {'first-name': 'Jane', 'last-name': 'Doe', 'preferred-ui-locale': 'en_US'}, {login: 'user@x.com'}, ); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiPatch = sinon.stub().resolves({data: {login: 'user@x.com'}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + backend.updateUser.resolves({login: 'user@x.com'}); await command.run(); - const body = ocapiPatch.firstCall.args[1].body; - expect(body).to.deep.equal({ - first_name: 'Jane', - last_name: 'Doe', - preferred_ui_locale: 'en_US', + const changes = backend.updateUser.firstCall.args[1]; + expect(changes).to.deep.equal({ + firstName: 'Jane', + lastName: 'Doe', + preferredUiLocale: 'en_US', }); }); @@ -65,22 +77,15 @@ describe('bm users update', () => { const command: any = await createCommand({}, {login: 'user@x.com'}); stubCommon(command, {jsonEnabled: true}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: sinon.stub()}})); - await expectError(() => command.run(), /No fields specified/); }); it('throws on 404', async () => { const command: any = await createCommand({disabled: true}, {login: 'missing@x.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiPatch = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'User not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + backend.updateUser.rejects(new Error('Failed to update user missing@x.com: User not found')); await expectError(() => command.run(), 'Failed to update user'); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index e10162158..aa58cc8f7 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -419,7 +419,7 @@ "data" ], "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 && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.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/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.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/merchant-users-v1.yaml b/packages/b2c-tooling-sdk/specs/merchant-users-v1.yaml new file mode 100644 index 000000000..6f0222586 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/merchant-users-v1.yaml @@ -0,0 +1,398 @@ +openapi: 3.0.3 +info: + title: Users + version: 1.0.0 + x-api-type: Admin + x-api-family: Merchant +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/merchant/users/v1" + variables: + shortCode: + default: 123456gf +paths: + /organizations/{organizationId}/users: + get: + operationId: getUsers + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 200 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns the collection of users + content: + application/json: + schema: + $ref: "#/components/schemas/UserSearch" + security: + - AmOAuth2: [sfcc.users, sfcc.users.rw] + /organizations/{organizationId}/users/{login}: + get: + operationId: getUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Returns the user details + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 404: + description: User not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users, sfcc.users.rw] + put: + operationId: createOrReplaceUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + required: true + responses: + 200: + description: The user was successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 201: + description: The user was successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 400: + description: Bad Request - Invalid user request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users.rw] + delete: + operationId: deleteUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The user was successfully deleted + 404: + description: User not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users.rw] + patch: + operationId: updateUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserUpdateRequest" + required: true + responses: + 200: + description: The user was successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 400: + description: Bad Request - Invalid user update request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: User not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Select: + type: string + minLength: 1 + pattern: ^[(].*[)]$ + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + LanguageCountry: + type: string + pattern: ^[a-z][a-z]-[A-Z][A-Z]$ + LanguageCode: + type: string + pattern: ^[a-z][a-z]$ + DefaultFallback: + type: string + default: default + pattern: ^default$ + LocaleCode: + oneOf: + - $ref: "#/components/schemas/LanguageCountry" + - $ref: "#/components/schemas/LanguageCode" + - $ref: "#/components/schemas/DefaultFallback" + User: + type: object + properties: + login: + type: string + maxLength: 256 + minLength: 1 + password: + type: string + maxLength: 256 + email: + type: string + maxLength: 256 + firstName: + type: string + maxLength: 256 + lastName: + type: string + maxLength: 256 + externalId: + type: string + maxLength: 256 + disabled: + type: boolean + locked: + type: boolean + lastLoginDate: + type: string + format: date + passwordExpirationDate: + type: string + format: date-time + passwordModificationDate: + type: string + format: date-time + preferredDataLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + preferredUiLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + roles: + type: array + items: + type: string + maxLength: 256 + required: [email, login] + UserSearch: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + type: string + required: [data] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + UserUpdateRequest: + type: object + properties: + email: + type: string + maxLength: 256 + firstName: + type: string + maxLength: 256 + lastName: + type: string + maxLength: 256 + externalId: + type: string + maxLength: 256 + preferredDataLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + preferredUiLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + select: + name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + login: + name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.users: Read access to user resources + sfcc.users.rw: Read and write access to user resources diff --git a/packages/b2c-tooling-sdk/src/cli/bm-command.ts b/packages/b2c-tooling-sdk/src/cli/bm-command.ts new file mode 100644 index 000000000..d2ed80fa3 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -0,0 +1,31 @@ +/* + * 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} from '@oclif/core'; +import {InstanceCommand} from './instance-command.js'; +import {createUsersBackend, type UsersBackend} from '../operations/bm-users/index.js'; + +/** + * Base command for Business Manager (instance-level) operations. + * + * Provides backend factories that select between OCAPI and SCAPI based on + * `--api-backend`. In auto mode, prefers SCAPI when shortCode + tenantId are + * configured, falling back to OCAPI on `invalid_scope`. + */ +export abstract class BmCommand extends InstanceCommand { + /** + * Creates a Users backend for `bm users *` commands. + */ + protected createUsersBackend(): UsersBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createUsersBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index a732faf93..b60e61aec 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -98,6 +98,7 @@ export {InstanceCommand} from './instance-command.js'; export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; export {CodeCommand} from './code-command.js'; +export {BmCommand} from './bm-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; export {AmCommand} from './am-command.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 2f41d06ae..d2c850bc3 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,21 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI Merchant Users +export { + createScapiMerchantUsersClient, + SCAPI_MERCHANT_USERS_READ_SCOPES, + SCAPI_MERCHANT_USERS_RW_SCOPES, +} from './scapi-merchant-users.js'; +export type { + ScapiMerchantUsersClient, + ScapiMerchantUsersClientConfig, + ScapiMerchantUsersError, + ScapiMerchantUsersResponse, + paths as ScapiMerchantUsersPaths, + components as ScapiMerchantUsersComponents, +} from './scapi-merchant-users.js'; + // SCAPI Scripts (code versions) export {createScapiScriptsClient, SCAPI_SCRIPTS_READ_SCOPES, SCAPI_SCRIPTS_RW_SCOPES} from './scapi-scripts.js'; export type { diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 33313678c..7e1011932 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -61,7 +61,8 @@ export type HttpClientType = | 'am-apiclients-api' | 'am-orgs-api' | 'scapi-jobs' - | 'scapi-scripts'; + | 'scapi-scripts' + | 'scapi-merchant-users'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts new file mode 100644 index 000000000..34f472962 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts @@ -0,0 +1,300 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/users/{login}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUser"]; + put: operations["createOrReplaceUser"]; + post?: never; + delete: operations["deleteUser"]; + options?: never; + head?: never; + patch: operations["updateUser"]; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Select: string; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + LanguageCountry: string; + LanguageCode: string; + /** @default default */ + DefaultFallback: string; + LocaleCode: components["schemas"]["LanguageCountry"] | components["schemas"]["LanguageCode"] | components["schemas"]["DefaultFallback"]; + User: { + login: string; + password?: string; + email: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + locked?: boolean; + /** Format: date */ + lastLoginDate?: string; + /** Format: date-time */ + passwordExpirationDate?: string; + /** Format: date-time */ + passwordModificationDate?: string; + preferredDataLocale?: components["schemas"]["LocaleCode"]; + preferredUiLocale?: components["schemas"]["LocaleCode"]; + roles?: string[]; + }; + UserSearch: { + data: components["schemas"]["User"][]; + } & components["schemas"]["PaginatedResultBase"]; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + UserUpdateRequest: { + email?: string; + firstName?: string; + lastName?: string; + externalId?: string; + preferredDataLocale?: components["schemas"]["LocaleCode"]; + preferredUiLocale?: components["schemas"]["LocaleCode"]; + }; + }; + responses: never; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + select: components["schemas"]["Select"]; + login: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getUsers: { + parameters: { + query?: { + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the collection of users */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSearch"]; + }; + }; + }; + }; + getUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the user details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createOrReplaceUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["User"]; + }; + }; + responses: { + /** @description The user was successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description The user was successfully created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Bad Request - Invalid user request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user was successfully deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdateRequest"]; + }; + }; + responses: { + /** @description The user was successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Bad Request - Invalid user update request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts new file mode 100644 index 000000000..384d47f65 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-merchant-users.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope} from './custom-apis.js'; + +export type {paths, components}; +export type ScapiMerchantUsersClient = Client; +export type ScapiMerchantUsersResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiMerchantUsersError = components['schemas']['ErrorResponse']; + +export type User = components['schemas']['User']; +export type UserUpdateRequest = components['schemas']['UserUpdateRequest']; +export type UserSearch = components['schemas']['UserSearch']; + +export const SCAPI_MERCHANT_USERS_READ_SCOPES = ['sfcc.users']; +export const SCAPI_MERCHANT_USERS_RW_SCOPES = ['sfcc.users.rw']; + +export interface ScapiMerchantUsersClientConfig { + shortCode: string; + tenantId: string; + /** Override scopes (default: sfcc.users.rw + tenant scope). */ + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiMerchantUsersClient( + config: ScapiMerchantUsersClientConfig, + auth: AuthStrategy, +): ScapiMerchantUsersClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/users/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_USERS_RW_SCOPES, buildTenantScope(config.tenantId)]; + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware('scapi-merchant-users')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-USERS'})); + client.use(createLoggingMiddleware('SCAPI-USERS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index f98a709cb..e7810ae6c 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -218,6 +218,24 @@ export type { ScapiScriptsBackendConfig, } from './operations/code/index.js'; +// Users (BM) backend abstraction +export { + createUsersBackend, + FallbackUsersBackend, + OcapiUsersBackend, + ScapiUsersBackend, +} from './operations/bm-users/index.js'; +export type { + UsersBackend, + UsersBackendConfig, + UserInfo, + ListUsersResult, + ListUsersOptions, + CreateUserInput, + UpdateUserChanges, + ScapiUsersBackendConfig, +} from './operations/bm-users/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts new file mode 100644 index 000000000..3a8263958 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts @@ -0,0 +1,79 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, +} from './types.js'; +import {OcapiUsersBackend} from './ocapi-backend.js'; +import {ScapiUsersBackend} from './scapi-backend.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; + +export interface UsersBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createUsersBackend(config: UsersBackendConfig): UsersBackend { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Users', + }); + + if (resolved === 'ocapi') { + return new OcapiUsersBackend(config.instance); + } + + const scapiBackend = new ScapiUsersBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + const ocapiBackend = new OcapiUsersBackend(config.instance); + return new FallbackUsersBackend(scapiBackend, ocapiBackend); +} + +export class FallbackUsersBackend extends ScapiFallbackBackend implements UsersBackend { + constructor(scapiBackend: ScapiUsersBackend, ocapiBackend: OcapiUsersBackend) { + super(scapiBackend, ocapiBackend, 'users'); + } + + async listUsers(options?: ListUsersOptions): Promise { + return this.withFallback((b) => b.listUsers(options)); + } + + async getUser(login: string): Promise { + return this.withFallback((b) => b.getUser(login)); + } + + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { + return this.withFallback((b) => b.createOrReplaceUser(login, input)); + } + + async updateUser(login: string, changes: UpdateUserChanges): Promise { + return this.withFallback((b) => b.updateUser(login, changes)); + } + + async deleteUser(login: string): Promise { + return this.withFallback((b) => b.deleteUser(login)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts index 3de7da64f..27d00fdb0 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -75,3 +75,18 @@ export type { SearchBmUsersOptions, UpdateBmUserChanges, } from './users.js'; + +// Users backend abstraction — supports OCAPI + SCAPI +export {createUsersBackend, FallbackUsersBackend} from './backend.js'; +export type {UsersBackendConfig} from './backend.js'; +export {OcapiUsersBackend} from './ocapi-backend.js'; +export {ScapiUsersBackend} from './scapi-backend.js'; +export type {ScapiUsersBackendConfig} from './scapi-backend.js'; +export type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + CreateUserInput, + UpdateUserChanges, +} from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts new file mode 100644 index 000000000..2fb94d01b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts @@ -0,0 +1,106 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, +} from './types.js'; +import { + listBmUsers as ocapiListBmUsers, + getBmUser as ocapiGetBmUser, + updateBmUser as ocapiUpdateBmUser, + deleteBmUser as ocapiDeleteBmUser, + type BmUser, +} from './users.js'; +import {getApiErrorMessage} from '../../clients/error-utils.js'; +import type {components} from '../../clients/ocapi.generated.js'; + +function mapOcapiUser(ocapi: BmUser): UserInfo { + return { + login: ocapi.login ?? '', + email: ocapi.email, + firstName: ocapi.first_name, + lastName: ocapi.last_name, + externalId: ocapi.external_id, + disabled: ocapi.disabled, + locked: ocapi.locked, + lastLoginDate: ocapi.last_login_date, + passwordExpirationDate: ocapi.password_expiration_date, + passwordModificationDate: ocapi.password_modification_date, + preferredDataLocale: ocapi.preferred_data_locale, + preferredUiLocale: ocapi.preferred_ui_locale, + roles: ocapi.roles, + _raw: ocapi, + }; +} + +export class OcapiUsersBackend implements UsersBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listUsers(options: ListUsersOptions = {}): Promise { + const result = await ocapiListBmUsers(this.instance, options); + const users = (result.data ?? []) as BmUser[]; + return { + total: result.total ?? 0, + start: result.start ?? 0, + count: result.count ?? users.length, + hits: users.map(mapOcapiUser), + }; + } + + async getUser(login: string): Promise { + const user = await ocapiGetBmUser(this.instance, login); + return mapOcapiUser(user); + } + + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { + // Map canonical camelCase → OCAPI snake_case. + const body: Record = { + login: input.login, + email: input.email, + first_name: input.firstName, + last_name: input.lastName, + external_id: input.externalId, + password: input.password, + disabled: input.disabled, + preferred_data_locale: input.preferredDataLocale, + preferred_ui_locale: input.preferredUiLocale, + roles: input.roles, + }; + const {data, error, response} = await this.instance.ocapi.PUT('/users/{login}', { + params: {path: {login}}, + body: body as components['schemas']['user'], + }); + if (error) { + throw new Error(`Failed to create user ${login}: ${getApiErrorMessage(error, response)}`); + } + return mapOcapiUser(data as BmUser); + } + + async updateUser(login: string, changes: UpdateUserChanges): Promise { + const ocapiChanges: Record = { + email: changes.email, + first_name: changes.firstName, + last_name: changes.lastName, + external_id: changes.externalId, + disabled: changes.disabled, + preferred_data_locale: changes.preferredDataLocale, + preferred_ui_locale: changes.preferredUiLocale, + }; + const updated = await ocapiUpdateBmUser(this.instance, login, ocapiChanges); + return mapOcapiUser(updated); + } + + async deleteUser(login: string): Promise { + await ocapiDeleteBmUser(this.instance, login); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts new file mode 100644 index 000000000..84ad3b244 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -0,0 +1,177 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, +} from './types.js'; +import { + createScapiMerchantUsersClient, + SCAPI_MERCHANT_USERS_RW_SCOPES, + SCAPI_MERCHANT_USERS_READ_SCOPES, + type ScapiMerchantUsersClient, + type ScapiMerchantUsersClientConfig, + type User as ScapiUser, + type UserUpdateRequest, + type UserSearch, +} from '../../clients/scapi-merchant-users.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; + +function mapScapiUser(scapi: ScapiUser): UserInfo { + return { + login: scapi.login, + email: scapi.email, + firstName: scapi.firstName, + lastName: scapi.lastName, + externalId: scapi.externalId, + disabled: scapi.disabled, + locked: scapi.locked, + lastLoginDate: scapi.lastLoginDate, + passwordExpirationDate: scapi.passwordExpirationDate, + passwordModificationDate: scapi.passwordModificationDate, + preferredDataLocale: scapi.preferredDataLocale as string | undefined, + preferredUiLocale: scapi.preferredUiLocale as string | undefined, + roles: scapi.roles, + _raw: scapi, + }; +} + +export interface ScapiUsersBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} + +export class ScapiUsersBackend implements UsersBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private scopeTier: ScopeTierManager; + + constructor(private config: ScapiUsersBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_MERCHANT_USERS_RW_SCOPES, + readScopes: SCAPI_MERCHANT_USERS_READ_SCOPES, + domainName: 'Users', + }); + } + + async listUsers(options: ListUsersOptions = {}): Promise { + const client = this.scopeTier.getClientForRead(); + const {start = 0, count = 25} = options; + + const {data, error} = await client.GET('/organizations/{organizationId}/users', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list users')); + } + const result = data as UserSearch; + return { + total: result.total ?? 0, + start: result.offset ?? start, + count: result.limit ?? count, + hits: (result.data ?? []).map(mapScapiUser), + }; + } + + async getUser(login: string): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get user ${login}`)); + } + return mapScapiUser(data); + } + + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { + const client = this.scopeTier.getClientForWrite(); + const body: ScapiUser = { + login: input.login, + email: input.email, + firstName: input.firstName, + lastName: input.lastName, + externalId: input.externalId, + password: input.password, + disabled: input.disabled, + preferredDataLocale: input.preferredDataLocale, + preferredUiLocale: input.preferredUiLocale, + roles: input.roles, + }; + const {data, error} = await client.PUT('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + body, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to create user ${login}`)); + } + return mapScapiUser(data); + } + + async updateUser(login: string, changes: UpdateUserChanges): Promise { + const client = this.scopeTier.getClientForWrite(); + // SCAPI UserUpdateRequest doesn't include `disabled`. To toggle disabled, + // callers must use createOrReplaceUser (PUT) on SCAPI or the OCAPI backend. + const body: UserUpdateRequest = { + email: changes.email, + firstName: changes.firstName, + lastName: changes.lastName, + externalId: changes.externalId, + preferredDataLocale: changes.preferredDataLocale, + preferredUiLocale: changes.preferredUiLocale, + }; + if (changes.disabled !== undefined) { + throw new Error( + 'SCAPI Users API does not support updating the `disabled` flag via PATCH. ' + + 'Use --api-backend ocapi to change disabled status.', + ); + } + const {data, error} = await client.PATCH('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + body, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to update user ${login}`)); + } + return mapScapiUser(data); + } + + async deleteUser(login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to delete user ${login}`)); + } + } + + private buildClient(scopes: string[]): ScapiMerchantUsersClient { + const clientConfig: ScapiMerchantUsersClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiMerchantUsersClient(clientConfig, this.config.auth); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts new file mode 100644 index 000000000..2948fdd16 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts @@ -0,0 +1,97 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for Business Manager user operations. + * + * Both the OCAPI Data API (`/users`) and the SCAPI Merchant Users API + * (`merchant/users/v1`) manage instance-level users on a B2C Commerce + * instance. We expose a single canonical shape (camelCase, matching SCAPI) + * so command code is agnostic to which backend serves the request. + * + * @module operations/bm-users/types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; + +/** + * Canonical Business Manager user. CamelCase fields match SCAPI; OCAPI + * mapping converts from snake_case. + */ +export interface UserInfo { + login: string; + email?: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + locked?: boolean; + lastLoginDate?: string; + passwordExpirationDate?: string; + passwordModificationDate?: string; + preferredDataLocale?: string; + preferredUiLocale?: string; + roles?: string[]; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +/** + * Patch fields. SCAPI uses camelCase; OCAPI backend translates to snake_case. + */ +export interface UpdateUserChanges { + email?: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + preferredDataLocale?: string; + preferredUiLocale?: string; +} + +/** + * Result of listing users — paginated. + */ +export interface ListUsersResult { + total: number; + start: number; + count: number; + hits: UserInfo[]; +} + +export interface ListUsersOptions { + start?: number; + count?: number; +} + +/** + * Body for create/replace (PUT). Required: login. + */ +export interface CreateUserInput { + login: string; + email: string; + firstName?: string; + lastName?: string; + externalId?: string; + password?: string; + disabled?: boolean; + preferredDataLocale?: string; + preferredUiLocale?: string; + roles?: string[]; +} + +/** + * Backend contract for BM user operations. + * + * Note: search and access-key operations remain OCAPI-only — they have + * no SCAPI equivalent in `merchant/users/v1`. The `whoami` operation is + * also OCAPI-only (resolves the BM identity behind the OAuth token). + */ +export interface UsersBackend extends BackendBase { + listUsers(options?: ListUsersOptions): Promise; + getUser(login: string): Promise; + createOrReplaceUser(login: string, input: CreateUserInput): Promise; + updateUser(login: string, changes: UpdateUserChanges): Promise; + deleteUser(login: string): Promise; +} From d46e734384f846d813bd6c49dd825cb78760d6f1 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:58:11 -0400 Subject: [PATCH 05/11] Add SCAPI Merchant Roles support with backend abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates bm roles list/get/create/delete/grant/revoke and bm roles permissions get/set commands to the dual-backend pattern. BmCommand now exposes createRolesBackend() alongside createUsersBackend(). - New SCAPI Merchant Roles client (merchant/roles/v1) - RolesBackend with canonical RoleInfo and RolePermissionsInfo (camelCase; OCAPI mapping converts snake_case fields like locale_id → localeId) - Permissions display updated to use canonical camelCase fields - Bm roles get --expand users continues to work via the _raw escape hatch (handles both OCAPI snake_case and SCAPI camelCase user fields) --- .../b2c-cli/src/commands/bm/roles/create.ts | 13 +- .../b2c-cli/src/commands/bm/roles/delete.ts | 10 +- packages/b2c-cli/src/commands/bm/roles/get.ts | 41 +- .../b2c-cli/src/commands/bm/roles/grant.ts | 26 +- .../b2c-cli/src/commands/bm/roles/list.ts | 37 +- .../src/commands/bm/roles/permissions/get.ts | 19 +- .../src/commands/bm/roles/permissions/set.ts | 17 +- .../b2c-cli/src/commands/bm/roles/revoke.ts | 10 +- .../b2c-cli/src/commands/code/activate.ts | 2 +- .../test/commands/bm/roles/create.test.ts | 42 +- .../test/commands/bm/roles/delete.test.ts | 47 +- .../test/commands/bm/roles/get.test.ts | 43 +- .../test/commands/bm/roles/grant.test.ts | 45 +- .../test/commands/bm/roles/list.test.ts | 48 +- .../test/commands/bm/roles/revoke.test.ts | 41 +- packages/b2c-tooling-sdk/package.json | 2 +- .../specs/merchant-roles-v1.yaml | 1085 +++++++++++++++++ .../b2c-tooling-sdk/src/cli/bm-command.ts | 15 + packages/b2c-tooling-sdk/src/clients/index.ts | 15 + .../src/clients/middleware-registry.ts | 3 +- .../clients/scapi-merchant-roles.generated.ts | 726 +++++++++++ .../src/clients/scapi-merchant-roles.ts | 57 + packages/b2c-tooling-sdk/src/index.ts | 18 + .../src/operations/bm-roles/backend.ts | 91 ++ .../src/operations/bm-roles/index.ts | 15 + .../src/operations/bm-roles/ocapi-backend.ts | 168 +++ .../src/operations/bm-roles/scapi-backend.ts | 178 +++ .../src/operations/bm-roles/types.ts | 58 + 28 files changed, 2695 insertions(+), 177 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/merchant-roles-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts diff --git a/packages/b2c-cli/src/commands/bm/roles/create.ts b/packages/b2c-cli/src/commands/bm/roles/create.ts index 14682eee9..d604531d3 100644 --- a/packages/b2c-cli/src/commands/bm/roles/create.ts +++ b/packages/b2c-cli/src/commands/bm/roles/create.ts @@ -4,11 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {createBmRole, type BmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RoleInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../i18n/index.js'; -export default class BmRolesCreate extends InstanceCommand { +export default class BmRolesCreate extends BmCommand { static args = { role: Args.string({ description: 'Role ID to create', @@ -33,16 +33,19 @@ export default class BmRolesCreate extends InstanceCommand }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; const {description} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles create`); + this.log(t('commands.bm.roles.create.creating', 'Creating role {{roleId}} on {{hostname}}...', {roleId, hostname})); - const role = await createBmRole(this.instance, roleId, {description}); + const role = await backend.createRole(roleId, {description}); if (this.jsonEnabled()) { return role; diff --git a/packages/b2c-cli/src/commands/bm/roles/delete.ts b/packages/b2c-cli/src/commands/bm/roles/delete.ts index 163e9b51b..b59eb524b 100644 --- a/packages/b2c-cli/src/commands/bm/roles/delete.ts +++ b/packages/b2c-cli/src/commands/bm/roles/delete.ts @@ -4,8 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t} from '../../../i18n/index.js'; interface DeleteResult { @@ -14,7 +13,7 @@ interface DeleteResult { hostname: string; } -export default class BmRolesDelete extends InstanceCommand { +export default class BmRolesDelete extends BmCommand { static args = { role: Args.string({ description: 'Role ID to delete', @@ -37,11 +36,14 @@ export default class BmRolesDelete extends InstanceCommand const {role: roleId} = this.args; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles delete`); + this.log( t('commands.bm.roles.delete.deleting', 'Deleting role {{roleId}} from {{hostname}}...', {roleId, hostname}), ); - await deleteBmRole(this.instance, roleId); + await backend.deleteRole(roleId); const result = {success: true, role: roleId, hostname}; diff --git a/packages/b2c-cli/src/commands/bm/roles/get.ts b/packages/b2c-cli/src/commands/bm/roles/get.ts index 74a9eee5e..b7b55c383 100644 --- a/packages/b2c-cli/src/commands/bm/roles/get.ts +++ b/packages/b2c-cli/src/commands/bm/roles/get.ts @@ -4,11 +4,19 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand, printFieldsBlock, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmRole, type BmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand, printFieldsBlock, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RoleInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../i18n/index.js'; -export default class BmRolesGet extends InstanceCommand { +interface ExpandedUser { + login?: string; + first_name?: string; + last_name?: string; + firstName?: string; + lastName?: string; +} + +export default class BmRolesGet extends BmCommand { static args = { role: Args.string({ description: 'Role ID (e.g. "Administrator")', @@ -29,33 +37,42 @@ export default class BmRolesGet extends InstanceCommand { static flags = { expand: Flags.string({ char: 'e', - description: 'Expansions to apply (e.g. users, permissions)', + description: 'Expansions to apply (users, permissions)', multiple: true, + options: ['users', 'permissions'], }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; const {expand} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles get`); + this.log(t('commands.bm.roles.get.fetching', 'Fetching role {{roleId}} from {{hostname}}...', {roleId, hostname})); - const role = await getBmRole(this.instance, roleId, {expand}); + const role = await backend.getRole(roleId, {expand: expand as ('permissions' | 'users')[] | undefined}); if (this.jsonEnabled()) { return role; } const sections: DetailSection[] = []; - if (role.users && role.users.length > 0) { + // Users may be present on _raw (both OCAPI and SCAPI return them under role.users when --expand users). + const raw = role._raw as undefined | {users?: ExpandedUser[]}; + const users = raw?.users; + if (users && users.length > 0) { sections.push({ title: 'Assigned Users', - lines: role.users.map((user) => { + lines: users.map((user) => { const login = user.login || '-'; - const name = [user.first_name, user.last_name].filter(Boolean).join(' '); + const first = user.firstName ?? user.first_name; + const last = user.lastName ?? user.last_name; + const name = [first, last].filter(Boolean).join(' '); return name ? `${login} ${name}` : login; }), }); @@ -66,10 +83,8 @@ export default class BmRolesGet extends InstanceCommand { [ ['ID', role.id], ['Description', role.description], - ['User Count', role.user_count?.toString()], - ['User Manager', role.user_manager?.toString()], - ['Created', role.creation_date], - ['Last Modified', role.last_modified], + ['User Count', role.userCount?.toString()], + ['User Manager', role.userManager?.toString()], ], {sections}, ); diff --git a/packages/b2c-cli/src/commands/bm/roles/grant.ts b/packages/b2c-cli/src/commands/bm/roles/grant.ts index 95a0434f8..b00d0dfff 100644 --- a/packages/b2c-cli/src/commands/bm/roles/grant.ts +++ b/packages/b2c-cli/src/commands/bm/roles/grant.ts @@ -4,14 +4,17 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {grantBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; -import type {OcapiComponents} from '@salesforce/b2c-tooling-sdk'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t} from '../../../i18n/index.js'; -type OcapiUser = OcapiComponents['schemas']['user']; +interface GrantResult { + success: boolean; + role: string; + login: string; + hostname: string; +} -export default class BmRolesGrant extends InstanceCommand { +export default class BmRolesGrant extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -39,13 +42,16 @@ export default class BmRolesGrant extends InstanceCommand { }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {login} = this.args; const {role} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles grant`); + this.log( t('commands.bm.roles.grant.granting', 'Granting role {{role}} to {{login}} on {{hostname}}...', { role, @@ -54,10 +60,12 @@ export default class BmRolesGrant extends InstanceCommand { }), ); - const user = await grantBmRole(this.instance, role, login); + await backend.grantRole(role, login); + + const result: GrantResult = {success: true, role, login, hostname}; if (this.jsonEnabled()) { - return user; + return result; } this.log( @@ -68,6 +76,6 @@ export default class BmRolesGrant extends InstanceCommand { }), ); - return user; + return result; } } diff --git a/packages/b2c-cli/src/commands/bm/roles/list.ts b/packages/b2c-cli/src/commands/bm/roles/list.ts index 680541dff..6cc709740 100644 --- a/packages/b2c-cli/src/commands/bm/roles/list.ts +++ b/packages/b2c-cli/src/commands/bm/roles/list.ts @@ -4,17 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags} from '@oclif/core'; -import { - InstanceCommand, - TableRenderer, - columnFlagsFor, - selectColumns, - type ColumnDef, -} from '@salesforce/b2c-tooling-sdk/cli'; -import {listBmRoles, type BmRole, type BmRoles} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RoleInfo, type ListRolesResult} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'ID', get: (r) => r.id || '-', @@ -26,11 +20,11 @@ const COLUMNS: Record> = { }, userCount: { header: 'Users', - get: (r) => r.user_count?.toString() ?? '-', + get: (r) => r.userCount?.toString() ?? '-', }, userManager: { header: 'User Manager', - get: (r) => (r.user_manager ? 'Yes' : 'No'), + get: (r) => (r.userManager ? 'Yes' : 'No'), extended: true, }, }; @@ -39,7 +33,7 @@ const DEFAULT_COLUMNS = ['id', 'userCount']; const tableRenderer = new TableRenderer(COLUMNS); -export default class BmRolesList extends InstanceCommand { +export default class BmRolesList extends BmCommand { static description = t('commands.bm.roles.list.description', 'List Business Manager access roles on an instance'); static enableJsonFlag = true; @@ -64,37 +58,40 @@ export default class BmRolesList extends InstanceCommand { ...columnFlagsFor(COLUMNS), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; const {count, start} = this.flags; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles list`); + this.log(t('commands.bm.roles.list.fetching', 'Fetching roles from {{hostname}}...', {hostname})); - const roles = await listBmRoles(this.instance, {count, start}); + const result = await backend.listRoles({count, start}); if (this.jsonEnabled()) { - return roles; + return result; } - const items = roles.data ?? []; + const items = result.hits; if (items.length === 0) { this.log(t('commands.bm.roles.list.noRoles', 'No roles found.')); - return roles; + return result; } tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); - if (roles.total && roles.total > items.length) { + if (result.total && result.total > items.length) { this.log( t('commands.bm.roles.list.moreRoles', '{{count}} of {{total}} roles shown.', { count: items.length, - total: roles.total, + total: result.total, }), ); } - return roles; + return result; } } diff --git a/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts b/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts index 544736549..7eaa9d68f 100644 --- a/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts +++ b/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts @@ -6,11 +6,11 @@ import fs from 'node:fs'; import {Args, Flags, ux} from '@oclif/core'; import cliui from 'cliui'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmRolePermissions, type BmRolePermissions} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RolePermissionsInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../../i18n/index.js'; -export default class BmRolesPermissionsGet extends InstanceCommand { +export default class BmRolesPermissionsGet extends BmCommand { static args = { role: Args.string({ description: 'Role ID (e.g. "Administrator")', @@ -38,13 +38,16 @@ export default class BmRolesPermissionsGet extends InstanceCommand { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; const {output} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles permissions get`); + this.log( t('commands.bm.roles.permissions.get.fetching', 'Fetching permissions for role {{roleId}} on {{hostname}}...', { roleId, @@ -52,7 +55,7 @@ export default class BmRolesPermissionsGet extends InstanceCommand p.name)], ['Functional (site)', functionalSite.length, functionalSite.map((p) => p.name)], ['Module (organization)', moduleOrg.length, moduleOrg.map((p) => `${p.application}:${p.name}`)], ['Module (site)', moduleSite.length, moduleSite.map((p) => `${p.application}:${p.name}`)], - ['Locale', localeUnscoped.length, localeUnscoped.map((p) => p.locale_id)], + ['Locale', localeUnscoped.length, localeUnscoped.map((p) => p.localeId)], ['WebDAV', webdavUnscoped.length, webdavUnscoped.map((p) => p.folder)], ]; diff --git a/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts b/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts index adc4d8b40..51331b7af 100644 --- a/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts +++ b/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts @@ -5,11 +5,11 @@ */ import fs from 'node:fs'; import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {setBmRolePermissions, type BmRolePermissions} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RolePermissionsInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../../i18n/index.js'; -export default class BmRolesPermissionsSet extends InstanceCommand { +export default class BmRolesPermissionsSet extends BmCommand { static args = { role: Args.string({ description: 'Role ID (e.g. "Administrator")', @@ -34,7 +34,7 @@ export default class BmRolesPermissionsSet extends InstanceCommand { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; @@ -45,14 +45,17 @@ export default class BmRolesPermissionsSet extends InstanceCommand { +export default class BmRolesRevoke extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -50,6 +49,9 @@ export default class BmRolesRevoke extends InstanceCommand const {role} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles revoke`); + this.log( t('commands.bm.roles.revoke.revoking', 'Revoking role {{role}} from {{login}} on {{hostname}}...', { role, @@ -58,7 +60,7 @@ export default class BmRolesRevoke extends InstanceCommand }), ); - await revokeBmRole(this.instance, role, login); + await backend.revokeRole(role, login); const result = {success: true, role, login, hostname}; diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index f126f2b05..f51b8b8e6 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -92,7 +92,7 @@ export default class CodeActivate extends CodeCommand { ); try { - await backend.activateCodeVersion(codeVersion); + await backend.activateCodeVersion(codeVersion!); this.log( t('commands.code.activate.activated', 'Code version {{codeVersion}} activated successfully', {codeVersion}), ); diff --git a/packages/b2c-cli/test/commands/bm/roles/create.test.ts b/packages/b2c-cli/test/commands/bm/roles/create.test.ts index 0e2bcc83b..fccb808ba 100644 --- a/packages/b2c-cli/test/commands/bm/roles/create.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/create.test.ts @@ -21,32 +21,47 @@ describe('bm roles create', () => { return createTestCommand(BmRolesCreate, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('creates role and returns in JSON mode', async () => { const command: any = await createCommand({description: 'Test role'}, {role: 'TestRole'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockRole = {id: 'TestRole', description: 'Test role'}; - const ocapiPut = sinon.stub().resolves({data: mockRole, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.createRole.resolves({id: 'TestRole', description: 'Test role'}); const result = await command.run(); expect(result.id).to.equal('TestRole'); - expect(ocapiPut.calledOnce).to.equal(true); + expect(backend.createRole.calledOnce).to.equal(true); }); it('logs success in non-JSON mode', async () => { const command: any = await createCommand({}, {role: 'TestRole'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({data: {id: 'TestRole'}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.createRole.resolves({id: 'TestRole'}); await command.run(); expect(logStub.calledWith(sinon.match('TestRole'))).to.equal(true); @@ -54,15 +69,10 @@ describe('bm roles create', () => { it('throws on 403 for reserved roles', async () => { const command: any = await createCommand({}, {role: 'Support'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Operation not allowed'}}, - response: {status: 403, statusText: 'Forbidden'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.createRole.rejects(new Error('Failed to create role Support: Operation not allowed')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/delete.test.ts b/packages/b2c-cli/test/commands/bm/roles/delete.test.ts index 18521ad9a..8d2402eb1 100644 --- a/packages/b2c-cli/test/commands/bm/roles/delete.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/delete.test.ts @@ -21,36 +21,48 @@ describe('bm roles delete', () => { return createTestCommand(BmRolesDelete, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('deletes role and returns result in JSON mode', async () => { const command: any = await createCommand({}, {role: 'TestRole'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteRole.resolves(); const result = await command.run(); expect(result.success).to.equal(true); expect(result.role).to.equal('TestRole'); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.deleteRole.calledOnce).to.equal(true); }); it('throws on 403 for system roles', async () => { const command: any = await createCommand({}, {role: 'Administrator'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Deletion not allowed'}}, - response: {status: 403, statusText: 'Forbidden'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteRole.rejects(new Error('Failed to delete role Administrator: Deletion not allowed')); try { await command.run(); @@ -62,15 +74,10 @@ describe('bm roles delete', () => { it('throws on 404 for non-existent role', async () => { const command: any = await createCommand({}, {role: 'NoSuchRole'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Role not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteRole.rejects(new Error('Failed to delete role NoSuchRole: Role not found')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/get.test.ts b/packages/b2c-cli/test/commands/bm/roles/get.test.ts index d9bd5a869..d979087b8 100644 --- a/packages/b2c-cli/test/commands/bm/roles/get.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/get.test.ts @@ -22,33 +22,47 @@ describe('bm roles get', () => { return createTestCommand(BmRolesGet, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('returns role details in JSON mode', async () => { const command: any = await createCommand({}, {role: 'Administrator'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockRole = {id: 'Administrator', description: 'Admin role', user_count: 5, user_manager: true}; - const ocapiGet = sinon.stub().resolves({data: mockRole, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getRole.resolves({id: 'Administrator', description: 'Admin role', userCount: 5, userManager: true}); const result = await command.run(); expect(result.id).to.equal('Administrator'); - expect(result.user_count).to.equal(5); + expect(result.userCount).to.equal(5); }); it('displays role details in non-JSON mode', async () => { const command: any = await createCommand({}, {role: 'Administrator'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const mockRole = {id: 'Administrator', description: 'Admin role', user_count: 5}; - const ocapiGet = sinon.stub().resolves({data: mockRole, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getRole.resolves({id: 'Administrator', description: 'Admin role', userCount: 5}); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -59,15 +73,10 @@ describe('bm roles get', () => { it('throws on 404', async () => { const command: any = await createCommand({}, {role: 'NonExistent'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Role not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getRole.rejects(new Error('Failed to get role NonExistent: Role not found')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/grant.test.ts b/packages/b2c-cli/test/commands/bm/roles/grant.test.ts index 4f4bb5d96..5af64a475 100644 --- a/packages/b2c-cli/test/commands/bm/roles/grant.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/grant.test.ts @@ -21,32 +21,48 @@ describe('bm roles grant', () => { return createTestCommand(BmRolesGrant, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } - it('grants role and returns user in JSON mode', async () => { + it('grants role in JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUser = {login: 'user@example.com', first_name: 'Test', last_name: 'User'}; - const ocapiPut = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.grantRole.resolves(); const result = await command.run(); + expect(result.success).to.equal(true); expect(result.login).to.equal('user@example.com'); - expect(ocapiPut.calledOnce).to.equal(true); + expect(backend.grantRole.calledOnce).to.equal(true); }); it('logs success in non-JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({data: {login: 'user@example.com'}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.grantRole.resolves(); await command.run(); expect(logStub.calledWith(sinon.match('user@example.com'))).to.equal(true); @@ -54,15 +70,10 @@ describe('bm roles grant', () => { it('throws on 400 for invalid role or user', async () => { const command: any = await createCommand({role: 'BadRole'}, {login: 'user@example.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Invalid role'}}, - response: {status: 400, statusText: 'Bad Request'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.grantRole.rejects(new Error('Failed to grant role BadRole to user@example.com: Invalid role')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/list.test.ts b/packages/b2c-cli/test/commands/bm/roles/list.test.ts index c7501dc3b..5953f1abd 100644 --- a/packages/b2c-cli/test/commands/bm/roles/list.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/list.test.ts @@ -21,49 +21,59 @@ describe('bm roles list', () => { return createTestCommand(BmRolesList, hooks.getConfig(), flags); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('returns data in JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockRoles = {count: 2, total: 2, data: [{id: 'Administrator'}, {id: 'Editor'}]}; - const ocapiGet = sinon.stub().resolves({data: mockRoles, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listRoles.resolves({total: 2, start: 0, count: 2, hits: [{id: 'Administrator'}, {id: 'Editor'}]}); const result = await command.run(); expect(result.count).to.equal(2); - expect(result.data).to.have.length(2); - expect(ocapiGet.calledOnce).to.equal(true); + expect(result.hits).to.have.length(2); + expect(backend.listRoles.calledOnce).to.equal(true); }); it('prints "no roles" message when empty in non-JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({data: {count: 0, total: 0, data: []}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listRoles.resolves({total: 0, start: 0, count: 0, hits: []}); const result = await command.run(); - expect(result.count).to.equal(0); + expect(result.total).to.equal(0); }); - it('throws when OCAPI returns error', async () => { + it('throws when backend returns error', async () => { const command: any = await createCommand(); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'boom'}}, - response: {status: 500, statusText: 'Error'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listRoles.rejects(new Error('Failed to list roles: boom')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts b/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts index 900a1bfb4..cc9258fd8 100644 --- a/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts @@ -21,33 +21,49 @@ describe('bm roles revoke', () => { return createTestCommand(BmRolesRevoke, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('revokes role and returns result in JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.revokeRole.resolves(); const result = await command.run(); expect(result.success).to.equal(true); expect(result.role).to.equal('Administrator'); expect(result.login).to.equal('user@example.com'); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.revokeRole.calledOnce).to.equal(true); }); it('logs success in non-JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.revokeRole.resolves(); await command.run(); expect(logStub.calledWith(sinon.match('user@example.com'))).to.equal(true); @@ -55,15 +71,10 @@ describe('bm roles revoke', () => { it('throws on 404 for non-existent assignment', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'nobody@example.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.revokeRole.rejects(new Error('Failed to revoke role Administrator from nobody@example.com: Not found')); try { await command.run(); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index aa58cc8f7..7e1314566 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -419,7 +419,7 @@ "data" ], "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 && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.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/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.generated.ts && openapi-typescript specs/merchant-roles-v1.yaml -o src/clients/scapi-merchant-roles.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/merchant-roles-v1.yaml b/packages/b2c-tooling-sdk/specs/merchant-roles-v1.yaml new file mode 100644 index 000000000..2a651980c --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/merchant-roles-v1.yaml @@ -0,0 +1,1085 @@ +openapi: 3.0.3 +info: + title: Roles + version: 1.0.0 + x-api-type: Admin + x-api-family: Merchant +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/merchant/roles/v1" + variables: + shortCode: + default: 123456gf +paths: + /organizations/{organizationId}/roles: + get: + operationId: getRoles + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [users, permissions] + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 200 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns the collection of access roles + content: + application/json: + schema: + $ref: "#/components/schemas/RoleSearch" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}: + get: + operationId: getRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [users, permissions] + responses: + 200: + description: Returns the access role details + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + put: + operationId: createRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + required: true + responses: + 200: + description: The access role was successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + 201: + description: The access role was successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + 400: + description: Bad Request - Invalid role request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + delete: + operationId: deleteRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The access role was successfully deleted + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/permissions: + get: + operationId: getRolePermissions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Returns the role permissions + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + put: + operationId: setRolePermissions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + required: true + responses: + 200: + description: The permissions were successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + 201: + description: The permissions were successfully assigned + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + 400: + description: Bad Request - Invalid permissions request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/user-search: + post: + operationId: searchRoleUsers + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUserSearchRequest" + required: true + responses: + 200: + description: Returns role user search results + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUserSearchResult" + 400: + description: Bad Request - Malformed search query or invalid parameters + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/users: + get: + operationId: getRoleUsers + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 200 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns the collection of users assigned to the role + content: + application/json: + schema: + $ref: "#/components/schemas/UserSearch" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/users/{login}: + put: + operationId: assignUserToRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: The user was successfully re-assigned to the role + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 201: + description: The user was successfully assigned to the role + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 404: + description: Role or user not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + delete: + operationId: unassignUserFromRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The user was successfully unassigned from the role + 404: + description: Role or user not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Select: + type: string + minLength: 1 + pattern: ^[(].*[)]$ + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + RoleModulePermission: + type: object + properties: + name: + type: string + maxLength: 256 + minLength: 1 + type: + type: string + maxLength: 256 + minLength: 1 + application: + type: string + maxLength: 256 + minLength: 1 + system: + type: boolean + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [application, name, type] + RoleModulePermissions: + type: object + properties: + organization: + type: array + items: + $ref: "#/components/schemas/RoleModulePermission" + type: string + site: + type: array + items: + $ref: "#/components/schemas/RoleModulePermission" + type: string + RoleFunctionalPermission: + type: object + properties: + name: + type: string + maxLength: 256 + minLength: 1 + type: + type: string + maxLength: 256 + minLength: 1 + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [name, type] + RoleFunctionalPermissions: + type: object + properties: + organization: + type: array + items: + $ref: "#/components/schemas/RoleFunctionalPermission" + type: string + site: + type: array + items: + $ref: "#/components/schemas/RoleFunctionalPermission" + type: string + LanguageCountry: + type: string + pattern: ^[a-z][a-z]-[A-Z][A-Z]$ + LanguageCode: + type: string + pattern: ^[a-z][a-z]$ + DefaultFallback: + type: string + default: default + pattern: ^default$ + LocaleCode: + oneOf: + - $ref: "#/components/schemas/LanguageCountry" + - $ref: "#/components/schemas/LanguageCode" + - $ref: "#/components/schemas/DefaultFallback" + RoleLocalePermission: + type: object + properties: + localeId: + allOf: + - $ref: "#/components/schemas/LocaleCode" + type: + type: string + maxLength: 256 + minLength: 1 + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [localeId, type] + RoleLocalePermissions: + type: object + properties: + unscoped: + type: array + items: + $ref: "#/components/schemas/RoleLocalePermission" + type: string + RoleWebdavPermission: + type: object + properties: + folder: + type: string + maxLength: 256 + minLength: 1 + type: + type: string + maxLength: 256 + minLength: 1 + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [folder, type] + RoleWebdavPermissions: + type: object + properties: + unscoped: + type: array + items: + $ref: "#/components/schemas/RoleWebdavPermission" + type: string + RolePermissions: + type: object + properties: + module: + $ref: "#/components/schemas/RoleModulePermissions" + functional: + $ref: "#/components/schemas/RoleFunctionalPermissions" + locale: + $ref: "#/components/schemas/RoleLocalePermissions" + webdav: + $ref: "#/components/schemas/RoleWebdavPermissions" + User: + type: object + properties: + login: + type: string + maxLength: 256 + minLength: 1 + password: + type: string + maxLength: 256 + email: + type: string + maxLength: 256 + firstName: + type: string + maxLength: 256 + lastName: + type: string + maxLength: 256 + externalId: + type: string + maxLength: 256 + disabled: + type: boolean + locked: + type: boolean + lastLoginDate: + type: string + format: date + passwordExpirationDate: + type: string + format: date-time + passwordModificationDate: + type: string + format: date-time + preferredDataLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + preferredUiLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + roles: + type: array + items: + type: string + maxLength: 256 + required: [email, login] + Role: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + description: + type: string + maxLength: 4000 + userCount: + type: integer + format: int32 + userManager: + type: boolean + permissions: + $ref: "#/components/schemas/RolePermissions" + users: + type: array + items: + $ref: "#/components/schemas/User" + type: string + RoleSearch: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/Role" + type: string + required: [data] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + Query: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolQuery: + $ref: "#/components/schemas/BoolQuery" + filteredQuery: + $ref: "#/components/schemas/FilteredQuery" + matchAllQuery: + $ref: "#/components/schemas/MatchAllQuery" + nestedQuery: + $ref: "#/components/schemas/NestedQuery" + termQuery: + $ref: "#/components/schemas/TermQuery" + textQuery: + $ref: "#/components/schemas/TextQuery" + BoolQuery: + type: object + additionalProperties: false + properties: + must: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + mustNot: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + should: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + Filter: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolFilter: + $ref: "#/components/schemas/BoolFilter" + queryFilter: + $ref: "#/components/schemas/QueryFilter" + range2Filter: + $ref: "#/components/schemas/Range2Filter" + rangeFilter: + $ref: "#/components/schemas/RangeFilter" + termFilter: + $ref: "#/components/schemas/TermFilter" + BoolFilter: + type: object + additionalProperties: false + properties: + filters: + type: array + items: + $ref: "#/components/schemas/Filter" + type: string + operator: + type: string + enum: [and, or, not] + required: [operator] + QueryFilter: + type: object + properties: + query: + $ref: "#/components/schemas/Query" + required: [query] + Field: + type: string + maxLength: 260 + Range2Filter: + type: object + additionalProperties: false + properties: + filterMode: + type: string + default: overlap + enum: [overlap, containing, contained] + fromField: + allOf: + - $ref: "#/components/schemas/Field" + fromInclusive: + type: boolean + default: true + fromValue: {} + toField: + allOf: + - $ref: "#/components/schemas/Field" + toInclusive: + type: boolean + default: true + toValue: {} + required: [fromField, toField] + RangeFilter: + type: object + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + from: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + fromInclusive: + type: boolean + default: true + to: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + toInclusive: + type: boolean + default: true + required: [field] + TermFilter: + type: object + additionalProperties: false + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + type: string + required: [field, operator] + FilteredQuery: + type: object + additionalProperties: false + properties: + filter: + $ref: "#/components/schemas/Filter" + query: + $ref: "#/components/schemas/Query" + required: [filter, query] + MatchAllQuery: + type: object + NestedQuery: + type: object + additionalProperties: false + properties: + path: + type: string + maxLength: 2048 + query: + $ref: "#/components/schemas/Query" + scoreMode: + type: string + enum: [avg, total, max, none] + required: [path, query] + TermQuery: + type: object + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + - type: integer + type: string + required: [fields, operator] + TextQuery: + type: object + additionalProperties: false + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + searchPhrase: + type: string + required: [fields, searchPhrase] + Sort: + type: object + additionalProperties: false + properties: + field: + type: string + maxLength: 256 + sortOrder: + type: string + default: asc + enum: [asc, desc] + required: [field] + SearchRequest: + type: object + properties: + limit: + type: integer + format: int32 + maximum: 200 + minimum: 1 + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + offset: + $ref: "#/components/schemas/Offset" + required: [query] + RoleUserSearchRequest: + allOf: + - $ref: "#/components/schemas/SearchRequest" + PaginatedSearchResult: + additionalProperties: false + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + hits: + type: array + items: + type: object + required: [query] + RoleUserSearchResult: + allOf: + - $ref: "#/components/schemas/PaginatedSearchResult" + properties: + hits: + type: array + items: + $ref: "#/components/schemas/User" + type: string + required: [hits, query] + UserSearch: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + type: string + required: [data] + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + expand: + name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [users, permissions] + select: + name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + roleId: + name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + login: + name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.roles: Read access to role resources + sfcc.roles.rw: Read and write access to role resources diff --git a/packages/b2c-tooling-sdk/src/cli/bm-command.ts b/packages/b2c-tooling-sdk/src/cli/bm-command.ts index d2ed80fa3..bef30b390 100644 --- a/packages/b2c-tooling-sdk/src/cli/bm-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -6,6 +6,7 @@ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; import {createUsersBackend, type UsersBackend} from '../operations/bm-users/index.js'; +import {createRolesBackend, type RolesBackend} from '../operations/bm-roles/index.js'; /** * Base command for Business Manager (instance-level) operations. @@ -28,4 +29,18 @@ export abstract class BmCommand extends InstanceComman auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, }); } + + /** + * Creates a Roles backend for `bm roles *` commands. + */ + protected createRolesBackend(): RolesBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createRolesBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } } diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index d2c850bc3..bbdba1832 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,21 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI Merchant Roles +export { + createScapiMerchantRolesClient, + SCAPI_MERCHANT_ROLES_READ_SCOPES, + SCAPI_MERCHANT_ROLES_RW_SCOPES, +} from './scapi-merchant-roles.js'; +export type { + ScapiMerchantRolesClient, + ScapiMerchantRolesClientConfig, + ScapiMerchantRolesError, + ScapiMerchantRolesResponse, + paths as ScapiMerchantRolesPaths, + components as ScapiMerchantRolesComponents, +} from './scapi-merchant-roles.js'; + // SCAPI Merchant Users export { createScapiMerchantUsersClient, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 7e1011932..fa428b7c2 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -62,7 +62,8 @@ export type HttpClientType = | 'am-orgs-api' | 'scapi-jobs' | 'scapi-scripts' - | 'scapi-merchant-users'; + | 'scapi-merchant-users' + | 'scapi-merchant-roles'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts new file mode 100644 index 000000000..97e405a1c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts @@ -0,0 +1,726 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRoles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRole"]; + put: operations["createRole"]; + post?: never; + delete: operations["deleteRole"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRolePermissions"]; + put: operations["setRolePermissions"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/user-search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["searchRoleUsers"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRoleUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/users/{login}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["assignUserToRole"]; + post?: never; + delete: operations["unassignUserFromRole"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Select: string; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + RoleModulePermission: { + name: string; + type: string; + application: string; + system?: boolean; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleModulePermissions: { + organization?: components["schemas"]["RoleModulePermission"][]; + site?: components["schemas"]["RoleModulePermission"][]; + }; + RoleFunctionalPermission: { + name: string; + type: string; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleFunctionalPermissions: { + organization?: components["schemas"]["RoleFunctionalPermission"][]; + site?: components["schemas"]["RoleFunctionalPermission"][]; + }; + LanguageCountry: string; + LanguageCode: string; + /** @default default */ + DefaultFallback: string; + LocaleCode: components["schemas"]["LanguageCountry"] | components["schemas"]["LanguageCode"] | components["schemas"]["DefaultFallback"]; + RoleLocalePermission: { + localeId: components["schemas"]["LocaleCode"]; + type: string; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleLocalePermissions: { + unscoped?: components["schemas"]["RoleLocalePermission"][]; + }; + RoleWebdavPermission: { + folder: string; + type: string; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleWebdavPermissions: { + unscoped?: components["schemas"]["RoleWebdavPermission"][]; + }; + RolePermissions: { + module?: components["schemas"]["RoleModulePermissions"]; + functional?: components["schemas"]["RoleFunctionalPermissions"]; + locale?: components["schemas"]["RoleLocalePermissions"]; + webdav?: components["schemas"]["RoleWebdavPermissions"]; + }; + User: { + login: string; + password?: string; + email: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + locked?: boolean; + /** Format: date */ + lastLoginDate?: string; + /** Format: date-time */ + passwordExpirationDate?: string; + /** Format: date-time */ + passwordModificationDate?: string; + preferredDataLocale?: components["schemas"]["LocaleCode"]; + preferredUiLocale?: components["schemas"]["LocaleCode"]; + roles?: string[]; + }; + Role: { + id?: string; + description?: string; + /** Format: int32 */ + userCount?: number; + userManager?: boolean; + permissions?: components["schemas"]["RolePermissions"]; + users?: components["schemas"]["User"][]; + }; + RoleSearch: { + data: components["schemas"]["Role"][]; + } & components["schemas"]["PaginatedResultBase"]; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + Query: { + boolQuery?: components["schemas"]["BoolQuery"]; + filteredQuery?: components["schemas"]["FilteredQuery"]; + matchAllQuery?: components["schemas"]["MatchAllQuery"]; + nestedQuery?: components["schemas"]["NestedQuery"]; + termQuery?: components["schemas"]["TermQuery"]; + textQuery?: components["schemas"]["TextQuery"]; + }; + BoolQuery: { + must?: components["schemas"]["Query"][]; + mustNot?: components["schemas"]["Query"][]; + should?: components["schemas"]["Query"][]; + }; + Filter: { + boolFilter?: components["schemas"]["BoolFilter"]; + queryFilter?: components["schemas"]["QueryFilter"]; + range2Filter?: components["schemas"]["Range2Filter"]; + rangeFilter?: components["schemas"]["RangeFilter"]; + termFilter?: components["schemas"]["TermFilter"]; + }; + BoolFilter: { + filters?: components["schemas"]["Filter"][]; + /** @enum {string} */ + operator: "and" | "or" | "not"; + }; + QueryFilter: { + query: components["schemas"]["Query"]; + }; + Field: string; + Range2Filter: { + /** + * @default overlap + * @enum {string} + */ + filterMode: "overlap" | "containing" | "contained"; + fromField: components["schemas"]["Field"]; + /** @default true */ + fromInclusive: boolean; + fromValue?: unknown; + toField: components["schemas"]["Field"]; + /** @default true */ + toInclusive: boolean; + toValue?: unknown; + }; + RangeFilter: { + field: components["schemas"]["Field"]; + from?: string | number; + /** @default true */ + fromInclusive: boolean; + to?: string | number; + /** @default true */ + toInclusive: boolean; + }; + TermFilter: { + field: components["schemas"]["Field"]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: string[]; + }; + FilteredQuery: { + filter: components["schemas"]["Filter"]; + query: components["schemas"]["Query"]; + }; + MatchAllQuery: Record; + NestedQuery: { + path: string; + query: components["schemas"]["Query"]; + /** @enum {string} */ + scoreMode?: "avg" | "total" | "max" | "none"; + }; + TermQuery: { + fields: components["schemas"]["Field"][]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: (string | number | boolean)[]; + }; + TextQuery: { + fields: components["schemas"]["Field"][]; + searchPhrase: string; + }; + Sort: { + field: string; + /** + * @default asc + * @enum {string} + */ + sortOrder: "asc" | "desc"; + }; + SearchRequest: { + /** Format: int32 */ + limit?: number; + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + offset?: components["schemas"]["Offset"]; + }; + RoleUserSearchRequest: components["schemas"]["SearchRequest"]; + PaginatedSearchResult: { + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + hits?: Record[]; + } & components["schemas"]["PaginatedResultBase"]; + RoleUserSearchResult: { + hits: components["schemas"]["User"][]; + } & WithRequired; + UserSearch: { + data: components["schemas"]["User"][]; + } & components["schemas"]["PaginatedResultBase"]; + }; + responses: never; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + expand: ("users" | "permissions")[]; + select: components["schemas"]["Select"]; + roleId: string; + login: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getRoles: { + parameters: { + query?: { + expand?: ("users" | "permissions")[]; + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the collection of access roles */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoleSearch"]; + }; + }; + }; + }; + getRole: { + parameters: { + query?: { + expand?: ("users" | "permissions")[]; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the access role details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + responses: { + /** @description The access role was successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description The access role was successfully created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description Bad Request - Invalid role request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The access role was successfully deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getRolePermissions: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the role permissions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + setRolePermissions: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + responses: { + /** @description The permissions were successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + /** @description The permissions were successfully assigned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + /** @description Bad Request - Invalid permissions request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + searchRoleUsers: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoleUserSearchRequest"]; + }; + }; + responses: { + /** @description Returns role user search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoleUserSearchResult"]; + }; + }; + /** @description Bad Request - Malformed search query or invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getRoleUsers: { + parameters: { + query?: { + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the collection of users assigned to the role */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSearch"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + assignUserToRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user was successfully re-assigned to the role */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description The user was successfully assigned to the role */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Role or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + unassignUserFromRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user was successfully unassigned from the role */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Role or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts new file mode 100644 index 000000000..592f04a1b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-merchant-roles.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope} from './custom-apis.js'; + +export type {paths, components}; +export type ScapiMerchantRolesClient = Client; +export type ScapiMerchantRolesResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiMerchantRolesError = components['schemas']['ErrorResponse']; + +export type Role = components['schemas']['Role']; +export type RolePermissions = components['schemas']['RolePermissions']; +export type RoleSearch = components['schemas']['RoleSearch']; + +export const SCAPI_MERCHANT_ROLES_READ_SCOPES = ['sfcc.roles']; +export const SCAPI_MERCHANT_ROLES_RW_SCOPES = ['sfcc.roles.rw']; + +export interface ScapiMerchantRolesClientConfig { + shortCode: string; + tenantId: string; + /** Override scopes (default: sfcc.roles.rw + tenant scope). */ + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiMerchantRolesClient( + config: ScapiMerchantRolesClientConfig, + auth: AuthStrategy, +): ScapiMerchantRolesClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/roles/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_ROLES_RW_SCOPES, buildTenantScope(config.tenantId)]; + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware('scapi-merchant-roles')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-ROLES'})); + client.use(createLoggingMiddleware('SCAPI-ROLES')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index e7810ae6c..981d562e3 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -236,6 +236,24 @@ export type { ScapiUsersBackendConfig, } from './operations/bm-users/index.js'; +// Roles (BM) backend abstraction +export { + createRolesBackend, + FallbackRolesBackend, + OcapiRolesBackend, + ScapiRolesBackend, +} from './operations/bm-roles/index.js'; +export type { + RolesBackend, + RolesBackendConfig, + RoleInfo, + RolePermissionsInfo, + ListRolesResult, + ListRolesOptions as ListBmRolesScopedOptions, + CreateRoleInput, + ScapiRolesBackendConfig, +} from './operations/bm-roles/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts new file mode 100644 index 000000000..4f85ec470 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts @@ -0,0 +1,91 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type { + RolesBackend, + RoleInfo, + ListRolesResult, + ListRolesOptions, + RolePermissionsInfo, + CreateRoleInput, +} from './types.js'; +import {OcapiRolesBackend} from './ocapi-backend.js'; +import {ScapiRolesBackend} from './scapi-backend.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; + +export interface RolesBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createRolesBackend(config: RolesBackendConfig): RolesBackend { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Roles', + }); + + if (resolved === 'ocapi') { + return new OcapiRolesBackend(config.instance); + } + + const scapiBackend = new ScapiRolesBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + const ocapiBackend = new OcapiRolesBackend(config.instance); + return new FallbackRolesBackend(scapiBackend, ocapiBackend); +} + +export class FallbackRolesBackend extends ScapiFallbackBackend implements RolesBackend { + constructor(scapiBackend: ScapiRolesBackend, ocapiBackend: OcapiRolesBackend) { + super(scapiBackend, ocapiBackend, 'roles'); + } + + async listRoles(options?: ListRolesOptions): Promise { + return this.withFallback((b) => b.listRoles(options)); + } + + async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { + return this.withFallback((b) => b.getRole(roleId, options)); + } + + async createRole(roleId: string, input?: CreateRoleInput): Promise { + return this.withFallback((b) => b.createRole(roleId, input)); + } + + async deleteRole(roleId: string): Promise { + return this.withFallback((b) => b.deleteRole(roleId)); + } + + async getPermissions(roleId: string): Promise { + return this.withFallback((b) => b.getPermissions(roleId)); + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + return this.withFallback((b) => b.setPermissions(roleId, permissions)); + } + + async grantRole(roleId: string, login: string): Promise { + return this.withFallback((b) => b.grantRole(roleId, login)); + } + + async revokeRole(roleId: string, login: string): Promise { + return this.withFallback((b) => b.revokeRole(roleId, login)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts index 96e3e74b2..13669796f 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts @@ -65,3 +65,18 @@ export { } from './roles.js'; export type {BmRole, BmRoles, BmRolePermissions, ListBmRolesOptions, GetBmRoleOptions} from './roles.js'; + +// Roles backend abstraction — supports OCAPI + SCAPI +export {createRolesBackend, FallbackRolesBackend} from './backend.js'; +export type {RolesBackendConfig} from './backend.js'; +export {OcapiRolesBackend} from './ocapi-backend.js'; +export {ScapiRolesBackend} from './scapi-backend.js'; +export type {ScapiRolesBackendConfig} from './scapi-backend.js'; +export type { + RolesBackend, + RoleInfo, + RolePermissionsInfo, + ListRolesResult, + ListRolesOptions, + CreateRoleInput, +} from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts new file mode 100644 index 000000000..999e7a6ab --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts @@ -0,0 +1,168 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type { + RolesBackend, + RoleInfo, + ListRolesResult, + ListRolesOptions, + RolePermissionsInfo, + CreateRoleInput, +} from './types.js'; +import type {BmRole, BmRolePermissions} from './roles.js'; +import { + listBmRoles as ocapiListBmRoles, + getBmRole as ocapiGetBmRole, + createBmRole as ocapiCreateBmRole, + deleteBmRole as ocapiDeleteBmRole, + getBmRolePermissions as ocapiGetBmRolePermissions, + setBmRolePermissions as ocapiSetBmRolePermissions, + grantBmRole as ocapiGrantBmRole, + revokeBmRole as ocapiRevokeBmRole, +} from './roles.js'; + +function mapOcapiRole(ocapi: BmRole): RoleInfo { + return { + id: ocapi.id ?? '', + description: ocapi.description, + userCount: ocapi.user_count, + userManager: ocapi.user_manager, + // OCAPI permissions shape uses snake_case nested groups; the canonical + // type uses SCAPI's camelCase shape. We avoid converting the deep + // structure here (it's only exposed via the permissions endpoints). + _raw: ocapi, + }; +} + +type LocalePermissionOcapi = {locale_id?: string; type?: string; values?: string[]; display_name?: unknown}; +type WebdavPermissionOcapi = {folder?: string; type?: string; values?: string[]}; +type ModulePermissionOcapi = {application?: string; name?: string; values?: string[]}; +type FunctionalPermissionOcapi = {name?: string; values?: string[]}; + +function mapOcapiPermissions(ocapi: BmRolePermissions): RolePermissionsInfo { + // OCAPI uses snake_case for innermost permission fields (locale_id, etc.) + // while SCAPI uses camelCase (localeId). Convert at this boundary. + const result: Record = {}; + if (ocapi.module) { + result.module = { + organization: ((ocapi.module.organization ?? []) as ModulePermissionOcapi[]).map((p) => ({ + application: p.application, + name: p.name, + values: p.values, + })), + site: ((ocapi.module.site ?? []) as ModulePermissionOcapi[]).map((p) => ({ + application: p.application, + name: p.name, + values: p.values, + })), + }; + } + if (ocapi.functional) { + result.functional = { + organization: ((ocapi.functional.organization ?? []) as FunctionalPermissionOcapi[]).map((p) => ({ + name: p.name, + values: p.values, + })), + site: ((ocapi.functional.site ?? []) as FunctionalPermissionOcapi[]).map((p) => ({ + name: p.name, + values: p.values, + })), + }; + } + if (ocapi.locale) { + result.locale = { + unscoped: ((ocapi.locale.unscoped ?? []) as LocalePermissionOcapi[]).map((p) => ({ + localeId: p.locale_id, + type: p.type, + values: p.values, + })), + }; + } + if (ocapi.webdav) { + result.webdav = { + unscoped: ((ocapi.webdav.unscoped ?? []) as WebdavPermissionOcapi[]).map((p) => ({ + folder: p.folder, + type: p.type, + values: p.values, + })), + }; + } + return result as RolePermissionsInfo; +} + +function mapScapiPermissionsToOcapi(perms: RolePermissionsInfo): BmRolePermissions { + // Reverse: camelCase → snake_case for the inner locale field. + const result: Record = {}; + if (perms.module) { + result.module = perms.module; + } + if (perms.functional) { + result.functional = perms.functional; + } + if (perms.locale) { + type LocaleScapi = {localeId?: string; type?: string; values?: unknown}; + result.locale = { + unscoped: ((perms.locale.unscoped ?? []) as LocaleScapi[]).map((p) => ({ + locale_id: p.localeId, + type: p.type, + values: p.values, + })), + }; + } + if (perms.webdav) { + result.webdav = perms.webdav; + } + return result as BmRolePermissions; +} + +export class OcapiRolesBackend implements RolesBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listRoles(options: ListRolesOptions = {}): Promise { + const result = await ocapiListBmRoles(this.instance, {start: options.start, count: options.count}); + const items = (result.data ?? []) as BmRole[]; + return { + total: result.total ?? 0, + start: result.start ?? 0, + count: result.count ?? items.length, + hits: items.map(mapOcapiRole), + }; + } + + async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { + const role = await ocapiGetBmRole(this.instance, roleId, {expand: options?.expand}); + return mapOcapiRole(role); + } + + async createRole(roleId: string, input?: CreateRoleInput): Promise { + const role = await ocapiCreateBmRole(this.instance, roleId, {description: input?.description}); + return mapOcapiRole(role); + } + + async deleteRole(roleId: string): Promise { + await ocapiDeleteBmRole(this.instance, roleId); + } + + async getPermissions(roleId: string): Promise { + const perms = await ocapiGetBmRolePermissions(this.instance, roleId); + return mapOcapiPermissions(perms); + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + const updated = await ocapiSetBmRolePermissions(this.instance, roleId, mapScapiPermissionsToOcapi(permissions)); + return mapOcapiPermissions(updated); + } + + async grantRole(roleId: string, login: string): Promise { + await ocapiGrantBmRole(this.instance, roleId, login); + } + + async revokeRole(roleId: string, login: string): Promise { + await ocapiRevokeBmRole(this.instance, roleId, login); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts new file mode 100644 index 000000000..4623080f1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -0,0 +1,178 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type { + RolesBackend, + RoleInfo, + ListRolesResult, + ListRolesOptions, + RolePermissionsInfo, + CreateRoleInput, +} from './types.js'; +import { + createScapiMerchantRolesClient, + SCAPI_MERCHANT_ROLES_RW_SCOPES, + SCAPI_MERCHANT_ROLES_READ_SCOPES, + type ScapiMerchantRolesClient, + type ScapiMerchantRolesClientConfig, + type Role as ScapiRole, + type RoleSearch, +} from '../../clients/scapi-merchant-roles.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; + +function mapScapiRole(scapi: ScapiRole): RoleInfo { + return { + id: scapi.id ?? '', + description: scapi.description, + userCount: scapi.userCount, + userManager: scapi.userManager, + permissions: scapi.permissions, + _raw: scapi, + }; +} + +export interface ScapiRolesBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} + +export class ScapiRolesBackend implements RolesBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private scopeTier: ScopeTierManager; + + constructor(private config: ScapiRolesBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_MERCHANT_ROLES_RW_SCOPES, + readScopes: SCAPI_MERCHANT_ROLES_READ_SCOPES, + domainName: 'Roles', + }); + } + + async listRoles(options: ListRolesOptions = {}): Promise { + const client = this.scopeTier.getClientForRead(); + const {start = 0, count = 25, expand} = options; + + const {data, error} = await client.GET('/organizations/{organizationId}/roles', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start, expand}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list roles')); + } + const result = data as RoleSearch; + return { + total: result.total ?? 0, + start: result.offset ?? start, + count: result.limit ?? count, + hits: (result.data ?? []).map(mapScapiRole), + }; + } + + async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}', { + params: { + path: {organizationId: this.organizationId, roleId}, + query: {expand: options?.expand}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get role ${roleId}`)); + } + return mapScapiRole(data); + } + + async createRole(roleId: string, input?: CreateRoleInput): Promise { + const client = this.scopeTier.getClientForWrite(); + const body: ScapiRole = { + id: roleId, + description: input?.description, + }; + const {data, error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}', { + params: {path: {organizationId: this.organizationId, roleId}}, + body, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to create role ${roleId}`)); + } + return mapScapiRole(data); + } + + async deleteRole(roleId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to delete role ${roleId}`)); + } + } + + async getPermissions(roleId: string): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get permissions for role ${roleId}`)); + } + return data; + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + const client = this.scopeTier.getClientForWrite(); + const {data, error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + body: permissions, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to set permissions for role ${roleId}`)); + } + return data; + } + + async grantRole(roleId: string, login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + params: {path: {organizationId: this.organizationId, roleId, login}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to grant role ${roleId} to ${login}`)); + } + } + + async revokeRole(roleId: string, login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + params: {path: {organizationId: this.organizationId, roleId, login}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to revoke role ${roleId} from ${login}`)); + } + } + + private buildClient(scopes: string[]): ScapiMerchantRolesClient { + const clientConfig: ScapiMerchantRolesClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiMerchantRolesClient(clientConfig, this.config.auth); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts new file mode 100644 index 000000000..5e24eb37d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts @@ -0,0 +1,58 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for Business Manager role operations. + * + * The OCAPI Data API and the SCAPI Merchant Roles API both manage instance- + * level access roles. Permission shapes are virtually identical across the + * two APIs — module/functional/locale/webdav permission groups — so the + * canonical type re-exports the SCAPI shape and the OCAPI backend converts. + * + * @module operations/bm-roles/types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; +import type {RolePermissions as ScapiRolePermissions} from '../../clients/scapi-merchant-roles.js'; + +export type RolePermissionsInfo = ScapiRolePermissions; + +export interface RoleInfo { + id: string; + description?: string; + userCount?: number; + userManager?: boolean; + permissions?: RolePermissionsInfo; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +export interface ListRolesResult { + total: number; + start: number; + count: number; + hits: RoleInfo[]; +} + +export interface ListRolesOptions { + start?: number; + count?: number; + expand?: ('users' | 'permissions')[]; +} + +export interface CreateRoleInput { + description?: string; +} + +export interface RolesBackend extends BackendBase { + listRoles(options?: ListRolesOptions): Promise; + getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise; + createRole(roleId: string, input?: CreateRoleInput): Promise; + deleteRole(roleId: string): Promise; + getPermissions(roleId: string): Promise; + setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise; + /** Assigns a user to a role. Returns void; OCAPI returns the user but we don't surface that. */ + grantRole(roleId: string, login: string): Promise; + revokeRole(roleId: string, login: string): Promise; +} From d08d69bd38aa5fa5e7990e75eddfa47824a940b6 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 14:02:22 -0400 Subject: [PATCH 06/11] Document SCAPI migration across code, bm, and configuration - Configuration guide: clarify api-backend applies to job, code, bm users, and bm roles commands - code.md: new "API Backend" section with SCAPI scopes, fallback behavior, and notes on reload/deploy/download/watch staying OCAPI/WebDAV - bm.md: new "API Backend" section with per-command compatibility table (users search, whoami, access-key remain OCAPI-only) - b2c-code skill: backend selection examples - b2c-bm-users-roles skill: backend selection notes including the --disabled fallback caveat - Single changeset replaces the jobs-only one --- .changeset/scapi-jobs-migration.md | 6 ---- .changeset/scapi-migration.md | 6 ++++ docs/cli/bm.md | 30 +++++++++++++++++- docs/cli/code.md | 31 ++++++++++++++++--- docs/guide/configuration.md | 2 +- .../skills/b2c-bm-users-roles/SKILL.md | 16 ++++++++++ skills/b2c-cli/skills/b2c-code/SKILL.md | 14 +++++++++ 7 files changed, 93 insertions(+), 12 deletions(-) delete mode 100644 .changeset/scapi-jobs-migration.md create mode 100644 .changeset/scapi-migration.md diff --git a/.changeset/scapi-jobs-migration.md b/.changeset/scapi-jobs-migration.md deleted file mode 100644 index f483fdbf5..000000000 --- a/.changeset/scapi-jobs-migration.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': minor -'@salesforce/b2c-tooling-sdk': minor ---- - -Add SCAPI Jobs API support with automatic backend selection. Job commands (`job run`, `job search`, `job wait`, `job log`) now use SCAPI when `shortCode` and `tenantId` are configured, falling back to OCAPI if SCAPI scopes are unavailable. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. New `job execution delete` command (SCAPI only) deletes job execution records. diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md new file mode 100644 index 000000000..201cbaf52 --- /dev/null +++ b/.changeset/scapi-migration.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Migrate `job`, `code`, `bm users`, and `bm roles` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs.rw`, `sfcc.scripts.rw`, `sfcc.users.rw`, `sfcc.roles.rw`. New `job execution delete` command (SCAPI only). diff --git a/docs/cli/bm.md b/docs/cli/bm.md index 1e496608b..32df81b88 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -4,7 +4,35 @@ description: Commands for administering Business Manager resources on a B2C Comm # Business Manager Commands -Commands for administering instance-level Business Manager resources via the OCAPI Data API. These are distinct from [Account Manager commands](/cli/account-manager) which manage cross-instance identity. +Commands for administering instance-level Business Manager resources. These are distinct from [Account Manager commands](/cli/account-manager) which manage cross-instance identity. + +## API Backend + +Most `bm users` and `bm roles` commands support both the OCAPI Data API and the SCAPI Merchant Users / Merchant Roles APIs. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If the SCAPI scopes aren't granted on your API client, the CLI silently falls back to OCAPI. + +```bash +# Force SCAPI backend +b2c bm users list --api-backend scapi + +# Force OCAPI backend +b2c bm roles get Administrator --api-backend ocapi +``` + +Or set in `dw.json`: `"api-backend": "scapi"`. Or `SFCC_API_BACKEND=scapi` env var. + +| Command | SCAPI | OCAPI | +|---|---|---| +| `bm users list/get/update/delete` | ✓ (`sfcc.users.rw`) | ✓ | +| `bm users search` | ✗ — OCAPI only | ✓ | +| `bm whoami` | ✗ — OCAPI only | ✓ | +| `bm access-key *` | ✗ — OCAPI only | ✓ | +| `bm roles list/get/create/delete` | ✓ (`sfcc.roles.rw`) | ✓ | +| `bm roles grant/revoke` | ✓ (`sfcc.roles.rw`) | ✓ | +| `bm roles permissions get/set` | ✓ (`sfcc.roles.rw`) | ✓ | + +::: warning +The SCAPI Users PATCH endpoint does not support changing the `disabled` flag. `bm users update --disabled` falls back to OCAPI in auto mode; with `--api-backend scapi` it errors with a clear message. +::: ## Authentication diff --git a/docs/cli/code.md b/docs/cli/code.md index a0beb2d25..f308d92a2 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -6,6 +6,28 @@ description: Commands for deploying, downloading, activating code versions, and Commands for managing cartridge code on B2C Commerce instances. +## API Backend + +The `code list`, `code activate`, and `code delete` commands support both OCAPI and SCAPI backends. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If SCAPI scopes are unavailable, the CLI falls back to OCAPI transparently. + +```bash +# Force SCAPI +b2c code list --api-backend scapi + +# Force OCAPI +b2c code list --api-backend ocapi +``` + +Or set in `dw.json`: `"api-backend": "scapi"`. Or `SFCC_API_BACKEND=scapi` env var. + +::: tip +The `code activate --reload` flag forces an OCAPI call regardless of `--api-backend`, since SCAPI does not expose the cache-rebuild operation. +::: + +::: tip +The `code deploy`, `code download`, and `code watch` commands always use WebDAV (no SCAPI equivalent for cartridge file transfer). +::: + ## Authentication Code commands use different authentication depending on the operation: @@ -13,7 +35,8 @@ Code commands use different authentication depending on the operation: | Operation | Auth Required | |-----------|--------------| | `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) | -| `code list`, `code activate`, `code delete` | OAuth + OCAPI | +| `code list`, `code activate`, `code delete` (SCAPI) | OAuth + `sfcc.scripts` (read) or `sfcc.scripts.rw` (write) + tenant scope | +| `code list`, `code activate`, `code delete` (OCAPI) | OAuth + OCAPI permissions for `/code_versions` | ### WebDAV Operations (deploy, download, watch) @@ -24,16 +47,16 @@ export SFCC_USERNAME=your-bm-username export SFCC_PASSWORD=your-webdav-access-key ``` -### OCAPI Operations (list, activate, delete) +### SCAPI / OCAPI Operations (list, activate, delete) -These commands require OAuth authentication with OCAPI permissions for the `/code_versions` resource configured in Business Manager. +These commands require OAuth authentication. For SCAPI, configure the `sfcc.scripts.rw` scope on your API client in Account Manager. For OCAPI, configure permissions for the `/code_versions` resource in Business Manager. ```bash export SFCC_CLIENT_ID=your-client-id export SFCC_CLIENT_SECRET=your-client-secret ``` -For complete setup instructions including OCAPI configuration, see the [Authentication Guide](/guide/authentication). +For complete setup instructions, see the [Authentication Guide](/guide/authentication). --- diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ee512f218..6d21ba1ad 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -268,7 +268,7 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). | `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | | `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | | `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | -| `api-backend` | API backend for operations: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set. | +| `api-backend` | API backend for `job`, `code`, `bm users`, and `bm roles` commands: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set, falling back to OCAPI on missing scopes. | ### Two-Factor Authentication (mTLS) diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md index 9a4848aae..12e28d2e3 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -11,6 +11,22 @@ Use the `b2c bm` commands to administer instance-level Business Manager resource For **Account Manager** user/role/client management (cross-instance, scoped to tenants), see the `b2c-cli:b2c-am` skill instead. +## API Backend + +`bm users` (list, get, update, delete) and `bm roles` (all subcommands including permissions) support both the OCAPI Data API and the SCAPI Merchant Users / Merchant Roles APIs. Auto mode (default) prefers SCAPI when `shortCode` and `tenantId` are configured. + +```bash +# force SCAPI (requires sfcc.users.rw / sfcc.roles.rw scope) +b2c bm users list --api-backend scapi + +# force OCAPI +b2c bm roles get Administrator --api-backend ocapi +``` + +OCAPI-only commands (no SCAPI equivalent): `bm users search`, `bm whoami`, `bm access-key *`. + +`bm users update --disabled` requires OCAPI (SCAPI's PATCH endpoint doesn't support changing `disabled`). Auto mode falls back to OCAPI for that case. + ## Authentication Most BM commands accept either client credentials or browser-based user auth. A handful require a *real BM user identity* and the CLI defaults those to user-auth automatically. diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index 1712b54bd..f4fe4c1f2 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -106,6 +106,20 @@ b2c code activate --reload b2c code delete ``` +### API Backend Selection + +`code list`, `code activate`, and `code delete` support both OCAPI and SCAPI. Auto mode (default) prefers SCAPI when `shortCode` and `tenantId` are configured. + +```bash +# force SCAPI (requires sfcc.scripts.rw scope) +b2c code list --api-backend scapi + +# force OCAPI +b2c code list --api-backend ocapi +``` + +`code activate --reload` always uses OCAPI (no SCAPI cache-rebuild equivalent). `code deploy`, `code download`, `code watch` always use WebDAV. + ### More Commands See `b2c code --help` for a full list of available commands and options in the `code` topic. From d79244ba343e2c3c861415ebe29c14c90cc58b7a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 14:45:07 -0400 Subject: [PATCH 07/11] Refactor SCAPI dual-backend pattern: extract generics, fix bugs Three correctness fixes plus four DRY extractions across the SCAPI migration. Tests stay green (1722 SDK + 1219 CLI) and the API surface is unchanged for consumers. Bugs fixed: - code activate --reload now works in auto mode. The reload toggle (list + activate(alt) + activate(target)) is implementable on any backend, so reloadCodeVersion is a backend-agnostic free function that takes a ScriptsBackend. The OCAPI-only stub in ScapiScriptsBackend that previously broke fallback is gone. - job run --body is no longer subject to a special-case backend switch. SCAPI accepts raw bodies for system jobs (just with a slightly different payload shape) so we pass --body through to whichever backend the user picked. Removes a no-op resolveBackend helper that called createJobsBackend twice. - Scope merging now works for any AuthStrategy. The instanceof OAuthStrategy check silently dropped scopes for ImplicitOAuthStrategy and StatefulOAuthStrategy. AuthStrategy gains an optional withAdditionalScopes; a new withScopes() helper centralizes the method-presence check across all SCAPI client factories. DRY extractions: - Fallback*Backend subclasses (jobs, scripts, users, roles) replaced with a single Proxy-based createFallbackBackend(). Each domain shed ~25 lines of mechanical method delegation. The proxy traps reads of `name` and routes method calls through the same withFallback logic. - create*Backend factory functions (jobs, scripts, users, roles) collapsed into createDualBackend() that takes constructors. Each domain backend.ts shrunk from ~50 lines to ~10. - createScapi*Client factories collapsed into buildScapiClient

() that takes a path segment, scope set, and middleware key. Each domain client.ts shrunk from ~60 lines to ~30. - InstanceCommand gained createBackend() so per-domain command base classes (JobCommand/CodeCommand/BmCommand) shrink to one-line wrappers. Other: - Renamed JobExecutionResult to JobExecutionInfo for consistency with CodeVersionInfo, UserInfo, RoleInfo across the canonical types. --- .../b2c-cli/src/commands/code/activate.ts | 3 +- packages/b2c-cli/src/commands/code/deploy.ts | 3 +- packages/b2c-cli/src/commands/job/log.ts | 6 +- packages/b2c-cli/src/commands/job/run.ts | 23 +-- packages/b2c-cli/src/commands/job/search.ts | 4 +- packages/b2c-cli/src/commands/job/wait.ts | 4 +- .../test/commands/code/activate.test.ts | 16 ++- .../b2c-cli/test/commands/code/deploy.test.ts | 5 +- packages/b2c-tooling-sdk/src/auth/types.ts | 12 ++ .../b2c-tooling-sdk/src/cli/bm-command.ts | 24 +--- .../b2c-tooling-sdk/src/cli/code-command.ts | 12 +- .../src/cli/instance-command.ts | 24 ++++ .../b2c-tooling-sdk/src/cli/job-command.ts | 26 +--- .../src/clients/dual-backend-factory.ts | 99 +++++++++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 8 +- .../src/clients/scapi-backend-utils.ts | 16 +++ .../src/clients/scapi-client-factory.ts | 109 ++++++++++++++ .../src/clients/scapi-fallback-backend.ts | 136 +++++++++++------- .../b2c-tooling-sdk/src/clients/scapi-jobs.ts | 42 ++---- .../src/clients/scapi-merchant-roles.ts | 44 ++---- .../src/clients/scapi-merchant-users.ts | 44 ++---- .../src/clients/scapi-scripts.ts | 44 ++---- packages/b2c-tooling-sdk/src/index.ts | 24 +--- .../src/operations/bm-roles/backend.ts | 84 +---------- .../src/operations/bm-roles/index.ts | 2 +- .../src/operations/bm-roles/scapi-backend.ts | 2 + .../src/operations/bm-users/backend.ts | 72 +--------- .../src/operations/bm-users/index.ts | 2 +- .../src/operations/bm-users/scapi-backend.ts | 2 + .../src/operations/code/deploy.ts | 6 +- .../src/operations/code/index.ts | 3 +- .../operations/code/ocapi-scripts-backend.ts | 5 - .../operations/code/scapi-scripts-backend.ts | 6 +- .../src/operations/code/scripts-backend.ts | 89 +++++------- .../src/operations/code/scripts-types.ts | 12 +- .../src/operations/code/versions.ts | 48 ------- .../src/operations/jobs/backend.ts | 73 ++-------- .../src/operations/jobs/index.ts | 4 +- .../src/operations/jobs/ocapi-backend.ts | 12 +- .../src/operations/jobs/scapi-backend.ts | 14 +- .../src/operations/jobs/types.ts | 10 +- .../test/operations/code/versions.test.ts | 11 +- 42 files changed, 545 insertions(+), 640 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index f51b8b8e6..11b820f74 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -5,6 +5,7 @@ */ import {Args, Flags} from '@oclif/core'; import {CodeCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {reloadCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import {t, withDocs} from '../../i18n/index.js'; export default class CodeActivate extends CodeCommand { @@ -67,7 +68,7 @@ export default class CodeActivate extends CodeCommand { ); try { - await backend.reloadCodeVersion(codeVersion); + await reloadCodeVersion(backend, codeVersion); this.log( t('commands.code.activate.reloaded', 'Code version{{version}} reloaded successfully', { version: codeVersion ? ` ${codeVersion}` : '', diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index a64ea4dd8..a0b3ff717 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -10,6 +10,7 @@ import { getActiveCodeVersion, activateCodeVersion, reloadCodeVersion, + OcapiScriptsBackend, type DeployResult, } from '@salesforce/b2c-tooling-sdk/operations/code'; import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; @@ -203,7 +204,7 @@ export default class CodeDeploy extends CartridgeCommand { await this.operations.activateCodeVersion(this.instance, version); activated = true; } else if (this.flags.reload) { - await this.operations.reloadCodeVersion(this.instance, version); + await this.operations.reloadCodeVersion(new OcapiScriptsBackend(this.instance), version); activated = true; reloaded = true; } diff --git a/packages/b2c-cli/src/commands/job/log.ts b/packages/b2c-cli/src/commands/job/log.ts index 5103077b0..828e31061 100644 --- a/packages/b2c-cli/src/commands/job/log.ts +++ b/packages/b2c-cli/src/commands/job/log.ts @@ -5,12 +5,12 @@ */ import {Args, Flags} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {type JobExecutionResult} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {type JobExecutionInfo} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; import {highlightLogText} from '../../utils/logs/index.js'; interface JobLogResult { - execution: JobExecutionResult; + execution: JobExecutionInfo; log: string; } @@ -61,7 +61,7 @@ export default class JobLog extends JobCommand { const backend = this.createJobsBackend(); this.logger.debug(`Using ${backend.name} backend for job log`); - let execution: JobExecutionResult; + let execution: JobExecutionInfo; if (executionId) { this.log( diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 91699db4c..fb3ae01bc 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -9,7 +9,7 @@ import { waitForJobExecution, JobExecutionError, type JobsBackend, - type JobExecutionResult, + type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -76,7 +76,7 @@ export default class JobRun extends JobCommand { static hiddenAliases = ['job:run']; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId} = this.args; @@ -103,9 +103,7 @@ export default class JobRun extends JobCommand { const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; - // When --body is used with auto mode, force OCAPI since raw bodies use OCAPI format - const backend = this.resolveBackend(rawBody); - + const backend = this.createJobsBackend(); this.logger.debug(`Using ${backend.name} backend for job operations`); // Create lifecycle context @@ -129,7 +127,7 @@ export default class JobRun extends JobCommand { id: '', jobId, executionStatus: 'finished', - } as unknown as JobExecutionResult; + } as unknown as JobExecutionInfo; } this.log( @@ -139,7 +137,7 @@ export default class JobRun extends JobCommand { }), ); - let execution: JobExecutionResult; + let execution: JobExecutionInfo; try { execution = await backend.executeJob(jobId, { parameters: rawBody ? undefined : parameters, @@ -241,15 +239,6 @@ export default class JobRun extends JobCommand { }); } - private resolveBackend(rawBody: Record | undefined): JobsBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - if (rawBody && preference === 'auto') { - this.logger.debug('Raw body provided with auto mode; using OCAPI backend'); - return this.createJobsBackend(); - } - return this.createJobsBackend(); - } - private async waitForJobCompletion(options: { backend: JobsBackend; jobId: string; @@ -258,7 +247,7 @@ export default class JobRun extends JobCommand { pollInterval: number | undefined; showLog: boolean; context: B2COperationContext; - }): Promise { + }): Promise { const {backend, jobId, executionId, timeout, pollInterval, showLog, context} = options; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index dfe9247f6..9a387f205 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -11,10 +11,10 @@ import { selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {type JobExecutionResult, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {type JobExecutionInfo, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'Execution ID', get: (e) => e.id ?? '-', diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index 693977ad9..cdc9454ed 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -8,7 +8,7 @@ import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { waitForJobExecution, JobExecutionError, - type JobExecutionResult, + type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -53,7 +53,7 @@ export default class JobWait extends JobCommand { }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId, executionId} = this.args; diff --git a/packages/b2c-cli/test/commands/code/activate.test.ts b/packages/b2c-cli/test/commands/code/activate.test.ts index 82f65d543..d38d94335 100644 --- a/packages/b2c-cli/test/commands/code/activate.test.ts +++ b/packages/b2c-cli/test/commands/code/activate.test.ts @@ -29,7 +29,6 @@ describe('code activate', () => { activateCodeVersion: sinon.stub(), deleteCodeVersion: sinon.stub(), createCodeVersion: sinon.stub(), - reloadCodeVersion: sinon.stub(), }; } @@ -70,18 +69,25 @@ describe('code activate', () => { it('reloads the active code version when --reload is set and no arg is provided', async () => { const command: any = await createCommand({reload: true}, {}); const backend = stubCommon(command); - backend.reloadCodeVersion.resolves(); + // reloadCodeVersion is now backend-agnostic: list+activate(alt)+activate(target) + backend.listCodeVersions.resolves([ + {id: 'v1', active: true}, + {id: 'v2', active: false}, + ]); + backend.activateCodeVersion.resolves(); await command.run(); - expect(backend.reloadCodeVersion.calledOnce).to.be.true; - expect(backend.reloadCodeVersion.firstCall.args[0]).to.equal(undefined); + // Called twice: alternate then target + expect(backend.activateCodeVersion.callCount).to.equal(2); + expect(backend.activateCodeVersion.getCall(0).args[0]).to.equal('v2'); + expect(backend.activateCodeVersion.getCall(1).args[0]).to.equal('v1'); }); it('calls command.error when reload fails with an error message', async () => { const command: any = await createCommand({reload: true}, {codeVersion: 'v1'}); const backend = stubCommon(command); - backend.reloadCodeVersion.rejects(new Error('boom')); + backend.listCodeVersions.rejects(new Error('boom')); const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); diff --git a/packages/b2c-cli/test/commands/code/deploy.test.ts b/packages/b2c-cli/test/commands/code/deploy.test.ts index b7e9fa1dd..c95f8ca34 100644 --- a/packages/b2c-cli/test/commands/code/deploy.test.ts +++ b/packages/b2c-cli/test/commands/code/deploy.test.ts @@ -86,7 +86,10 @@ describe('code deploy', () => { expect(uploadStub.calledOnce).to.be.true; expect(uploadStub.firstCall.args[0]).to.equal(instance); expect(uploadStub.firstCall.args[1]).to.equal(cartridges); - expect(reloadStub.calledOnceWithExactly(instance, 'v1')).to.be.true; + expect(reloadStub.calledOnce).to.be.true; + // First arg is now a ScriptsBackend (OcapiScriptsBackend wrapping the instance), not the instance directly + expect(reloadStub.firstCall.args[0]).to.have.property('listCodeVersions'); + expect(reloadStub.firstCall.args[1]).to.equal('v1'); expect(result).to.deep.include({codeVersion: 'v1', activated: true, reloaded: true}); expect(afterHooksStub.calledOnce).to.be.true; diff --git a/packages/b2c-tooling-sdk/src/auth/types.ts b/packages/b2c-tooling-sdk/src/auth/types.ts index 92d74effe..835cc0e12 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -31,6 +31,18 @@ export interface AuthStrategy { * Used by middleware to retry requests after receiving a 401 response. */ invalidateToken?(): void; + + /** + * Optional: Returns a copy of this strategy with the given scopes merged into + * its requested scope set. SCAPI client factories use this to ensure the + * domain scope (e.g., `sfcc.jobs.rw`) and the tenant scope are present. + * + * Implemented by `OAuthStrategy` and `JwtOAuthStrategy`. Strategies that + * obtain tokens by other means (basic, api-key, implicit-via-stored-session) + * may not implement this; callers should treat them as "scopes already + * established at construction time." + */ + withAdditionalScopes?(additionalScopes: string[]): AuthStrategy; } /** diff --git a/packages/b2c-tooling-sdk/src/cli/bm-command.ts b/packages/b2c-tooling-sdk/src/cli/bm-command.ts index bef30b390..4155e6cc2 100644 --- a/packages/b2c-tooling-sdk/src/cli/bm-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -16,31 +16,11 @@ import {createRolesBackend, type RolesBackend} from '../operations/bm-roles/inde * configured, falling back to OCAPI on `invalid_scope`. */ export abstract class BmCommand extends InstanceCommand { - /** - * Creates a Users backend for `bm users *` commands. - */ protected createUsersBackend(): UsersBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createUsersBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createUsersBackend); } - /** - * Creates a Roles backend for `bm roles *` commands. - */ protected createRolesBackend(): RolesBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createRolesBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createRolesBackend); } } diff --git a/packages/b2c-tooling-sdk/src/cli/code-command.ts b/packages/b2c-tooling-sdk/src/cli/code-command.ts index 082bbf390..a0c23246b 100644 --- a/packages/b2c-tooling-sdk/src/cli/code-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/code-command.ts @@ -16,17 +16,7 @@ import {createScriptsBackend, type ScriptsBackend} from '../operations/code/inde * back to OCAPI on `invalid_scope`. */ export abstract class CodeCommand extends InstanceCommand { - /** - * Creates a Scripts backend based on the resolved configuration. - */ protected createScriptsBackend(): ScriptsBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createScriptsBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createScriptsBackend); } } diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index 209fbb54a..e3c8a4679 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -190,6 +190,30 @@ export abstract class InstanceCommand extends OAuthCom return loadConfig(extractInstanceFlags(this.flags as Record), this.getBaseConfigOptions()); } + /** + * Creates a SCAPI/OCAPI dual backend by passing the resolved configuration + * (apiBackend preference, instance, shortCode, tenantId, OAuth) to the + * supplied factory. Each backend domain (jobs, scripts, users, roles) + * exports its own factory; this helper supplies the same plumbing for all. + * + * @example + * ```ts + * const backend = this.createBackend(createJobsBackend); + * await backend.executeJob('my-job'); + * ``` + */ + protected createBackend( + factory: (config: import('../clients/dual-backend-factory.js').DualBackendConfig) => T, + ): T { + return factory({ + preference: this.resolvedConfig.values.apiBackend ?? 'auto', + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } + /** * Gets the B2CInstance for this command. * diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index bb901e6f3..ea9753d1f 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -6,7 +6,7 @@ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; import {getJobLog, getJobErrorMessage, type JobExecution} from '../operations/jobs/index.js'; -import {createJobsBackend, type JobsBackend, type JobExecutionResult} from '../operations/jobs/index.js'; +import {createJobsBackend, type JobsBackend, type JobExecutionInfo} from '../operations/jobs/index.js'; import {t} from '../i18n/index.js'; /** @@ -24,35 +24,23 @@ import {t} from '../i18n/index.js'; * } */ export abstract class JobCommand extends InstanceCommand { - /** - * Creates a jobs backend based on the resolved configuration. - * In auto mode (default), prefers SCAPI when shortCode+tenantId are configured, - * falling back to OCAPI if SCAPI scopes are unavailable. - */ protected createJobsBackend(): JobsBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createJobsBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createJobsBackend); } /** * Display a job's log file content and error message if available. - * Accepts both canonical JobExecutionResult and legacy OCAPI JobExecution. + * Accepts both canonical JobExecutionInfo and legacy OCAPI JobExecution. * Outputs to stderr since this is typically shown for failed jobs. */ - protected async showJobLog(execution: JobExecutionResult | JobExecution): Promise { + protected async showJobLog(execution: JobExecutionInfo | JobExecution): Promise { if (isCanonicalExecution(execution)) { return this.showCanonicalJobLog(execution); } return this.showOcapiJobLog(execution); } - private async showCanonicalJobLog(execution: JobExecutionResult): Promise { + private async showCanonicalJobLog(execution: JobExecutionInfo): Promise { const errorMessage = getCanonicalJobErrorMessage(execution); if (!execution.isLogFileExisting) { @@ -110,11 +98,11 @@ export abstract class JobCommand extends InstanceComma } } -function isCanonicalExecution(execution: JobExecutionResult | JobExecution): execution is JobExecutionResult { +function isCanonicalExecution(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { return 'executionStatus' in execution; } -function getCanonicalJobErrorMessage(execution: JobExecutionResult): string | undefined { +function getCanonicalJobErrorMessage(execution: JobExecutionInfo): string | undefined { if (!execution.stepExecutions || execution.stepExecutions.length === 0) { return undefined; } diff --git a/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts new file mode 100644 index 000000000..6d243673b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Generic factory for SCAPI/OCAPI dual backends. + * + * Replaces the per-domain `create*Backend()` functions (jobs, scripts, + * users, roles) which were 100% structurally identical. Each domain now + * supplies its constructors and config and delegates to {@link createDualBackend}. + * + * @module clients/dual-backend-factory + */ +import type {AuthStrategy} from '../auth/types.js'; +import type {B2CInstance} from '../instance/index.js'; +import {createFallbackBackend} from './scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference, type BackendBase} from './scapi-backend-utils.js'; + +/** + * Common shape of every dual-backend factory's input. + */ +export interface DualBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +/** + * Configuration passed to a SCAPI backend constructor. Domains add their + * own optional fields (e.g., `instance` for log/WebDAV access on jobs) but + * always include shortCode + tenantId + auth. + */ +export interface ScapiBackendCtorConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; + instance: B2CInstance; +} + +/** + * Constructors needed to build a dual-backend instance. Each domain plugs in + * its own SCAPI/OCAPI backend classes; the factory wires them together. + */ +export interface DualBackendCtors { + domainName: string; + Scapi: new (config: ScapiBackendCtorConfig) => T; + Ocapi: new (instance: B2CInstance) => T; +} + +/** + * Resolves the user's preference + config availability into a concrete + * backend instance. + * + * - Explicit `'ocapi'` returns an OCAPI backend. + * - Explicit `'scapi'` returns a SCAPI backend (throws if config missing). + * - `'auto'` returns a fallback Proxy that tries SCAPI first, falls back to + * OCAPI on `invalid_scope`. + * + * @example + * ```ts + * export function createJobsBackend(config: JobsBackendConfig): JobsBackend { + * return createDualBackend(config, { + * domainName: 'Jobs', + * Scapi: ScapiJobsBackend, + * Ocapi: OcapiJobsBackend, + * }); + * } + * ``` + */ +export function createDualBackend(config: DualBackendConfig, ctors: DualBackendCtors): T { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: ctors.domainName, + }); + + if (resolved === 'ocapi') { + return new ctors.Ocapi(config.instance); + } + + const scapiBackend = new ctors.Scapi({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + instance: config.instance, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new ctors.Ocapi(config.instance); + return createFallbackBackend(scapiBackend, ocapiBackend, ctors.domainName.toLowerCase()); +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index bbdba1832..9fcbf9181 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -378,9 +378,13 @@ export type { } from './scapi-scripts.js'; // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) -export {isInvalidScopeError, resolveScapiOrOcapi} from './scapi-backend-utils.js'; +export {isInvalidScopeError, resolveScapiOrOcapi, withScopes} from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; -export {ScapiFallbackBackend} from './scapi-fallback-backend.js'; +export {createFallbackBackend} from './scapi-fallback-backend.js'; +export {createDualBackend} from './dual-backend-factory.js'; +export type {DualBackendConfig, DualBackendCtors, ScapiBackendCtorConfig} from './dual-backend-factory.js'; +export {buildScapiClient} from './scapi-client-factory.js'; +export type {BuildScapiClientOptions, ScapiClientConfig} from './scapi-client-factory.js'; export {ScopeTierManager} from './scapi-scope-tier.js'; export type {ScopeTier, ScopeTierManagerOptions} from './scapi-scope-tier.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts index 87a79d965..fbdc5d838 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -12,6 +12,7 @@ * * @module clients/scapi-backend-utils */ +import type {AuthStrategy} from '../auth/types.js'; /** * User-facing API backend preference. @@ -31,6 +32,21 @@ export interface BackendBase { readonly name: 'ocapi' | 'scapi'; } +/** + * Returns a copy of `auth` with `additionalScopes` merged in, or the original + * `auth` if the strategy doesn't support scope merging (e.g., basic/api-key + * auth, or a stored-session strategy where scopes were fixed at acquisition). + * + * Centralized so SCAPI client factories don't have to keep extending an + * `instanceof` chain as new OAuth strategy types are added. + */ +export function withScopes(auth: AuthStrategy, additionalScopes: string[]): AuthStrategy { + if (typeof auth.withAdditionalScopes === 'function') { + return auth.withAdditionalScopes(additionalScopes); + } + return auth; +} + /** * Detects an Account Manager `invalid_scope` error. * diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts new file mode 100644 index 000000000..73f272bf5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Generic builder for SCAPI Admin API clients. + * + * The four new SCAPI clients (jobs, scripts, merchant-users, merchant-roles) + * each had ~20 lines of nearly-identical setup: build the openapi-fetch + * client with a domain URL, install auth middleware with merged scopes, + * install plugin middleware from the registry, then rate-limit and logging. + * + * This module collapses that setup into one helper. + * + * @module clients/scapi-client-factory + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type HttpClientType, type MiddlewareRegistry} from './middleware-registry.js'; +import {buildTenantScope} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; + +export interface BuildScapiClientOptions { + /** + * URL path segment after the SCAPI host root, e.g. `'operation/jobs/v1'`. + */ + pathSegment: string; + /** + * Middleware registry key, e.g. `'scapi-jobs'`. Plugin middleware + * registered under this key gets installed on the client. + */ + domainKey: HttpClientType; + /** + * Default scopes to request when the caller doesn't override `config.scopes`. + * Typically the rw scope; the tenant scope is added automatically. + */ + defaultScopes: string[]; + /** + * Logging/rate-limit prefix, e.g. `'SCAPI-JOBS'`. Used in log lines. + */ + logPrefix: string; +} + +export interface ScapiClientConfig { + shortCode: string; + tenantId: string; + /** + * Override the requested scopes. When omitted, defaults to + * `[...defaultScopes, buildTenantScope(tenantId)]`. + */ + scopes?: string[]; + /** Override the global middleware registry (mainly for tests). */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Builds a typed openapi-fetch client for a SCAPI Admin API. + * + * @param options - Domain-specific URL/key/scopes/log-prefix + * @param config - Caller-supplied shortCode, tenantId, optional overrides + * @param auth - Auth strategy (scopes are merged via {@link withScopes}) + * + * @example + * ```ts + * export function createScapiJobsClient(config: ScapiClientConfig, auth: AuthStrategy): ScapiJobsClient { + * return buildScapiClient( + * { + * pathSegment: 'operation/jobs/v1', + * domainKey: 'scapi-jobs', + * defaultScopes: SCAPI_JOBS_RW_SCOPES, + * logPrefix: 'SCAPI-JOBS', + * }, + * config, + * auth, + * ); + * } + * ``` + */ +// `paths` types from openapi-typescript are `interface paths { ... }` shapes +// which don't satisfy `Record`. The unconstrained generic +// is fine since openapi-fetch's `Client

` constraint handles the shape check. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildScapiClient

>( + options: BuildScapiClientOptions, + config: ScapiClientConfig, + auth: AuthStrategy, +): Client

{ + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient

({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/${options.pathSegment}`, + }); + + const requiredScopes = config.scopes ?? [...options.defaultScopes, buildTenantScope(config.tenantId)]; + const scopedAuth = withScopes(auth, requiredScopes); + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware(options.domainKey)) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: options.logPrefix})); + client.use(createLoggingMiddleware(options.logPrefix)); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index 3d5c7e192..ac3b61e53 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -6,10 +6,11 @@ /** * Generic fallback wrapper for SCAPI/OCAPI dual backends. * - * Each domain (jobs, scripts, users, roles) gets a thin subclass that - * delegates each interface method through `withFallback`. The wrapper itself - * holds no domain knowledge — it only implements the "try SCAPI first; on - * `invalid_scope`, fall back to OCAPI; cache the choice" behavior. + * Builds a Proxy that implements the same interface as the underlying + * backends. Each method call routes through {@link withFallback}: try SCAPI + * first; on `invalid_scope`, fall back to OCAPI and cache the choice for the + * lifetime of the wrapper. The `name` property reflects the currently-active + * backend ('scapi' before the first call resolves, then whichever survived). * * @module clients/scapi-fallback-backend */ @@ -17,59 +18,94 @@ import {getLogger} from '../logging/logger.js'; import {isInvalidScopeError, type BackendBase} from './scapi-backend-utils.js'; /** - * Base class for `Fallback*Backend` implementations. Subclasses implement - * the domain interface (e.g., `JobsBackend`) by delegating each method to - * `withFallback`. + * Internal state shared by all method invocations on a Proxy. Holds the + * resolved backend so that once SCAPI succeeds (or we've fallen back to + * OCAPI), subsequent calls skip the SCAPI attempt. + */ +interface FallbackState { + scapi: T; + ocapi: T; + domainName: string; + resolved?: T; +} + +/** + * Wraps a SCAPI call with automatic OCAPI fallback on `invalid_scope`. + * + * Standalone helper so the Proxy traps and any future direct callers share + * one definition. + */ +async function withFallback( + state: FallbackState, + fn: (backend: T) => Promise, +): Promise { + if (state.resolved) { + return fn(state.resolved); + } + + try { + const result = await fn(state.scapi); + state.resolved = state.scapi; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + getLogger().info(`SCAPI ${state.domainName} scope unavailable, falling back to OCAPI`); + state.resolved = state.ocapi; + return fn(state.ocapi); + } + throw error; + } +} + +/** + * Creates a fallback wrapper over `scapi` and `ocapi` backends. + * + * The returned object presents the same interface as `T`. Method calls are + * intercepted: the first call tries SCAPI; on `invalid_scope` it falls back + * to OCAPI. The choice is cached for the wrapper's lifetime. + * + * @param scapi - Primary (SCAPI) backend implementation + * @param ocapi - Fallback (OCAPI) backend implementation + * @param domainName - Used in fallback log messages, e.g. `'jobs'` + * @returns A Proxy over `scapi` whose methods route through fallback logic * * @example * ```ts - * class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { - * async executeJob(jobId: string, options?: ExecuteJobOptions) { - * return this.withFallback((b) => b.executeJob(jobId, options)); - * } - * // ... one delegating method per interface method - * } + * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); + * await backend.executeJob('my-job'); // tries SCAPI, may fall back to OCAPI * ``` */ -export abstract class ScapiFallbackBackend { - protected resolvedBackend?: T; - - constructor( - protected scapiBackend: T, - protected ocapiBackend: T, - /** Used in fallback log messages, e.g. `'jobs'`, `'scripts'`. */ - protected domainName: string, - ) {} +export function createFallbackBackend(scapi: T, ocapi: T, domainName: string): T { + const state: FallbackState = {scapi, ocapi, domainName}; - /** - * Reports the backend that served the last successful call. Defaults to - * `'scapi'` before the first call, since that's what we'd try first. - */ - get name(): 'ocapi' | 'scapi' { - return this.resolvedBackend?.name ?? this.scapiBackend.name; - } + return new Proxy(scapi, { + get(target, prop, receiver) { + // Special property: `name` reflects whichever backend has handled requests so far. + if (prop === 'name') { + return (state.resolved ?? scapi).name; + } - /** - * Runs `fn` against the resolved backend, or against SCAPI first with - * automatic OCAPI fallback on `invalid_scope`. The choice is cached: once - * a backend has succeeded (or fallen back), all subsequent calls go to it. - */ - protected async withFallback(fn: (backend: T) => Promise): Promise { - if (this.resolvedBackend) { - return fn(this.resolvedBackend); - } + const value = Reflect.get(target, prop, receiver); - try { - const result = await fn(this.scapiBackend); - this.resolvedBackend = this.scapiBackend; - return result; - } catch (error) { - if (isInvalidScopeError(error)) { - getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); - this.resolvedBackend = this.ocapiBackend; - return fn(this.ocapiBackend); + // Non-functions (constants, getters): return as-is from the SCAPI backend. + // Wrappers don't currently expose any non-method state besides `name`, + // but this keeps the Proxy transparent for property access. + if (typeof value !== 'function') { + return value; } - throw error; - } - } + + // For each method, return a wrapper that routes the call through fallback. + // We must look up the method by name on the resolved backend (not on the + // SCAPI target we're proxying), since the OCAPI backend may have a + // different implementation. + return (...args: unknown[]) => + withFallback(state, (backend) => { + const fn = (backend as unknown as Record)[prop]; + if (typeof fn !== 'function') { + throw new TypeError(`Method ${String(prop)} is not a function on ${backend.name} backend`); + } + return (fn as (...a: unknown[]) => Promise).apply(backend, args); + }); + }, + }) as T; } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts index 56683fb96..5935f0444 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -3,12 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-jobs.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; export {toOrganizationId, normalizeTenantId, buildTenantScope}; @@ -28,31 +26,17 @@ export type JobExecutionSearchResult = components['schemas']['JobExecutionSearch export const SCAPI_JOBS_READ_SCOPES = ['sfcc.jobs']; export const SCAPI_JOBS_RW_SCOPES = ['sfcc.jobs.rw']; -export interface ScapiJobsClientConfig { - shortCode: string; - tenantId: string; - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiJobsClientConfig = ScapiClientConfig; export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthStrategy): ScapiJobsClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/operation/jobs/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_JOBS_RW_SCOPES, buildTenantScope(config.tenantId)]; - const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; - - client.use(createAuthMiddleware(scopedAuth)); - - for (const middleware of registry.getMiddleware('scapi-jobs')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-JOBS'})); - client.use(createLoggingMiddleware('SCAPI-JOBS')); - - return client; + return buildScapiClient( + { + pathSegment: 'operation/jobs/v1', + domainKey: 'scapi-jobs', + defaultScopes: SCAPI_JOBS_RW_SCOPES, + logPrefix: 'SCAPI-JOBS', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts index 592f04a1b..dac7b033e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts @@ -3,13 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-merchant-roles.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {buildTenantScope} from './custom-apis.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; export type {paths, components}; export type ScapiMerchantRolesClient = Client; @@ -23,35 +20,20 @@ export type RoleSearch = components['schemas']['RoleSearch']; export const SCAPI_MERCHANT_ROLES_READ_SCOPES = ['sfcc.roles']; export const SCAPI_MERCHANT_ROLES_RW_SCOPES = ['sfcc.roles.rw']; -export interface ScapiMerchantRolesClientConfig { - shortCode: string; - tenantId: string; - /** Override scopes (default: sfcc.roles.rw + tenant scope). */ - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiMerchantRolesClientConfig = ScapiClientConfig; export function createScapiMerchantRolesClient( config: ScapiMerchantRolesClientConfig, auth: AuthStrategy, ): ScapiMerchantRolesClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/roles/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_ROLES_RW_SCOPES, buildTenantScope(config.tenantId)]; - const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; - - client.use(createAuthMiddleware(scopedAuth)); - - for (const middleware of registry.getMiddleware('scapi-merchant-roles')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-ROLES'})); - client.use(createLoggingMiddleware('SCAPI-ROLES')); - - return client; + return buildScapiClient( + { + pathSegment: 'merchant/roles/v1', + domainKey: 'scapi-merchant-roles', + defaultScopes: SCAPI_MERCHANT_ROLES_RW_SCOPES, + logPrefix: 'SCAPI-ROLES', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts index 384d47f65..4b2f2889e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts @@ -3,13 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-merchant-users.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {buildTenantScope} from './custom-apis.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; export type {paths, components}; export type ScapiMerchantUsersClient = Client; @@ -23,35 +20,20 @@ export type UserSearch = components['schemas']['UserSearch']; export const SCAPI_MERCHANT_USERS_READ_SCOPES = ['sfcc.users']; export const SCAPI_MERCHANT_USERS_RW_SCOPES = ['sfcc.users.rw']; -export interface ScapiMerchantUsersClientConfig { - shortCode: string; - tenantId: string; - /** Override scopes (default: sfcc.users.rw + tenant scope). */ - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiMerchantUsersClientConfig = ScapiClientConfig; export function createScapiMerchantUsersClient( config: ScapiMerchantUsersClientConfig, auth: AuthStrategy, ): ScapiMerchantUsersClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/users/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_USERS_RW_SCOPES, buildTenantScope(config.tenantId)]; - const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; - - client.use(createAuthMiddleware(scopedAuth)); - - for (const middleware of registry.getMiddleware('scapi-merchant-users')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-USERS'})); - client.use(createLoggingMiddleware('SCAPI-USERS')); - - return client; + return buildScapiClient( + { + pathSegment: 'merchant/users/v1', + domainKey: 'scapi-merchant-users', + defaultScopes: SCAPI_MERCHANT_USERS_RW_SCOPES, + logPrefix: 'SCAPI-USERS', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts index ac8cd23cc..a5bdd9a0e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts @@ -3,13 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-scripts.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {buildTenantScope} from './custom-apis.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; export type {paths, components}; export type ScapiScriptsClient = Client; @@ -21,32 +18,17 @@ export type CodeVersion = components['schemas']['CodeVersion']; export const SCAPI_SCRIPTS_READ_SCOPES = ['sfcc.scripts']; export const SCAPI_SCRIPTS_RW_SCOPES = ['sfcc.scripts.rw']; -export interface ScapiScriptsClientConfig { - shortCode: string; - tenantId: string; - /** Override scopes (default: sfcc.scripts.rw + tenant scope). */ - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiScriptsClientConfig = ScapiClientConfig; export function createScapiScriptsClient(config: ScapiScriptsClientConfig, auth: AuthStrategy): ScapiScriptsClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/scripts/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_SCRIPTS_RW_SCOPES, buildTenantScope(config.tenantId)]; - const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; - - client.use(createAuthMiddleware(scopedAuth)); - - for (const middleware of registry.getMiddleware('scapi-scripts')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-SCRIPTS'})); - client.use(createLoggingMiddleware('SCAPI-SCRIPTS')); - - return client; + return buildScapiClient( + { + pathSegment: 'dx/scripts/v1', + domainKey: 'scapi-scripts', + defaultScopes: SCAPI_SCRIPTS_RW_SCOPES, + logPrefix: 'SCAPI-SCRIPTS', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 981d562e3..8ce2ed575 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -205,12 +205,7 @@ export type { } from './operations/code/index.js'; // Scripts (code versions) backend abstraction -export { - createScriptsBackend, - FallbackScriptsBackend, - OcapiScriptsBackend, - ScapiScriptsBackend, -} from './operations/code/index.js'; +export {createScriptsBackend, OcapiScriptsBackend, ScapiScriptsBackend} from './operations/code/index.js'; export type { ScriptsBackend, ScriptsBackendConfig, @@ -219,12 +214,7 @@ export type { } from './operations/code/index.js'; // Users (BM) backend abstraction -export { - createUsersBackend, - FallbackUsersBackend, - OcapiUsersBackend, - ScapiUsersBackend, -} from './operations/bm-users/index.js'; +export {createUsersBackend, OcapiUsersBackend, ScapiUsersBackend} from './operations/bm-users/index.js'; export type { UsersBackend, UsersBackendConfig, @@ -237,12 +227,7 @@ export type { } from './operations/bm-users/index.js'; // Roles (BM) backend abstraction -export { - createRolesBackend, - FallbackRolesBackend, - OcapiRolesBackend, - ScapiRolesBackend, -} from './operations/bm-roles/index.js'; +export {createRolesBackend, OcapiRolesBackend, ScapiRolesBackend} from './operations/bm-roles/index.js'; export type { RolesBackend, RolesBackendConfig, @@ -270,7 +255,6 @@ export { // Backend abstraction createJobsBackend, waitForJobExecution, - FallbackJobsBackend, OcapiJobsBackend, ScapiJobsBackend, } from './operations/jobs/index.js'; @@ -288,7 +272,7 @@ export type { JobsBackend, JobsBackendConfig, ApiBackendPreference, - JobExecutionResult, + JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults, ScapiJobsBackendConfig, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts index 4f85ec470..ac38b29dc 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts @@ -3,89 +3,17 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type { - RolesBackend, - RoleInfo, - ListRolesResult, - ListRolesOptions, - RolePermissionsInfo, - CreateRoleInput, -} from './types.js'; +import type {RolesBackend} from './types.js'; import {OcapiRolesBackend} from './ocapi-backend.js'; import {ScapiRolesBackend} from './scapi-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -export interface RolesBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type RolesBackendConfig = DualBackendConfig; export function createRolesBackend(config: RolesBackendConfig): RolesBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Roles', + Scapi: ScapiRolesBackend, + Ocapi: OcapiRolesBackend, }); - - if (resolved === 'ocapi') { - return new OcapiRolesBackend(config.instance); - } - - const scapiBackend = new ScapiRolesBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - const ocapiBackend = new OcapiRolesBackend(config.instance); - return new FallbackRolesBackend(scapiBackend, ocapiBackend); -} - -export class FallbackRolesBackend extends ScapiFallbackBackend implements RolesBackend { - constructor(scapiBackend: ScapiRolesBackend, ocapiBackend: OcapiRolesBackend) { - super(scapiBackend, ocapiBackend, 'roles'); - } - - async listRoles(options?: ListRolesOptions): Promise { - return this.withFallback((b) => b.listRoles(options)); - } - - async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { - return this.withFallback((b) => b.getRole(roleId, options)); - } - - async createRole(roleId: string, input?: CreateRoleInput): Promise { - return this.withFallback((b) => b.createRole(roleId, input)); - } - - async deleteRole(roleId: string): Promise { - return this.withFallback((b) => b.deleteRole(roleId)); - } - - async getPermissions(roleId: string): Promise { - return this.withFallback((b) => b.getPermissions(roleId)); - } - - async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { - return this.withFallback((b) => b.setPermissions(roleId, permissions)); - } - - async grantRole(roleId: string, login: string): Promise { - return this.withFallback((b) => b.grantRole(roleId, login)); - } - - async revokeRole(roleId: string, login: string): Promise { - return this.withFallback((b) => b.revokeRole(roleId, login)); - } } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts index 13669796f..56b40fd33 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts @@ -67,7 +67,7 @@ export { export type {BmRole, BmRoles, BmRolePermissions, ListBmRolesOptions, GetBmRoleOptions} from './roles.js'; // Roles backend abstraction — supports OCAPI + SCAPI -export {createRolesBackend, FallbackRolesBackend} from './backend.js'; +export {createRolesBackend} from './backend.js'; export type {RolesBackendConfig} from './backend.js'; export {OcapiRolesBackend} from './ocapi-backend.js'; export {ScapiRolesBackend} from './scapi-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts index 4623080f1..30a08a2c3 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -39,6 +39,8 @@ export interface ScapiRolesBackendConfig { shortCode: string; tenantId: string; auth: AuthStrategy; + /** Unused by Roles; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; } export class ScapiRolesBackend implements RolesBackend { diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts index 3a8263958..863bd6184 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts @@ -3,77 +3,17 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type { - UsersBackend, - UserInfo, - ListUsersResult, - ListUsersOptions, - UpdateUserChanges, - CreateUserInput, -} from './types.js'; +import type {UsersBackend} from './types.js'; import {OcapiUsersBackend} from './ocapi-backend.js'; import {ScapiUsersBackend} from './scapi-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -export interface UsersBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type UsersBackendConfig = DualBackendConfig; export function createUsersBackend(config: UsersBackendConfig): UsersBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Users', + Scapi: ScapiUsersBackend, + Ocapi: OcapiUsersBackend, }); - - if (resolved === 'ocapi') { - return new OcapiUsersBackend(config.instance); - } - - const scapiBackend = new ScapiUsersBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - const ocapiBackend = new OcapiUsersBackend(config.instance); - return new FallbackUsersBackend(scapiBackend, ocapiBackend); -} - -export class FallbackUsersBackend extends ScapiFallbackBackend implements UsersBackend { - constructor(scapiBackend: ScapiUsersBackend, ocapiBackend: OcapiUsersBackend) { - super(scapiBackend, ocapiBackend, 'users'); - } - - async listUsers(options?: ListUsersOptions): Promise { - return this.withFallback((b) => b.listUsers(options)); - } - - async getUser(login: string): Promise { - return this.withFallback((b) => b.getUser(login)); - } - - async createOrReplaceUser(login: string, input: CreateUserInput): Promise { - return this.withFallback((b) => b.createOrReplaceUser(login, input)); - } - - async updateUser(login: string, changes: UpdateUserChanges): Promise { - return this.withFallback((b) => b.updateUser(login, changes)); - } - - async deleteUser(login: string): Promise { - return this.withFallback((b) => b.deleteUser(login)); - } } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts index 27d00fdb0..025caa27d 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -77,7 +77,7 @@ export type { } from './users.js'; // Users backend abstraction — supports OCAPI + SCAPI -export {createUsersBackend, FallbackUsersBackend} from './backend.js'; +export {createUsersBackend} from './backend.js'; export type {UsersBackendConfig} from './backend.js'; export {OcapiUsersBackend} from './ocapi-backend.js'; export {ScapiUsersBackend} from './scapi-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts index 84ad3b244..e5a5dd9b2 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -48,6 +48,8 @@ export interface ScapiUsersBackendConfig { shortCode: string; tenantId: string; auth: AuthStrategy; + /** Unused by Users; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; } export class ScapiUsersBackend implements UsersBackend { diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index b0bed4ad5..87f5c02c5 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -9,7 +9,9 @@ import JSZip from 'jszip'; import type {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; import {findCartridges, type CartridgeMapping, type FindCartridgesOptions} from './cartridges.js'; -import {activateCodeVersion, reloadCodeVersion} from './versions.js'; +import {activateCodeVersion} from './versions.js'; +import {reloadCodeVersion} from './scripts-backend.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; const UNZIP_BODY = new URLSearchParams({method: 'UNZIP'}).toString(); @@ -320,7 +322,7 @@ export async function findAndDeployCartridges( activated = true; } else if (options.reload) { logger.debug('Reloading code version...'); - await reloadCodeVersion(instance, codeVersion); + await reloadCodeVersion(new OcapiScriptsBackend(instance), codeVersion); activated = true; reloaded = true; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index f2386d2e4..cc9a1f2be 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -75,14 +75,13 @@ export { listCodeVersions, getActiveCodeVersion, activateCodeVersion, - reloadCodeVersion, deleteCodeVersion, createCodeVersion, } from './versions.js'; export type {CodeVersion, CodeVersionResult} from './versions.js'; // Scripts (code versions) backend abstraction — supports OCAPI + SCAPI -export {createScriptsBackend, FallbackScriptsBackend} from './scripts-backend.js'; +export {createScriptsBackend, reloadCodeVersion} from './scripts-backend.js'; export type {ScriptsBackendConfig} from './scripts-backend.js'; export {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; export {ScapiScriptsBackend} from './scapi-scripts-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts index 397d2513a..5cf087cfd 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts @@ -12,7 +12,6 @@ import { activateCodeVersion as ocapiActivateCodeVersion, deleteCodeVersion as ocapiDeleteCodeVersion, createCodeVersion as ocapiCreateCodeVersion, - reloadCodeVersion as ocapiReloadCodeVersion, } from './versions.js'; function mapOcapiCodeVersion(ocapi: OcapiCodeVersion): CodeVersionInfo { @@ -56,8 +55,4 @@ export class OcapiScriptsBackend implements ScriptsBackend { async createCodeVersion(codeVersionId: string): Promise { await ocapiCreateCodeVersion(this.instance, codeVersionId); } - - async reloadCodeVersion(codeVersionId?: string): Promise { - await ocapiReloadCodeVersion(this.instance, codeVersionId); - } } diff --git a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts index 5cb4e7609..05f303a8f 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -36,6 +36,8 @@ export interface ScapiScriptsBackendConfig { shortCode: string; tenantId: string; auth: AuthStrategy; + /** Unused by Scripts; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; } export class ScapiScriptsBackend implements ScriptsBackend { @@ -106,10 +108,6 @@ export class ScapiScriptsBackend implements ScriptsBackend { } } - async reloadCodeVersion(_codeVersionId?: string): Promise { - throw new Error('Reloading code versions is not supported via SCAPI. Use --api-backend ocapi to reload.'); - } - private buildClient(scopes: string[]): ScapiScriptsClient { const clientConfig: ScapiScriptsClientConfig = { shortCode: this.config.shortCode, diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts index 3874fd215..09f02b652 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts @@ -3,75 +3,50 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import type {ScriptsBackend} from './scripts-types.js'; import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; import {ScapiScriptsBackend} from './scapi-scripts-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -export interface ScriptsBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type ScriptsBackendConfig = DualBackendConfig; export function createScriptsBackend(config: ScriptsBackendConfig): ScriptsBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Scripts', + Scapi: ScapiScriptsBackend, + Ocapi: OcapiScriptsBackend, }); - - if (resolved === 'ocapi') { - return new OcapiScriptsBackend(config.instance); - } - - const scapiBackend = new ScapiScriptsBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - // Auto mode: wrap with fallback - const ocapiBackend = new OcapiScriptsBackend(config.instance); - return new FallbackScriptsBackend(scapiBackend, ocapiBackend); } -export class FallbackScriptsBackend extends ScapiFallbackBackend implements ScriptsBackend { - constructor(scapiBackend: ScapiScriptsBackend, ocapiBackend: OcapiScriptsBackend) { - super(scapiBackend, ocapiBackend, 'scripts'); - } - - async listCodeVersions(): Promise { - return this.withFallback((b) => b.listCodeVersions()); - } - - async getActiveCodeVersion(): Promise { - return this.withFallback((b) => b.getActiveCodeVersion()); - } - - async activateCodeVersion(codeVersionId: string): Promise { - return this.withFallback((b) => b.activateCodeVersion(codeVersionId)); - } +/** + * Reloads (re-activates) a code version using a toggle-activate technique. + * + * Activates an alternate version, then re-activates the target. This forces + * the instance to reload the code (rebuild caches, re-register custom APIs, + * etc.). Works on top of any `ScriptsBackend` since it only uses + * list+activate primitives. + * + * @param backend - Scripts backend (OCAPI, SCAPI, or fallback) + * @param codeVersionId - Code version to reload (defaults to current active) + * @throws Error if no alternate code version is available for toggling + */ +export async function reloadCodeVersion(backend: ScriptsBackend, codeVersionId?: string): Promise { + const versions = await backend.listCodeVersions(); + const activeVersion = versions.find((v) => v.active); + const targetVersion = codeVersionId ?? activeVersion?.id; - async deleteCodeVersion(codeVersionId: string): Promise { - return this.withFallback((b) => b.deleteCodeVersion(codeVersionId)); + if (!targetVersion) { + throw new Error('No code version specified and no active version found'); } - async createCodeVersion(codeVersionId: string): Promise { - return this.withFallback((b) => b.createCodeVersion(codeVersionId)); + // If the target is already active, toggle through an alternate first. + if (activeVersion?.id === targetVersion) { + const alternateVersion = versions.find((v) => v.id !== targetVersion); + if (!alternateVersion) { + throw new Error('Cannot reload: no alternate code version available for toggle'); + } + await backend.activateCodeVersion(alternateVersion.id); } - async reloadCodeVersion(codeVersionId?: string): Promise { - return this.withFallback((b) => b.reloadCodeVersion(codeVersionId)); - } + await backend.activateCodeVersion(targetVersion); } diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts index a0d8e18ce..f6df91252 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts @@ -35,10 +35,9 @@ export interface CodeVersionInfo { /** * Backend contract for code-version operations. * - * `reloadCodeVersion` is OCAPI-only — the SCAPI backend's implementation - * throws to advertise that. In auto mode the fallback wrapper will fall - * through to OCAPI on the first call (since reload requires the OCAPI cache - * rebuild semantics). + * Reload is implemented as a backend-agnostic helper (`reloadCodeVersion` + * in the operations module) since it's just `activate(alternate) + + * activate(target)` on top of these primitives. */ export interface ScriptsBackend extends BackendBase { listCodeVersions(): Promise; @@ -46,9 +45,4 @@ export interface ScriptsBackend extends BackendBase { activateCodeVersion(codeVersionId: string): Promise; deleteCodeVersion(codeVersionId: string): Promise; createCodeVersion(codeVersionId: string): Promise; - /** - * Re-activates the current code version to force a code cache reload. - * Implemented only by the OCAPI backend. - */ - reloadCodeVersion(codeVersionId?: string): Promise; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/versions.ts b/packages/b2c-tooling-sdk/src/operations/code/versions.ts index 052be7f83..2f89efd00 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/versions.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/versions.ts @@ -86,54 +86,6 @@ export async function activateCodeVersion(instance: B2CInstance, codeVersionId: logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); } -/** - * Reloads (re-activates) the current code version. - * - * This performs a "toggle" activation - first activating a different code version, - * then re-activating the target version. This forces the instance to reload the code. - * - * @param instance - B2C instance - * @param codeVersionId - Code version to reload (defaults to current active) - * @throws Error if reload fails or no alternate version is available - * - * @example - * ```typescript - * // Reload the currently active code version - * await reloadCodeVersion(instance); - * - * // Reload a specific code version - * await reloadCodeVersion(instance, 'v1'); - * ``` - */ -export async function reloadCodeVersion(instance: B2CInstance, codeVersionId?: string): Promise { - const logger = getLogger(); - const versions = await listCodeVersions(instance); - - const activeVersion = versions.find((v) => v.active); - const targetVersion = codeVersionId ?? activeVersion?.id; - - if (!targetVersion) { - throw new Error('No code version specified and no active version found'); - } - - logger.debug({codeVersionId: targetVersion}, `Reloading code version ${targetVersion}`); - - // If the target is already active, we need to toggle to another version first - if (activeVersion?.id === targetVersion) { - const alternateVersion = versions.find((v) => v.id !== targetVersion); - if (!alternateVersion) { - throw new Error('Cannot reload: no alternate code version available for toggle'); - } - - logger.debug({codeVersionId: alternateVersion.id}, `Temporarily activating ${alternateVersion.id}`); - await activateCodeVersion(instance, alternateVersion.id!); - } - - // Now activate the target version - await activateCodeVersion(instance, targetVersion); - logger.debug({codeVersionId: targetVersion}, `Code version ${targetVersion} reloaded`); -} - /** * Deletes a code version from an instance. * diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts index 75f4d4667..cf05bf49a 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts @@ -3,77 +3,22 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type {JobsBackend, JobExecutionResult, JobExecutionSearchResults} from './types.js'; -import type {ExecuteJobOptions, SearchJobExecutionsOptions, WaitForJobOptions, WaitForJobPollInfo} from './run.js'; +import type {JobsBackend, JobExecutionInfo} from './types.js'; +import type {WaitForJobOptions, WaitForJobPollInfo} from './run.js'; import {OcapiJobsBackend} from './ocapi-backend.js'; import {ScapiJobsBackend} from './scapi-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; +import type {ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; export type {ApiBackendPreference}; - -export interface JobsBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type JobsBackendConfig = DualBackendConfig; export function createJobsBackend(config: JobsBackendConfig): JobsBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Jobs', + Scapi: ScapiJobsBackend, + Ocapi: OcapiJobsBackend, }); - - if (resolved === 'ocapi') { - return new OcapiJobsBackend(config.instance); - } - - const scapiBackend = new ScapiJobsBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - instance: config.instance, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - // Auto mode: wrap with fallback - const ocapiBackend = new OcapiJobsBackend(config.instance); - return new FallbackJobsBackend(scapiBackend, ocapiBackend); -} - -export class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { - constructor(scapiBackend: ScapiJobsBackend, ocapiBackend: OcapiJobsBackend) { - super(scapiBackend, ocapiBackend, 'jobs'); - } - - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - return this.withFallback((backend) => backend.executeJob(jobId, options)); - } - - async getJobExecution(jobId: string, executionId: string): Promise { - return this.withFallback((backend) => backend.getJobExecution(jobId, executionId)); - } - - async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - return this.withFallback((backend) => backend.searchJobExecutions(options)); - } - - async deleteJobExecution(jobId: string, executionId: string): Promise { - return this.withFallback((backend) => backend.deleteJobExecution(jobId, executionId)); - } - - async getJobLog(execution: JobExecutionResult): Promise { - return this.withFallback((backend) => backend.getJobLog(execution)); - } } export async function waitForJobExecution( @@ -81,7 +26,7 @@ export async function waitForJobExecution( jobId: string, executionId: string, options: WaitForJobOptions = {}, -): Promise { +): Promise { const {pollIntervalSeconds = 3, timeoutSeconds = 0, onPoll} = options; const sleepFn = options.sleep ?? defaultSleep; const startTime = Date.now(); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 6164484d4..6d81574e1 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -91,12 +91,12 @@ export type { } from './run.js'; // Backend abstraction -export {createJobsBackend, waitForJobExecution, FallbackJobsBackend} from './backend.js'; +export {createJobsBackend, waitForJobExecution} from './backend.js'; export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; export {OcapiJobsBackend} from './ocapi-backend.js'; export {ScapiJobsBackend} from './scapi-backend.js'; export type {ScapiJobsBackendConfig} from './scapi-backend.js'; -export type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +export type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; // Site archive import/export export { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts index a58e0b6f1..9cf221314 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts @@ -4,7 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {B2CInstance} from '../../instance/index.js'; -import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; import type {ExecuteJobOptions, SearchJobExecutionsOptions, JobExecution, JobStepExecution} from './run.js'; import { executeJob as ocapiExecuteJob, @@ -29,11 +29,11 @@ function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { }; } -function mapOcapiExecution(ocapi: JobExecution): JobExecutionResult { +function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { return { id: ocapi.id ?? '', jobId: ocapi.job_id ?? '', - executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionResult['executionStatus'], + executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionInfo['executionStatus'], exitStatus: ocapi.exit_status ? { code: ocapi.exit_status.code ?? '', @@ -57,12 +57,12 @@ export class OcapiJobsBackend implements JobsBackend { constructor(private instance: B2CInstance) {} - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { const result = await ocapiExecuteJob(this.instance, jobId, options); return mapOcapiExecution(result); } - async getJobExecution(jobId: string, executionId: string): Promise { + async getJobExecution(jobId: string, executionId: string): Promise { const result = await ocapiGetJobExecution(this.instance, jobId, executionId); return mapOcapiExecution(result); } @@ -81,7 +81,7 @@ export class OcapiJobsBackend implements JobsBackend { throw new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'); } - async getJobLog(execution: JobExecutionResult): Promise { + async getJobLog(execution: JobExecutionInfo): Promise { const ocapiExecution = execution._raw as JobExecution; if (ocapiExecution) { return ocapiGetJobLog(this.instance, ocapiExecution); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts index 4a5df3e06..3ad0d389e 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -5,7 +5,7 @@ */ import type {B2CInstance} from '../../instance/index.js'; import type {AuthStrategy} from '../../auth/types.js'; -import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; import { createScapiJobsClient, @@ -36,11 +36,11 @@ function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { }; } -function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionResult { +function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionInfo { return { id: scapi.id, jobId: scapi.jobId, - executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionResult['executionStatus'], + executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionInfo['executionStatus'], exitStatus: scapi.exitStatus ? { code: scapi.exitStatus.code ?? '', @@ -82,7 +82,7 @@ export class ScapiJobsBackend implements JobsBackend { }); } - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { const client = this.scopeTier.getClientForWrite(); const {parameters = [], body: rawBody} = options ?? {}; @@ -123,7 +123,7 @@ export class ScapiJobsBackend implements JobsBackend { return mapScapiExecution(data); } - async getJobExecution(jobId: string, executionId: string): Promise { + async getJobExecution(jobId: string, executionId: string): Promise { const client = this.scopeTier.getClientForRead(); const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { @@ -200,7 +200,7 @@ export class ScapiJobsBackend implements JobsBackend { } } - async getJobLog(execution: JobExecutionResult): Promise { + async getJobLog(execution: JobExecutionInfo): Promise { if (!execution.logFilePath) { throw new Error('No log file path available'); } @@ -221,7 +221,7 @@ export class ScapiJobsBackend implements JobsBackend { return createScapiJobsClient(clientConfig, this.config.auth); } - private async findRunningExecution(jobId: string): Promise { + private async findRunningExecution(jobId: string): Promise { const results = await this.searchJobExecutions({ jobId, status: ['RUNNING', 'PENDING'], diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts index efc3bed1a..a48c1727c 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -7,7 +7,7 @@ import type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} f export type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions}; -export interface JobExecutionResult { +export interface JobExecutionInfo { id: string; jobId: string; executionStatus: @@ -48,14 +48,14 @@ export interface JobExecutionSearchResults { total: number; limit: number; offset: number; - hits: JobExecutionResult[]; + hits: JobExecutionInfo[]; } export interface JobsBackend { readonly name: 'ocapi' | 'scapi'; - executeJob(jobId: string, options?: ExecuteJobOptions): Promise; - getJobExecution(jobId: string, executionId: string): Promise; + executeJob(jobId: string, options?: ExecuteJobOptions): Promise; + getJobExecution(jobId: string, executionId: string): Promise; searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; deleteJobExecution(jobId: string, executionId: string): Promise; - getJobLog(execution: JobExecutionResult): Promise; + getJobLog(execution: JobExecutionInfo): Promise; } diff --git a/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts index e7d7b848a..ae5b46a95 100644 --- a/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts @@ -16,8 +16,9 @@ import { activateCodeVersion, createCodeVersion, deleteCodeVersion, - reloadCodeVersion, } from '../../../src/operations/code/versions.js'; +import {reloadCodeVersion} from '../../../src/operations/code/scripts-backend.js'; +import {OcapiScriptsBackend} from '../../../src/operations/code/ocapi-scripts-backend.js'; const TEST_HOST = 'test.demandware.net'; const BASE_URL = `https://${TEST_HOST}/s/-/dw/data/v25_6`; @@ -258,7 +259,7 @@ describe('operations/code/versions', () => { }), ); - await reloadCodeVersion(mockInstance, 'v2'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v2'); // Success - no error thrown }); @@ -277,7 +278,7 @@ describe('operations/code/versions', () => { }), ); - await reloadCodeVersion(mockInstance, 'v2'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v2'); // Success - no error thrown }); @@ -291,7 +292,7 @@ describe('operations/code/versions', () => { ); try { - await reloadCodeVersion(mockInstance, 'v1'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v1'); expect.fail('Should have thrown error'); } catch (error: any) { expect(error.message).to.include('no alternate code version available'); @@ -308,7 +309,7 @@ describe('operations/code/versions', () => { ); try { - await reloadCodeVersion(mockInstance); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance)); expect.fail('Should have thrown error'); } catch (error: any) { expect(error.message).to.include('No code version specified'); From b010f755b7617d95636059c71a281d2aa969a112 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 15:49:42 -0400 Subject: [PATCH 08/11] Test and document the Proxy-based fallback wrapper Adds 8 unit tests for createFallbackBackend covering: - happy path (SCAPI works, choice is cached) - fallback path (invalid_scope triggers OCAPI, choice is cached) - name reflects the resolved backend - non-fallback errors are rethrown without falling back - multi-arg method dispatch - non-method property access (documented as SCAPI-target-only) Tightens the JSDoc on createFallbackBackend to spell out the contract explicitly: both backends must implement T (TypeScript enforces this at the call site), only methods are routed through fallback, non-method properties stay on the SCAPI target, and concurrent first-calls are benign since they only retry SCAPI redundantly. --- .../src/clients/scapi-fallback-backend.ts | 16 +- .../clients/scapi-fallback-backend.test.ts | 169 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index ac3b61e53..f820cdf0a 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -64,6 +64,20 @@ async function withFallback( * intercepted: the first call tries SCAPI; on `invalid_scope` it falls back * to OCAPI. The choice is cached for the wrapper's lifetime. * + * **Contract:** + * - Both `scapi` and `ocapi` must implement `T`. TypeScript enforces this + * at the call site since both are typed as `T`. + * - Only methods of `T` are routed through the fallback logic. The `name` + * getter is special-cased to reflect the resolved backend. + * - **Non-method properties** are returned from the SCAPI target only and + * are not switched on fallback. By convention, backends should be method + * bags — any state beyond `name` should be encapsulated, not exposed. + * - **Concurrency:** if two calls race before resolution, both may attempt + * SCAPI. This is benign for read operations (idempotent retries) and + * acceptable for writes (both either succeed or fail with the same + * error). Each Proxy instance has its own state, so this concerns only + * shared use of a single wrapper. + * * @param scapi - Primary (SCAPI) backend implementation * @param ocapi - Fallback (OCAPI) backend implementation * @param domainName - Used in fallback log messages, e.g. `'jobs'` @@ -71,7 +85,7 @@ async function withFallback( * * @example * ```ts - * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); + * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); * await backend.executeJob('my-job'); // tries SCAPI, may fall back to OCAPI * ``` */ diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts new file mode 100644 index 000000000..febc665ae --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.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 {createFallbackBackend} from '../../src/clients/scapi-fallback-backend.js'; + +interface TestBackend { + readonly name: 'ocapi' | 'scapi'; + doRead(): Promise; + doWrite(input: string): Promise; + multiArg(a: string, b: number, c?: boolean): Promise; +} + +function makeBackend(name: 'ocapi' | 'scapi', impl: Partial): TestBackend { + return { + name, + doRead: impl.doRead ?? (async () => `${name}-read`), + doWrite: impl.doWrite ?? (async () => undefined), + multiArg: impl.multiArg ?? (async (a, b, c) => `${name}:${a}:${b}:${c}`), + }; +} + +const invalidScopeError = () => new Error('Failed to get access token: 400 invalid_scope'); + +describe('createFallbackBackend', () => { + describe('happy path: SCAPI works', () => { + it('returns SCAPI result on first call and caches the choice', async () => { + let scapiCalls = 0; + let ocapiCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => { + scapiCalls++; + return 'scapi-read'; + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => { + ocapiCalls++; + return 'ocapi-read'; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(await backend.doRead()).to.equal('scapi-read'); + expect(await backend.doRead()).to.equal('scapi-read'); + expect(scapiCalls).to.equal(2); + expect(ocapiCalls).to.equal(0); + }); + + it('reflects scapi.name before any call resolves and after success', async () => { + const scapi = makeBackend('scapi', {}); + const ocapi = makeBackend('ocapi', {}); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(backend.name).to.equal('scapi'); + await backend.doRead(); + expect(backend.name).to.equal('scapi'); + }); + }); + + describe('fallback path: SCAPI fails with invalid_scope', () => { + it('falls back to OCAPI and caches the choice', async () => { + let scapiCalls = 0; + let ocapiCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => { + scapiCalls++; + throw invalidScopeError(); + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => { + ocapiCalls++; + return 'ocapi-read'; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(await backend.doRead()).to.equal('ocapi-read'); + expect(await backend.doRead()).to.equal('ocapi-read'); + // SCAPI tried once on the first call; OCAPI handles all subsequent + expect(scapiCalls).to.equal(1); + expect(ocapiCalls).to.equal(2); + }); + + it('reflects ocapi.name after fallback', async () => { + const scapi = makeBackend('scapi', { + doRead: async () => { + throw invalidScopeError(); + }, + }); + const ocapi = makeBackend('ocapi', {}); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(backend.name).to.equal('scapi'); + await backend.doRead(); + expect(backend.name).to.equal('ocapi'); + }); + + it('routes a different method to the cached OCAPI backend after fallback', async () => { + let ocapiWriteCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => { + throw invalidScopeError(); + }, + doWrite: async () => { + throw new Error('SCAPI doWrite should not be called after fallback'); + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => 'ocapi-read', + doWrite: async () => { + ocapiWriteCalls++; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + await backend.doRead(); + await backend.doWrite('payload'); + expect(ocapiWriteCalls).to.equal(1); + }); + }); + + describe('non-fallback errors', () => { + it('rethrows non-invalid_scope errors without falling back', async () => { + const scapi = makeBackend('scapi', { + doRead: async () => { + throw new Error('something else broke'); + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => 'should-not-reach-this', + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + try { + await backend.doRead(); + expect.fail('should have thrown'); + } catch (e) { + expect((e as Error).message).to.equal('something else broke'); + } + // Subsequent calls still try SCAPI since fallback didn't trigger + expect(backend.name).to.equal('scapi'); + }); + }); + + describe('argument forwarding', () => { + it('forwards positional and optional args correctly', async () => { + const scapi = makeBackend('scapi', {}); + const ocapi = makeBackend('ocapi', {}); + const backend = createFallbackBackend(scapi, ocapi, 'test'); + + expect(await backend.multiArg('x', 7, true)).to.equal('scapi:x:7:true'); + expect(await backend.multiArg('y', 0)).to.equal('scapi:y:0:undefined'); + }); + }); + + describe('property access', () => { + it('returns non-method properties from the SCAPI target', () => { + const scapi = {...makeBackend('scapi', {}), customProp: 'scapi-value'}; + const ocapi = {...makeBackend('ocapi', {}), customProp: 'ocapi-value'}; + const backend = createFallbackBackend(scapi, ocapi, 'test'); + // Documented contract: non-method properties are not switched between backends + expect(backend.customProp).to.equal('scapi-value'); + }); + }); +}); From 357097aa7e4d03eb006c3436e8da75d433b5eb85 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 16:06:31 -0400 Subject: [PATCH 09/11] Encode capability differences in the type system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hostile review surfaced two real type-safety gaps in the dual-backend pattern. Both are fixed by making interface-level capability explicit rather than relying on runtime throws. 1. RoleInfo.permissions silently dropped after fallback. The OCAPI role mapper omitted permissions; SCAPI included them. Both satisfied the optional `permissions?` field, so TypeScript and the Proxy were happy while data quietly disappeared on the fallback path. Fix: map OCAPI's permissions through the existing snake_case → camelCase converter so getRole returns the same shape from both backends. 2. JobsBackend.deleteJobExecution was a runtime-throwing stub on OCAPI. Auto-mode behavior was unstable: it worked on a SCAPI-resolved backend, threw on an OCAPI-resolved one. Fix: split capability into DeletableJobsBackend (extends JobsBackend), which only ScapiJobsBackend implements. A supportsDeleteJobExecution() type guard lets callers narrow before calling. The Fallback Proxy detects SCAPI-only methods (those missing on OCAPI) and routes them directly to SCAPI without attempting fallback — invalid_scope errors propagate to the caller instead of trying an OCAPI that can't handle the operation. The job execution delete command now uses the type guard and gives a clear error message when the active backend can't delete. Adds 2 fallback-backend tests covering SCAPI-only method dispatch. 1732 SDK + 1219 CLI tests passing. --- .../src/commands/job/execution/delete.ts | 11 +++++ .../commands/job/execution/delete.test.ts | 30 +++++++++----- .../src/clients/scapi-fallback-backend.ts | 13 ++++++ packages/b2c-tooling-sdk/src/index.ts | 2 + .../src/operations/bm-roles/ocapi-backend.ts | 7 ++-- .../src/operations/jobs/index.ts | 9 +++- .../src/operations/jobs/ocapi-backend.ts | 4 -- .../src/operations/jobs/scapi-backend.ts | 9 +++- .../src/operations/jobs/types.ts | 18 +++++++- .../clients/scapi-fallback-backend.test.ts | 41 +++++++++++++++++++ 10 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts index 56272b463..8bb350264 100644 --- a/packages/b2c-cli/src/commands/job/execution/delete.ts +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -5,6 +5,7 @@ */ import {Args} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {supportsDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../../i18n/index.js'; export default class JobExecutionDelete extends JobCommand { @@ -41,6 +42,16 @@ export default class JobExecutionDelete extends JobCommand { return createTestCommand(JobExecutionDelete, hooks.getConfig(), flags, args); } - function createMockBackend() { + function createScapiBackend() { + // SCAPI backend implements DeletableJobsBackend (has deleteJobExecution) return { name: 'scapi' as const, executeJob: sinon.stub(), @@ -32,18 +33,28 @@ describe('job execution delete', () => { }; } - function stubCommon(command: any) { + function createOcapiBackend() { + // OCAPI backend does NOT implement deleteJobExecution + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + + function stubCommon(command: any, backend: object) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); - const backend = createMockBackend(); sinon.stub(command, 'createJobsBackend').returns(backend); return backend; } - it('deletes a job execution', async () => { + it('deletes a job execution when SCAPI backend is active', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const backend = stubCommon(command, createScapiBackend()) as ReturnType; backend.deleteJobExecution.resolves(); await runSilent(() => command.run()); @@ -53,18 +64,15 @@ describe('job execution delete', () => { expect(backend.deleteJobExecution.getCall(0).args[1]).to.equal('exec-1'); }); - it('throws when OCAPI backend does not support delete', async () => { + it('errors with a clear message when OCAPI backend is active (no delete capability)', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); - backend.deleteJobExecution.rejects( - new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'), - ); + stubCommon(command, createOcapiBackend()); try { await command.run(); expect.fail('should have thrown'); } catch (error: any) { - expect(error.message).to.include('not supported via OCAPI'); + expect(error.message).to.match(/SCAPI/i); } }); }); diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index f820cdf0a..06c7494be 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -112,6 +112,19 @@ export function createFallbackBackend(scapi: T, ocapi: T, // We must look up the method by name on the resolved backend (not on the // SCAPI target we're proxying), since the OCAPI backend may have a // different implementation. + // + // If the method is missing from OCAPI (e.g., a SCAPI-only capability like + // delete), don't attempt a fallback — let SCAPI handle it directly. + // The caller should use the type-guard pattern (e.g. supportsDeleteJobExecution) + // to detect this before calling. + const ocapiHasMethod = typeof (ocapi as unknown as Record)[prop] === 'function'; + if (!ocapiHasMethod) { + return (...args: unknown[]) => { + const fn = (scapi as unknown as Record)[prop]; + return (fn as (...a: unknown[]) => Promise).apply(scapi, args); + }; + } + return (...args: unknown[]) => withFallback(state, (backend) => { const fn = (backend as unknown as Record)[prop]; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 8ce2ed575..c6e78b946 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -257,6 +257,7 @@ export { waitForJobExecution, OcapiJobsBackend, ScapiJobsBackend, + supportsDeleteJobExecution, } from './operations/jobs/index.js'; export type { JobExecution, @@ -270,6 +271,7 @@ export type { JobExecutionSearchResult, // Backend abstraction types JobsBackend, + DeletableJobsBackend, JobsBackendConfig, ApiBackendPreference, JobExecutionInfo, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts index 999e7a6ab..ff0d0ddad 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts @@ -30,9 +30,10 @@ function mapOcapiRole(ocapi: BmRole): RoleInfo { description: ocapi.description, userCount: ocapi.user_count, userManager: ocapi.user_manager, - // OCAPI permissions shape uses snake_case nested groups; the canonical - // type uses SCAPI's camelCase shape. We avoid converting the deep - // structure here (it's only exposed via the permissions endpoints). + // OCAPI returns permissions inline on the role when expanded, same as SCAPI. + // Map snake_case → camelCase to match the canonical RoleInfo shape so that + // callers see consistent data after a fallback from SCAPI to OCAPI. + permissions: ocapi.permissions ? mapOcapiPermissions(ocapi.permissions) : undefined, _raw: ocapi, }; } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 6d81574e1..9771a8b46 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -96,7 +96,14 @@ export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; export {OcapiJobsBackend} from './ocapi-backend.js'; export {ScapiJobsBackend} from './scapi-backend.js'; export type {ScapiJobsBackendConfig} from './scapi-backend.js'; -export type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +export {supportsDeleteJobExecution} from './types.js'; +export type { + JobsBackend, + DeletableJobsBackend, + JobExecutionInfo, + JobStepExecutionResult, + JobExecutionSearchResults, +} from './types.js'; // Site archive import/export export { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts index 9cf221314..3e86861a6 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts @@ -77,10 +77,6 @@ export class OcapiJobsBackend implements JobsBackend { }; } - async deleteJobExecution(_jobId: string, _executionId: string): Promise { - throw new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'); - } - async getJobLog(execution: JobExecutionInfo): Promise { const ocapiExecution = execution._raw as JobExecution; if (ocapiExecution) { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts index 3ad0d389e..804fd81da 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -5,7 +5,12 @@ */ import type {B2CInstance} from '../../instance/index.js'; import type {AuthStrategy} from '../../auth/types.js'; -import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type { + DeletableJobsBackend, + JobExecutionInfo, + JobStepExecutionResult, + JobExecutionSearchResults, +} from './types.js'; import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; import { createScapiJobsClient, @@ -66,7 +71,7 @@ export interface ScapiJobsBackendConfig { instance: B2CInstance; } -export class ScapiJobsBackend implements JobsBackend { +export class ScapiJobsBackend implements DeletableJobsBackend { readonly name = 'scapi' as const; private organizationId: string; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts index a48c1727c..d218867e0 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -56,6 +56,22 @@ export interface JobsBackend { executeJob(jobId: string, options?: ExecuteJobOptions): Promise; getJobExecution(jobId: string, executionId: string): Promise; searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; - deleteJobExecution(jobId: string, executionId: string): Promise; getJobLog(execution: JobExecutionInfo): Promise; } + +/** + * Capability extension for backends that can delete job execution records. + * Only SCAPI exposes this — OCAPI's Data API has no equivalent endpoint. + * + * Use {@link supportsDeleteJobExecution} to narrow at runtime. + */ +export interface DeletableJobsBackend extends JobsBackend { + deleteJobExecution(jobId: string, executionId: string): Promise; +} + +/** + * Type guard: returns true if the backend supports deleting job executions. + */ +export function supportsDeleteJobExecution(backend: JobsBackend): backend is DeletableJobsBackend { + return typeof (backend as DeletableJobsBackend).deleteJobExecution === 'function'; +} diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts index febc665ae..7fdec11a9 100644 --- a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -166,4 +166,45 @@ describe('createFallbackBackend', () => { expect(backend.customProp).to.equal('scapi-value'); }); }); + + describe('SCAPI-only methods (capability extension)', () => { + interface ExtendedBackend extends TestBackend { + scapiOnlyMethod(): Promise; + } + + it('routes SCAPI-only methods directly to SCAPI without attempting fallback', async () => { + let scapiCalls = 0; + const scapi: ExtendedBackend = { + ...makeBackend('scapi', {}), + scapiOnlyMethod: async () => { + scapiCalls++; + return 'scapi-only-result'; + }, + }; + const ocapi = makeBackend('ocapi', {}); // no scapiOnlyMethod + + const backend = createFallbackBackend(scapi, ocapi as ExtendedBackend, 'test'); + expect(await backend.scapiOnlyMethod()).to.equal('scapi-only-result'); + expect(scapiCalls).to.equal(1); + }); + + it('does not fall back on invalid_scope for SCAPI-only methods', async () => { + const scapi: ExtendedBackend = { + ...makeBackend('scapi', {}), + scapiOnlyMethod: async () => { + throw invalidScopeError(); + }, + }; + const ocapi = makeBackend('ocapi', {}); // no scapiOnlyMethod + + const backend = createFallbackBackend(scapi, ocapi as ExtendedBackend, 'test'); + try { + await backend.scapiOnlyMethod(); + expect.fail('should have thrown'); + } catch (e) { + // Should be the original invalid_scope error, not a "method not found" error + expect((e as Error).message).to.include('invalid_scope'); + } + }); + }); }); From 67b5ef877d6b181ad1183ee12973a4b34d766d75 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 22:30:49 -0400 Subject: [PATCH 10/11] Rebuild jobs SCAPI/OCAPI dispatch around auth-layer scope cascade Replaces the layered backend abstraction (interface + adapter classes + Proxy fallback wrapper + ScopeTierManager) for the jobs domain with a single CLI-side dispatcher and free-function SCAPI ops. The dispatcher's sole purpose is caching the resolved backend across multi-call operations in apiBackend=auto mode, so a polling command (job run --wait) doesn't re-probe SCAPI on every iteration when the user has no SCAPI scopes provisioned. Auth changes: - AuthStrategy gains optional getAccessTokenForCascade(candidates). OAuthStrategy and JwtOAuthStrategy walk candidates in order; first that AM accepts wins, cached per requested scope set. - findCachedTokenSatisfying scans the cache for a non-expired token whose scopes are a superset of a required set, so a previously granted broader-scope token can serve a narrower request without another AM round trip. - 5 new unit tests exercise cascade resolution, cache reuse, and error paths. Client/middleware changes: - buildScapiClient gains scopeCascade option and a new createScapiAuthMiddleware that reads the per-request x-b2c-scope-mode header and asks the strategy to resolve the chosen cascade tier (read or write). Legacy defaultScopes still works for scripts/users/roles until they migrate. - SCAPI Jobs declares its cascade once at client construction: read = [['sfcc.jobs.rw'], ['sfcc.jobs']], write = [['sfcc.jobs.rw']]. Jobs domain: - ScapiJobsBackend / OcapiJobsBackend / FallbackJobsBackend / DeletableJobsBackend all deleted. ScopeTierManager dependency removed from jobs. - New scapi-ops.ts exports free functions (scapiExecuteJob, scapiGetJobExecution, scapiSearchJobExecutions, scapiDeleteJobExecution, scapiGetJobLog) that take a ScapiJobsClient and declare scope mode via headers. - mapOcapiExecution / mapOcapiSearchResult exposed as transitional helpers for the OCAPI dispatcher branches. - waitForJobExecution rewritten to take a getter callback over the canonical JobExecutionInfo shape. - New CanonicalJobExecutionError carries JobExecutionInfo (was raw OCAPI), fixing a regression where SCAPI --wait failures reported 'ERROR' instead of the real exit code. CLI: - BackendDispatcher moved to src/compat/dispatcher.ts behind a new ./compat package export. Re-exported from ./cli for ergonomics. runScapiOnly removed; SCAPI-only commands branch on apiBackendPreference + buildScapiJobsClient directly. - JobCommand exposes createJobsDispatcher, buildScapiJobsClient, and showJobLog (canonical-first, raw-OCAPI accepted for legacy callers). - All five job commands rewritten to dispatcher.run({scapi, ocapi}) with the scapi branch receiving a typed ScapiJobsClient. Behavioral guarantee: no CLI-visible changes. OCAPI-only setups, explicit --api-backend ocapi/scapi, and apiBackend=auto with full SCAPI scopes all behave identically to the prior implementation. The auto + missing SCAPI scopes path now caches the OCAPI choice across polls instead of re-probing AM on every call. 13 dispatcher unit tests + 5 cascade unit tests + updated CLI command tests. 1746 SDK + 1220 CLI tests passing. --- .../src/commands/job/execution/delete.ts | 30 +- packages/b2c-cli/src/commands/job/log.ts | 35 ++- packages/b2c-cli/src/commands/job/run.ts | 65 +++-- packages/b2c-cli/src/commands/job/search.ts | 24 +- packages/b2c-cli/src/commands/job/wait.ts | 21 +- .../commands/job/execution/delete.test.ts | 67 ++--- .../b2c-cli/test/commands/job/log.test.ts | 114 ++++---- .../b2c-cli/test/commands/job/run.test.ts | 55 ++-- .../b2c-cli/test/commands/job/search.test.ts | 43 +-- .../b2c-cli/test/commands/job/wait.test.ts | 35 +-- packages/b2c-tooling-sdk/package.json | 11 + .../b2c-tooling-sdk/src/auth/oauth-jwt.ts | 76 ++++- packages/b2c-tooling-sdk/src/auth/oauth.ts | 126 +++++++- packages/b2c-tooling-sdk/src/auth/types.ts | 28 +- packages/b2c-tooling-sdk/src/cli/index.ts | 6 + .../src/cli/instance-command.ts | 45 ++- .../b2c-tooling-sdk/src/cli/job-command.ts | 106 +++---- packages/b2c-tooling-sdk/src/clients/index.ts | 2 +- .../b2c-tooling-sdk/src/clients/middleware.ts | 111 +++++++ .../src/clients/scapi-client-factory.ts | 50 +++- .../b2c-tooling-sdk/src/clients/scapi-jobs.ts | 17 +- .../b2c-tooling-sdk/src/compat/dispatcher.ts | 144 +++++++++ packages/b2c-tooling-sdk/src/compat/index.ts | 14 + packages/b2c-tooling-sdk/src/index.ts | 21 +- .../src/operations/jobs/index.ts | 93 ++---- .../src/operations/jobs/ocapi-backend.ts | 95 ------ .../src/operations/jobs/ocapi-mapping.ts | 71 +++++ .../src/operations/jobs/scapi-backend.ts | 250 ---------------- .../src/operations/jobs/scapi-ops.ts | 276 ++++++++++++++++++ .../src/operations/jobs/types.ts | 31 +- .../jobs/{backend.ts => wait-canonical.ts} | 53 ++-- .../b2c-tooling-sdk/test/auth/oauth.test.ts | 143 +++++++++ .../test/compat/dispatcher.test.ts | 121 ++++++++ 33 files changed, 1611 insertions(+), 768 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/compat/dispatcher.ts create mode 100644 packages/b2c-tooling-sdk/src/compat/index.ts delete mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts delete mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts rename packages/b2c-tooling-sdk/src/operations/jobs/{backend.ts => wait-canonical.ts} (52%) create mode 100644 packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts index 8bb350264..d1a959760 100644 --- a/packages/b2c-cli/src/commands/job/execution/delete.ts +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -5,7 +5,7 @@ */ import {Args} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {supportsDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {scapiDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../../i18n/index.js'; export default class JobExecutionDelete extends JobCommand { @@ -20,8 +20,13 @@ export default class JobExecutionDelete extends JobCommand { const {jobId, executionId} = this.args; const {failed} = this.flags; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job log`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; let execution: JobExecutionInfo; @@ -70,7 +78,10 @@ export default class JobLog extends JobCommand { executionId, }), ); - execution = await backend.getJobExecution(jobId, executionId); + execution = await dispatcher.run({ + scapi: (client) => scapiGetJobExecution(client, jobId, executionId, tenantId!), + ocapi: async () => mapOcapiExecution(await ocapiGetJobExecution(this.instance, jobId, executionId)), + }); } else { this.log( failed @@ -84,12 +95,17 @@ export default class JobLog extends JobCommand { }), ); - const results = await backend.searchJobExecutions({ + const searchOptions = { jobId, status: failed ? ['ERROR'] : undefined, count: 10, sortBy: 'start_time', - sortOrder: 'desc', + sortOrder: 'desc' as const, + }; + + const results = await dispatcher.run({ + scapi: (client) => scapiSearchJobExecutions(client, {...searchOptions, tenantId: tenantId!}), + ocapi: async () => mapOcapiSearchResult(await ocapiSearchJobExecutions(this.instance, searchOptions)), }); const match = results.hits.find((hit) => hit.isLogFileExisting); @@ -120,7 +136,12 @@ export default class JobLog extends JobCommand { }), ); - const log = await backend.getJobLog(execution); + if (!execution.logFilePath) { + this.error(t('commands.job.log.noLogFile', 'No log file exists for this execution')); + } + const webdavPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await this.instance.webdav.get(webdavPath); + const log = new TextDecoder().decode(content); if (!this.jsonEnabled()) { const useColor = !this.flags['no-color'] && process.stdout.isTTY; diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index fb3ae01bc..6ae78b1e4 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -4,13 +4,18 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {JobCommand, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; +import {JobCommand, BackendDispatcher, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; import { + executeJob as ocapiExecuteJob, + getJobExecution as ocapiGetJobExecution, + scapiExecuteJob, + scapiGetJobExecution, + mapOcapiExecution, waitForJobExecution, - JobExecutionError, - type JobsBackend, + CanonicalJobExecutionError, type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import type {ScapiJobsClient} from '@salesforce/b2c-tooling-sdk/clients'; import {t, withDocs} from '../../i18n/index.js'; export default class JobRun extends JobCommand { @@ -90,7 +95,6 @@ export default class JobRun extends JobCommand { 'show-log': showLog, } = this.flags; - // Safety evaluation — check rules for this job before executing. const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId}); if (jobEvaluation.action === 'block') { this.error(jobEvaluation.reason, {exit: 1}); @@ -99,14 +103,12 @@ export default class JobRun extends JobCommand { await this.confirmOrBlock(jobEvaluation); } - // Parse parameters or body const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job operations`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; - // Create lifecycle context const context = this.createContext('job:run', { jobId, parameters: rawBody ? undefined : parameters, @@ -115,7 +117,6 @@ export default class JobRun extends JobCommand { hostname: this.resolvedConfig.values.hostname, }); - // Run beforeOperation hooks - check for skip const beforeResult = await this.runBeforeHooks(context); if (beforeResult.skip) { this.log( @@ -137,17 +138,28 @@ export default class JobRun extends JobCommand { }), ); + const ocapiOptions = { + parameters: rawBody ? undefined : parameters, + body: rawBody, + waitForRunning: !noWaitRunning, + }; + let execution: JobExecutionInfo; try { - execution = await backend.executeJob(jobId, { - parameters: rawBody ? undefined : parameters, - body: rawBody, - waitForRunning: !noWaitRunning, + execution = await dispatcher.run({ + scapi: (client) => + scapiExecuteJob(client, jobId, { + ...ocapiOptions, + tenantId: tenantId!, + }), + ocapi: async () => mapOcapiExecution(await ocapiExecuteJob(this.instance, jobId, ocapiOptions)), }); } catch (error) { this.handleExecutionError(error, context); } + this.logger.debug(`Used ${dispatcher.active} backend for job execution`); + this.log( t('commands.job.run.started', 'Job started: {{executionId}} (status: {{status}})', { executionId: execution.id, @@ -155,12 +167,11 @@ export default class JobRun extends JobCommand { }), ); - // Wait for completion if requested if (wait) { execution = await this.waitForJobCompletion({ - backend, + dispatcher, jobId, - executionId: execution.id!, + executionId: execution.id, timeout, pollInterval, showLog, @@ -178,9 +189,6 @@ export default class JobRun extends JobCommand { } private handleExecutionError(error: unknown, context: B2COperationContext): never { - // Fire-and-forget: we're already on the error path and rethrow below; surface - // hook failures in the debug log so they aren't completely invisible, but - // don't shadow the original error. this.runAfterHooks(context, { success: false, error: error instanceof Error ? error : new Error(String(error)), @@ -200,16 +208,16 @@ export default class JobRun extends JobCommand { success: false, error: error instanceof Error ? error : new Error(String(error)), duration: Date.now() - context.startTime, - data: error instanceof JobExecutionError ? error.execution : undefined, + data: error instanceof CanonicalJobExecutionError ? error.execution : undefined, }); - if (error instanceof JobExecutionError) { + if (error instanceof CanonicalJobExecutionError) { if (showLog) { await this.showJobLog(error.execution); } this.error( t('commands.job.run.jobFailed', 'Job failed: {{status}}', { - status: error.execution.exit_status?.code || 'ERROR', + status: error.execution.exitStatus?.code || 'ERROR', }), ); } @@ -240,7 +248,7 @@ export default class JobRun extends JobCommand { } private async waitForJobCompletion(options: { - backend: JobsBackend; + dispatcher: BackendDispatcher; jobId: string; executionId: string; timeout: number | undefined; @@ -248,11 +256,18 @@ export default class JobRun extends JobCommand { showLog: boolean; context: B2COperationContext; }): Promise { - const {backend, jobId, executionId, timeout, pollInterval, showLog, context} = options; + const {dispatcher, jobId, executionId, timeout, pollInterval, showLog, context} = options; + const tenantId = this.resolvedConfig.values.tenantId; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); try { - const execution = await waitForJobExecution(backend, jobId, executionId, { + const getExecution = (jid: string, eid: string) => + dispatcher.run({ + scapi: (client) => scapiGetJobExecution(client, jid, eid, tenantId!), + ocapi: async () => mapOcapiExecution(await ocapiGetJobExecution(this.instance, jid, eid)), + }); + + const execution = await waitForJobExecution(getExecution, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index 9a387f205..b3169bf81 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -11,7 +11,13 @@ import { selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {type JobExecutionInfo, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import { + searchJobExecutions as ocapiSearchJobExecutions, + scapiSearchJobExecutions, + mapOcapiSearchResult, + type JobExecutionInfo, + type JobExecutionSearchResults, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; const COLUMNS: Record> = { @@ -92,8 +98,8 @@ export default class JobSearch extends JobCommand { const {'job-id': jobId, status, count, start, 'sort-by': sortBy, 'sort-order': sortOrder} = this.flags; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job search`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; this.log( t('commands.job.search.searching', 'Searching job executions on {{hostname}}...', { @@ -101,13 +107,11 @@ export default class JobSearch extends JobCommand { }), ); - const results = await backend.searchJobExecutions({ - jobId, - status, - count, - start, - sortBy, - sortOrder: sortOrder as 'asc' | 'desc', + const searchOptions = {jobId, status, count, start, sortBy, sortOrder: sortOrder as 'asc' | 'desc'}; + + const results = await dispatcher.run({ + scapi: (client) => scapiSearchJobExecutions(client, {...searchOptions, tenantId: tenantId!}), + ocapi: async () => mapOcapiSearchResult(await ocapiSearchJobExecutions(this.instance, searchOptions)), }); if (this.jsonEnabled()) { diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index cdc9454ed..ba94dcf80 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -6,8 +6,11 @@ import {Args, Flags} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { + getJobExecution as ocapiGetJobExecution, + scapiGetJobExecution, + mapOcapiExecution, waitForJobExecution, - JobExecutionError, + CanonicalJobExecutionError, type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -59,8 +62,8 @@ export default class JobWait extends JobCommand { const {jobId, executionId} = this.args; const {timeout, 'poll-interval': pollInterval, 'show-log': showLog} = this.flags; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job wait`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; this.log( t('commands.job.wait.waiting', 'Waiting for job {{jobId}} execution {{executionId}}...', { @@ -70,7 +73,13 @@ export default class JobWait extends JobCommand { ); try { - const execution = await waitForJobExecution(backend, jobId, executionId, { + const getExecution = (jid: string, eid: string) => + dispatcher.run({ + scapi: (client) => scapiGetJobExecution(client, jid, eid, tenantId!), + ocapi: async () => mapOcapiExecution(await ocapiGetJobExecution(this.instance, jid, eid)), + }); + + const execution = await waitForJobExecution(getExecution, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { @@ -95,13 +104,13 @@ export default class JobWait extends JobCommand { return execution; } catch (error) { - if (error instanceof JobExecutionError) { + if (error instanceof CanonicalJobExecutionError) { if (showLog) { await this.showJobLog(error.execution); } this.error( t('commands.job.wait.jobFailed', 'Job failed: {{status}}', { - status: error.execution.exit_status?.code || 'ERROR', + status: error.execution.exitStatus?.code || 'ERROR', }), ); } diff --git a/packages/b2c-cli/test/commands/job/execution/delete.test.ts b/packages/b2c-cli/test/commands/job/execution/delete.test.ts index 7f41762c2..5599efe92 100644 --- a/packages/b2c-cli/test/commands/job/execution/delete.test.ts +++ b/packages/b2c-cli/test/commands/job/execution/delete.test.ts @@ -21,52 +21,49 @@ describe('job execution delete', () => { return createTestCommand(JobExecutionDelete, hooks.getConfig(), flags, args); } - function createScapiBackend() { - // SCAPI backend implements DeletableJobsBackend (has deleteJobExecution) - return { - name: 'scapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - - function createOcapiBackend() { - // OCAPI backend does NOT implement deleteJobExecution - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - - function stubCommon(command: any, backend: object) { + function stubCommon( + command: any, + opts: {client?: unknown; tenantId?: string; preference?: 'auto' | 'ocapi' | 'scapi'} = {}, + ) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: opts.tenantId}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + sinon.stub(command, 'apiBackendPreference').get(() => opts.preference ?? 'auto'); + sinon.stub(command, 'buildScapiJobsClient').returns(opts.client); } - it('deletes a job execution when SCAPI backend is active', async () => { + it('calls scapiDeleteJobExecution when SCAPI is configured', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command, createScapiBackend()) as ReturnType; - backend.deleteJobExecution.resolves(); + // Provide a fake client that responds with no error from the openapi-fetch shape. + const fakeClient = { + DELETE: sinon.stub().resolves({error: undefined, response: {status: 204}}), + }; + stubCommon(command, {client: fakeClient, tenantId: 'tenant_test'}); await runSilent(() => command.run()); - expect(backend.deleteJobExecution.calledOnce).to.equal(true); - expect(backend.deleteJobExecution.getCall(0).args[0]).to.equal('my-job'); - expect(backend.deleteJobExecution.getCall(0).args[1]).to.equal('exec-1'); + expect(fakeClient.DELETE.calledOnce).to.equal(true); + const call = fakeClient.DELETE.getCall(0); + expect(call.args[0]).to.match(/executions\/\{executionId\}$/); + expect(call.args[1].params.path.jobId).to.equal('my-job'); + expect(call.args[1].params.path.executionId).to.equal('exec-1'); + }); + + it('errors when --api-backend ocapi is set', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + stubCommon(command, {client: {}, tenantId: 'tenant_test', preference: 'ocapi'}); + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/SCAPI/i); + } }); - it('errors with a clear message when OCAPI backend is active (no delete capability)', async () => { + it('errors when SCAPI is not configured', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - stubCommon(command, createOcapiBackend()); + stubCommon(command, {client: undefined}); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/job/log.test.ts b/packages/b2c-cli/test/commands/job/log.test.ts index 3c9aca707..74101a22f 100644 --- a/packages/b2c-cli/test/commands/job/log.test.ts +++ b/packages/b2c-cli/test/commands/job/log.test.ts @@ -10,6 +10,18 @@ import sinon from 'sinon'; import JobLog from '../../../src/commands/job/log.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js'; +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job log', () => { const hooks = createIsolatedConfigHooks(); @@ -21,86 +33,85 @@ describe('job log', () => { return createTestCommand(JobLog, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); + sinon.stub(command, 'instance').get(() => ({ + config: {hostname: 'example.com'}, + webdav: {get: sinon.stub().resolves(new TextEncoder().encode('log content here'))}, + })); sinon.stub(command, 'log').returns(void 0); - const backend = createMockBackend(); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('fetches log for a specific execution', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; - backend.getJobExecution.resolves(execution); - backend.getJobLog.resolves('log content here'); + const execution = { + id: 'exec-1', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-1.log', + exitStatus: {code: 'OK'}, + }; + runner.resolves(execution); const result = (await runSilent(() => command.run())) as {execution: unknown; log: string}; - expect(backend.getJobExecution.calledOnce).to.equal(true); - expect(backend.getJobExecution.getCall(0).args[0]).to.equal('my-job'); - expect(backend.getJobExecution.getCall(0).args[1]).to.equal('exec-1'); - expect(backend.getJobLog.calledOnce).to.equal(true); + expect(runner.calledOnce).to.equal(true); expect(result.log).to.equal('log content here'); expect(result.execution).to.equal(execution); }); it('searches for most recent execution with log', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); const execWithoutLog = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; - const execWithLog = {id: 'exec-2', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; - backend.searchJobExecutions.resolves({total: 2, hits: [execWithoutLog, execWithLog]}); - backend.getJobLog.resolves('log from exec-2'); + const execWithLog = { + id: 'exec-2', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-2.log', + exitStatus: {code: 'OK'}, + }; + runner.resolves({total: 2, hits: [execWithoutLog, execWithLog]}); const result = (await runSilent(() => command.run())) as {log: string}; - expect(backend.searchJobExecutions.calledOnce).to.equal(true); - expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({jobId: 'my-job'}); - expect(backend.getJobLog.calledOnce).to.equal(true); - expect(backend.getJobLog.getCall(0).args[0]).to.equal(execWithLog); - expect(result.log).to.equal('log from exec-2'); + expect(runner.calledOnce).to.equal(true); + expect(result.log).to.equal('log content here'); }); it('searches for most recent failed execution with --failed', async () => { const command: any = await createCommand({failed: true}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-3', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'ERROR'}}; - backend.searchJobExecutions.resolves({total: 1, hits: [execution]}); - backend.getJobLog.resolves('error log'); + const execution = { + id: 'exec-3', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-3.log', + exitStatus: {code: 'ERROR'}, + }; + runner.resolves({total: 1, hits: [execution]}); const result = (await runSilent(() => command.run())) as {log: string}; - expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({status: ['ERROR']}); - expect(result.log).to.equal('error log'); + expect(result.log).to.equal('log content here'); }); it('errors when specific execution has no log file', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); - const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; - backend.getJobExecution.resolves(execution); + runner.resolves({id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}); try { await command.run(); @@ -112,9 +123,9 @@ describe('job log', () => { it('errors when no executions with log found', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); - backend.searchJobExecutions.resolves({total: 0, hits: []}); + runner.resolves({total: 0, hits: []}); try { await command.run(); @@ -126,16 +137,21 @@ describe('job log', () => { it('returns structured result in json mode', async () => { const command: any = await createCommand({json: true}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; - backend.getJobExecution.resolves(execution); - backend.getJobLog.resolves('json log content'); + const execution = { + id: 'exec-1', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-1.log', + exitStatus: {code: 'OK'}, + }; + runner.resolves(execution); const result = await command.run(); expect(result).to.have.property('execution'); - expect(result).to.have.property('log', 'json log content'); + expect(result).to.have.property('log', 'log content here'); }); }); diff --git a/packages/b2c-cli/test/commands/job/run.test.ts b/packages/b2c-cli/test/commands/job/run.test.ts index 0734c82a3..b55fc9279 100644 --- a/packages/b2c-cli/test/commands/job/run.test.ts +++ b/packages/b2c-cli/test/commands/job/run.test.ts @@ -10,6 +10,25 @@ import sinon from 'sinon'; import JobRun from '../../../src/commands/job/run.js'; import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +/** + * The dispatcher's branch-routing behavior is unit-tested in + * b2c-tooling-sdk/test/compat/dispatcher.test.ts. Command tests stub + * `createJobsDispatcher` to return a fake whose `run()` returns a + * pre-programmed value — we test command-level orchestration without + * exercising the dispatcher internals. + */ +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job run', () => { const hooks = createIsolatedConfigHooks(); @@ -21,20 +40,9 @@ describe('job run', () => { return createTestCommand(JobRun, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ @@ -42,9 +50,9 @@ describe('job run', () => { metadata, startTime: Date.now(), })); - const backend = createMockBackend(); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('errors on invalid -P param format', async () => { @@ -65,17 +73,16 @@ describe('job run', () => { it('executes without waiting when --wait is false', async () => { const command: any = await createCommand({param: ['A=1'], json: true}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); + runner.resolves({id: 'e1', jobId: 'my-job', executionStatus: 'running'}); const result = await command.run(); - expect(backend.executeJob.calledOnce).to.equal(true); - expect(backend.executeJob.getCall(0).args[0]).to.equal('my-job'); + expect(runner.calledOnce).to.equal(true); expect(result.id).to.equal('e1'); }); @@ -84,21 +91,23 @@ describe('job run', () => { {wait: true, timeout: 10, 'poll-interval': 1, json: true}, {jobId: 'my-job'}, ); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); - backend.getJobExecution.resolves({ + // First run() call is executeJob; subsequent are getJobExecution polls. + runner.onFirstCall().resolves({id: 'e1', jobId: 'my-job', executionStatus: 'running'}); + runner.onSecondCall().resolves({ id: 'e1', + jobId: 'my-job', executionStatus: 'finished', exitStatus: {code: 'OK', status: 'ok'}, }); const result = await command.run(); - expect(backend.getJobExecution.called).to.equal(true); + expect(runner.callCount).to.be.greaterThanOrEqual(2); expect(result.executionStatus).to.equal('finished'); }); diff --git a/packages/b2c-cli/test/commands/job/search.test.ts b/packages/b2c-cli/test/commands/job/search.test.ts index cbbbfa318..9f5dc7cc7 100644 --- a/packages/b2c-cli/test/commands/job/search.test.ts +++ b/packages/b2c-cli/test/commands/job/search.test.ts @@ -11,6 +11,18 @@ import sinon from 'sinon'; import JobSearch from '../../../src/commands/job/search.js'; import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job search', () => { const hooks = createIsolatedConfigHooks(); @@ -22,54 +34,43 @@ describe('job search', () => { return createTestCommand(JobSearch, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); - const backend = createMockBackend(); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('returns results in json mode', async () => { const command: any = await createCommand({json: true}, {}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - backend.searchJobExecutions.resolves({total: 1, hits: [{id: 'e1'}]}); + runner.resolves({total: 1, hits: [{id: 'e1'}]}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); - expect(backend.searchJobExecutions.calledOnce).to.equal(true); + expect(runner.calledOnce).to.equal(true); expect(uxStub.called).to.equal(false); expect(result.total).to.equal(1); }); it('prints no results in non-json mode', async () => { const command: any = await createCommand({}, {}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - backend.searchJobExecutions.resolves({total: 0, hits: []}); + runner.resolves({total: 0, hits: []}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); expect(result.total).to.equal(0); expect(uxStub.calledOnce).to.equal(true); - expect(backend.searchJobExecutions.calledOnce).to.equal(true); + expect(runner.calledOnce).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/job/wait.test.ts b/packages/b2c-cli/test/commands/job/wait.test.ts index eae257426..46162b990 100644 --- a/packages/b2c-cli/test/commands/job/wait.test.ts +++ b/packages/b2c-cli/test/commands/job/wait.test.ts @@ -10,6 +10,18 @@ import sinon from 'sinon'; import JobWait from '../../../src/commands/job/wait.js'; import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job wait', () => { const hooks = createIsolatedConfigHooks(); @@ -21,38 +33,27 @@ describe('job wait', () => { return createTestCommand(JobWait, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - - it('waits using backend polling', async () => { + it('waits using dispatcher polling', async () => { const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'jsonEnabled').returns(true); - const backend = createMockBackend(); - backend.getJobExecution.resolves({ + const {runner, dispatcher} = makeDispatcherFake(); + runner.resolves({ id: 'e1', jobId: 'my-job', executionStatus: 'finished', exitStatus: {code: 'OK', status: 'ok'}, }); - sinon.stub(command, 'createJobsBackend').returns(backend); + sinon.stub(command, 'createJobsDispatcher').returns(dispatcher); const result = await command.run(); - expect(backend.getJobExecution.called).to.equal(true); + expect(runner.called).to.equal(true); expect(result.id).to.equal('e1'); }); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 7e1314566..71a81ce90 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -255,6 +255,17 @@ "default": "./dist/cjs/clients/index.js" } }, + "./compat": { + "development": "./src/compat/index.ts", + "import": { + "types": "./dist/esm/compat/index.d.ts", + "default": "./dist/esm/compat/index.js" + }, + "require": { + "types": "./dist/cjs/compat/index.d.ts", + "default": "./dist/cjs/compat/index.js" + } + }, "./logging": { "development": "./src/logging/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts index 6c3a2e19a..93127cda2 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts @@ -20,6 +20,7 @@ import { getCachedOAuthToken, setCachedOAuthToken, invalidateCachedOAuthToken, + findCachedTokenSatisfying, decodeJWT, } from './oauth.js'; import {globalAuthMiddlewareRegistry, applyAuthRequestMiddleware, applyAuthResponseMiddleware} from './middleware.js'; @@ -252,6 +253,49 @@ export class JwtOAuthStrategy implements AuthStrategy { }); } + /** + * Resolves a scope cascade. See {@link AuthStrategy.getAccessTokenForCascade}. + * Mirrors `OAuthStrategy.getAccessTokenForCascade` for the JWT bearer flow. + */ + async getAccessTokenForCascade(candidates: string[][]): Promise { + const baseScopes = this.config.scopes ?? []; + const identityPrefix = `${this.config.accountManagerHost}:${this.config.clientId}:jwt:`; + + for (const candidate of candidates) { + const required = [...new Set([...baseScopes, ...candidate])]; + const cached = findCachedTokenSatisfying(identityPrefix, required); + if (cached) { + this.logger.debug( + {required, cachedScopes: cached.scopes}, + `[JwtOAuthStrategy] Cache hit: cached token satisfies cascade candidate ${JSON.stringify(candidate)}`, + ); + return cached.accessToken; + } + } + + let lastError: unknown; + for (const candidate of candidates) { + const merged = [...new Set([...baseScopes, ...candidate])]; + try { + this.logger.debug({scopes: merged}, `[JwtOAuthStrategy] Cascade trying scopes ${JSON.stringify(candidate)}`); + const tokenResponse = await this.requestNewTokenForScopes(merged); + return tokenResponse.accessToken; + } catch (error) { + if (error instanceof Error && error.message.includes('invalid_scope')) { + this.logger.debug( + {scopes: merged}, + `[JwtOAuthStrategy] Cascade candidate ${JSON.stringify(candidate)} rejected (invalid_scope), trying next`, + ); + lastError = error; + continue; + } + throw error; + } + } + + throw lastError ?? new Error('All scope cascade candidates failed'); + } + /** * Gets the full token response including expiration and scopes. * Useful for commands that need to display or return token metadata. @@ -293,10 +337,17 @@ export class JwtOAuthStrategy implements AuthStrategy { } /** - * Requests a new access token from Account Manager using JWT Bearer flow. - * Returns the full token response and caches it. + * Requests a new access token using the strategy's configured scopes. */ private async requestNewToken(): Promise { + return this.requestNewTokenForScopes(this.config.scopes); + } + + /** + * Requests a new access token from Account Manager using JWT Bearer flow, + * for the given scope set. Caches under a key derived from `scopes`. + */ + private async requestNewTokenForScopes(scopes: string[] | undefined): Promise { this.logger.trace('[JwtOAuthStrategy] Requesting new access token with JWT Bearer flow'); // Generate signed JWT @@ -313,15 +364,15 @@ export class JwtOAuthStrategy implements AuthStrategy { client_assertion: jwt, // ← JWT in body, not header }); - if (this.config.scopes && this.config.scopes.length > 0) { - params.append('scope', this.config.scopes.join(' ')); + if (scopes && scopes.length > 0) { + params.append('scope', scopes.join(' ')); } this.logger.trace( { tokenUrl, clientId: this.config.clientId, - scopes: this.config.scopes, + scopes, }, '[JwtOAuthStrategy] Sending JWT Bearer token request', ); @@ -378,24 +429,27 @@ export class JwtOAuthStrategy implements AuthStrategy { const expiresInSeconds = data.expires_in ?? 1800; const expiryDate = new Date(Date.now() + expiresInSeconds * 1000); - // Decode JWT to extract scopes (scope can be string or array) + // Decode JWT to extract scopes (scope can be string or array). Fall back + // to the requested scopes if the token doesn't carry a `scope` claim, so + // cache satisfies-checks still work for cascade resolution. const decoded = decodeJWT(data.access_token); const scope = decoded.payload.scope as string | string[] | undefined; - const scopes = Array.isArray(scope) ? scope : scope?.split(' ') || this.config.scopes || []; + const tokenScopes = Array.isArray(scope) ? scope : scope?.split(' ') || scopes || []; - // Build and cache token response const tokenResponse: AccessTokenResponse = { accessToken: data.access_token, expires: expiryDate, - scopes, + scopes: tokenScopes, }; - setCachedOAuthToken(this.cacheKey, tokenResponse); + // Cache under a key derived from the requested scopes (matches OAuthStrategy). + const cacheKey = getOAuthCacheKey(this.config.clientId, 'jwt', this.config.accountManagerHost, scopes); + setCachedOAuthToken(cacheKey, tokenResponse); this.logger.trace( { expiresIn: expiresInSeconds, expiresAt: expiryDate.toISOString(), - scopes, + scopes: tokenScopes, }, '[JwtOAuthStrategy] Access token obtained successfully', ); diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index 5f3510e3f..3e3543d0a 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -89,6 +89,38 @@ export function setCachedOAuthToken(cacheKey: string, tokenResponse: AccessToken ACCESS_TOKEN_CACHE.set(cacheKey, tokenResponse); } +/** + * Scans the cache for a non-expired token (matching the supplied identity + * prefix) whose scopes are a superset of `requiredScopes`. + * + * Used by cascade resolution: a cached token granted with broader scopes + * (e.g. `sfcc.jobs.rw`) automatically satisfies a later request that needs + * a narrower scope (e.g. `sfcc.jobs`), with no extra AM round trip. + * + * The identity prefix is `${accountManagerHost}:${clientId}:${method}:` — + * the same prefix `getOAuthCacheKey` produces. We iterate cache values that + * share this prefix; in practice 1-3 entries per identity. + * + * @returns The first satisfying token, or undefined if none. + */ +export function findCachedTokenSatisfying( + identityPrefix: string, + requiredScopes: string[], +): AccessTokenResponse | undefined { + const now = new Date(); + for (const [key, entry] of ACCESS_TOKEN_CACHE) { + if (!key.startsWith(identityPrefix)) continue; + if (now.getTime() > entry.expires.getTime()) { + ACCESS_TOKEN_CACHE.delete(key); + continue; + } + if (requiredScopes.every((s) => entry.scopes.includes(s))) { + return entry; + } + } + return undefined; +} + /** * Invalidates a cached OAuth token. * @@ -192,6 +224,63 @@ export class OAuthStrategy implements AuthStrategy { }); } + /** + * Resolves a scope cascade. See {@link AuthStrategy.getAccessTokenForCascade}. + * + * Each candidate is merged with this strategy's base scopes (e.g. tenant + * scope baked in via {@link withAdditionalScopes}) before being sent to AM. + * + * Cache strategy: + * 1. For each candidate, scan the cache for any non-expired token whose + * scopes ⊇ (base ∪ candidate). First hit wins, no AM call. + * 2. On miss, request each candidate from AM in order. Cache successes. + * 3. On `invalid_scope` for a candidate, continue to the next candidate. + * On any other error, rethrow. + */ + async getAccessTokenForCascade(candidates: string[][]): Promise { + const logger = getLogger(); + const baseScopes = this.config.scopes ?? []; + const identityPrefix = `${this.accountManagerHost}:${this.config.clientId}:client-credentials:`; + + // Pass 1: cache scan. Return the first cached token that satisfies any + // candidate. + for (const candidate of candidates) { + const required = [...new Set([...baseScopes, ...candidate])]; + const cached = findCachedTokenSatisfying(identityPrefix, required); + if (cached) { + logger.debug( + {required, cachedScopes: cached.scopes}, + `[OAuthStrategy] Cache hit: cached token satisfies cascade candidate ${JSON.stringify(candidate)}`, + ); + return cached.accessToken; + } + } + + // Pass 2: try each candidate against AM in order. + let lastError: unknown; + for (const candidate of candidates) { + const merged = [...new Set([...baseScopes, ...candidate])]; + try { + logger.debug({scopes: merged}, `[OAuthStrategy] Cascade trying scopes ${JSON.stringify(candidate)}`); + const tokenResponse = await this.refreshTokenForScopes(merged); + return tokenResponse.accessToken; + } catch (error) { + if (error instanceof Error && error.message.includes('invalid_scope')) { + logger.debug( + {scopes: merged}, + `[OAuthStrategy] Cascade candidate ${JSON.stringify(candidate)} rejected (invalid_scope), trying next`, + ); + lastError = error; + continue; + } + throw error; + } + } + + // All candidates exhausted. Rethrow the last invalid_scope. + throw lastError ?? new Error('All scope cascade candidates failed'); + } + /** * Gets an access token, using cache if valid */ @@ -214,28 +303,40 @@ export class OAuthStrategy implements AuthStrategy { * when many requests trigger refresh at once. */ private refreshTokenSingleflight(): Promise { - const existing = PENDING_TOKEN_REQUESTS.get(this.cacheKey); + return this.refreshTokenForScopes(this.config.scopes); + } + + /** + * Variant of {@link refreshTokenSingleflight} that requests a specific scope + * set rather than the strategy's configured scopes. Used by cascade + * resolution. Caches under a key derived from the requested scopes. + */ + private refreshTokenForScopes(scopes: string[] | undefined): Promise { + const cacheKey = getOAuthCacheKey(this.config.clientId, 'client-credentials', this.accountManagerHost, scopes); + const existing = PENDING_TOKEN_REQUESTS.get(cacheKey); if (existing) { getLogger().debug('[OAuthStrategy] Joining in-flight token request'); return existing; } const pending = (async () => { - getLogger().debug('[OAuthStrategy] Requesting new access token'); - const tokenResponse = await this.clientCredentialsGrant(); - setCachedOAuthToken(this.cacheKey, tokenResponse); + getLogger().debug({scopes}, '[OAuthStrategy] Requesting new access token'); + const tokenResponse = await this.clientCredentialsGrant(scopes); + setCachedOAuthToken(cacheKey, tokenResponse); return tokenResponse; })().finally(() => { - PENDING_TOKEN_REQUESTS.delete(this.cacheKey); + PENDING_TOKEN_REQUESTS.delete(cacheKey); }); - PENDING_TOKEN_REQUESTS.set(this.cacheKey, pending); + PENDING_TOKEN_REQUESTS.set(cacheKey, pending); return pending; } /** - * Performs client credentials grant flow + * Performs client credentials grant flow with the given scope set. + * Defaults to the strategy's configured scopes when `scopes` is omitted. */ - private async clientCredentialsGrant(): Promise { + private async clientCredentialsGrant(scopeOverride?: string[]): Promise { const logger = getLogger(); + const requestedScopes = scopeOverride ?? this.config.scopes; const url = `https://${this.accountManagerHost}/dwsso/oauth2/access_token`; const method = 'POST'; @@ -243,8 +344,8 @@ export class OAuthStrategy implements AuthStrategy { grant_type: 'client_credentials', }); - if (this.config.scopes && this.config.scopes.length > 0) { - params.append('scope', this.config.scopes.join(' ')); + if (requestedScopes && requestedScopes.length > 0) { + params.append('scope', requestedScopes.join(' ')); } const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64'); @@ -319,7 +420,10 @@ export class OAuthStrategy implements AuthStrategy { const now = new Date(); const expiration = new Date(now.getTime() + data.expires_in * 1000); - const scopes = data.scope?.split(' ') ?? []; + // AM normally echoes back the granted scopes; some configurations omit + // the `scope` claim in the token response. Fall back to what we + // requested so cache satisfies-checks (cascade resolution) still work. + const scopes = data.scope?.split(' ') ?? requestedScopes ?? []; return { accessToken: data.access_token, diff --git a/packages/b2c-tooling-sdk/src/auth/types.ts b/packages/b2c-tooling-sdk/src/auth/types.ts index 835cc0e12..434f33c06 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -35,7 +35,7 @@ export interface AuthStrategy { /** * Optional: Returns a copy of this strategy with the given scopes merged into * its requested scope set. SCAPI client factories use this to ensure the - * domain scope (e.g., `sfcc.jobs.rw`) and the tenant scope are present. + * tenant scope is present on every token request. * * Implemented by `OAuthStrategy` and `JwtOAuthStrategy`. Strategies that * obtain tokens by other means (basic, api-key, implicit-via-stored-session) @@ -43,6 +43,32 @@ export interface AuthStrategy { * established at construction time." */ withAdditionalScopes?(additionalScopes: string[]): AuthStrategy; + + /** + * Optional: Resolves a scope cascade by trying each candidate scope set + * in order and returning the first that AM accepts. + * + * Implementations should: + * 1. Return any cached token whose scopes ⊇ a candidate (no AM call). + * 2. Otherwise, call AM with each candidate in order until one survives; + * cache the result keyed by what was requested. + * 3. Throw the last `invalid_scope` error if all candidates fail. + * + * Implementations MUST add any base scopes (e.g. tenant scope baked in + * via {@link withAdditionalScopes}) to each candidate before sending it + * to AM. + * + * Used by the SCAPI auth middleware to pick the right scope tier (rw vs + * ro) per operation. Strategies without OAuth-style scope grants (basic, + * api-key) should leave this unset; the middleware falls through to + * {@link getAuthorizationHeader} in that case. + * + * @param candidates - Outer array is cascade order; inner arrays are the + * scopes for each token request attempt. e.g. + * `[['sfcc.jobs.rw'], ['sfcc.jobs']]`. + * @returns The access token (Bearer value, no `Bearer ` prefix). + */ + getAccessTokenForCascade?(candidates: string[][]): Promise; } /** diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index b60e61aec..2e3ef3417 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -90,6 +90,12 @@ * @module cli */ +// Backend dispatcher — re-exported from `compat/` for CLI ergonomics. The +// canonical home is `@salesforce/b2c-tooling-sdk/compat`; CLI commands and +// other interfaces (VSCode, MCP) can import from either location. +export {BackendDispatcher} from '../compat/dispatcher.js'; +export type {ApiBackendPreference, ResolvedBackend, DispatchBranches} from '../compat/dispatcher.js'; + // Base command classes export {BaseCommand} from './base-command.js'; export type {Flags, Args} from './base-command.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index e3c8a4679..4a623ebf0 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -19,6 +19,7 @@ import { type B2COperationLifecycleHookOptions, type B2COperationLifecycleHookResult, } from './lifecycle.js'; +import {BackendDispatcher, type ApiBackendPreference} from '../compat/dispatcher.js'; /** * Base command for B2C instance operations. @@ -191,22 +192,46 @@ export abstract class InstanceCommand extends OAuthCom } /** - * Creates a SCAPI/OCAPI dual backend by passing the resolved configuration - * (apiBackend preference, instance, shortCode, tenantId, OAuth) to the - * supplied factory. Each backend domain (jobs, scripts, users, roles) - * exports its own factory; this helper supplies the same plumbing for all. + * Creates a per-command {@link BackendDispatcher} for routing operations + * to SCAPI or OCAPI based on the user's `--api-backend` preference. * - * @example - * ```ts - * const backend = this.createBackend(createJobsBackend); - * await backend.executeJob('my-job'); - * ``` + * Domain command bases (e.g., `JobCommand`) typically expose a thinner + * helper on top of this. SDK consumers don't use the dispatcher — they + * call SCAPI ops or OCAPI free functions directly. + * + * @param domainName - Used in fallback log lines, e.g. `'jobs'`. + * @param createScapi - Builds the SCAPI ops bundle. Should return + * `undefined` when SCAPI is not configured. + */ + protected createDispatcher(domainName: string, createScapi: () => S | undefined): BackendDispatcher { + return new BackendDispatcher(this.apiBackendPreference, createScapi, domainName); + } + + /** Resolved `--api-backend` preference (default `'auto'`). */ + protected get apiBackendPreference(): ApiBackendPreference { + return this.resolvedConfig.values.apiBackend ?? 'auto'; + } + + /** True iff shortCode + tenantId + OAuth credentials are all available. */ + protected hasScapiConfig(): boolean { + return Boolean( + this.resolvedConfig.values.shortCode && this.resolvedConfig.values.tenantId && this.hasOAuthCredentials(), + ); + } + + /** + * Legacy dual-backend factory bridge for domains (scripts, users, roles) + * that have not yet migrated to the dispatcher pattern. Will be removed + * once those domains move to SCAPI ops + dispatcher branches in CLI. + * + * @deprecated Use {@link createDispatcher} and call SCAPI ops / OCAPI + * functions directly from CLI commands. */ protected createBackend( factory: (config: import('../clients/dual-backend-factory.js').DualBackendConfig) => T, ): T { return factory({ - preference: this.resolvedConfig.values.apiBackend ?? 'auto', + preference: this.apiBackendPreference, instance: this.instance, shortCode: this.resolvedConfig.values.shortCode, tenantId: this.resolvedConfig.values.tenantId, diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index ea9753d1f..936fc3d83 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -5,45 +5,70 @@ */ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; -import {getJobLog, getJobErrorMessage, type JobExecution} from '../operations/jobs/index.js'; -import {createJobsBackend, type JobsBackend, type JobExecutionInfo} from '../operations/jobs/index.js'; +import {BackendDispatcher} from '../compat/dispatcher.js'; +import {createScapiJobsClient, type ScapiJobsClient} from '../clients/scapi-jobs.js'; +import {mapOcapiExecution, type JobExecution, type JobExecutionInfo} from '../operations/jobs/index.js'; import {t} from '../i18n/index.js'; /** * Base command for job operations. * - * Extends InstanceCommand with job-specific functionality like - * displaying job logs on failure and creating backend-aware job clients. + * Provides: + * - {@link createJobsDispatcher} for routing operations to SCAPI or OCAPI + * - {@link buildScapiJobsClient} for SCAPI-only commands (e.g. delete) that + * don't need the dispatcher's auto-fallback + * - {@link showJobLog} for retrieving and printing canonical job logs on failure * * @example + * ```ts + * import {scapiExecuteJob, mapOcapiExecution, executeJob as ocapiExecuteJob} from + * '@salesforce/b2c-tooling-sdk/operations/jobs'; + * * export default class MyJobCommand extends JobCommand { - * async run(): Promise { - * const backend = this.createJobsBackend(); - * const execution = await backend.executeJob('my-job'); + * async run() { + * const dispatcher = this.createJobsDispatcher(); + * const exec = await dispatcher.run({ + * scapi: (client) => scapiExecuteJob(client, 'my-job', {tenantId: this.resolvedConfig.values.tenantId!}), + * ocapi: async () => mapOcapiExecution(await ocapiExecuteJob(this.instance, 'my-job')), + * }); * } * } + * ``` */ export abstract class JobCommand extends InstanceCommand { - protected createJobsBackend(): JobsBackend { - return this.createBackend(createJobsBackend); + protected createJobsDispatcher(): BackendDispatcher { + return this.createDispatcher('jobs', () => this.buildScapiJobsClient()); } /** - * Display a job's log file content and error message if available. - * Accepts both canonical JobExecutionInfo and legacy OCAPI JobExecution. - * Outputs to stderr since this is typically shown for failed jobs. + * Builds a SCAPI Jobs client, or `undefined` if SCAPI is not configured. + * Used both as the dispatcher's SCAPI factory and directly by SCAPI-only + * commands (e.g. `job execution delete`) that don't use the dispatcher. */ - protected async showJobLog(execution: JobExecutionInfo | JobExecution): Promise { - if (isCanonicalExecution(execution)) { - return this.showCanonicalJobLog(execution); - } - return this.showOcapiJobLog(execution); + protected buildScapiJobsClient(): ScapiJobsClient | undefined { + if (!this.hasScapiConfig()) return undefined; + return createScapiJobsClient( + { + shortCode: this.resolvedConfig.values.shortCode!, + tenantId: this.resolvedConfig.values.tenantId!, + }, + this.getOAuthStrategy(), + ); } - private async showCanonicalJobLog(execution: JobExecutionInfo): Promise { - const errorMessage = getCanonicalJobErrorMessage(execution); + /** + * Display a job execution's log file content and error message if available. + * + * Accepts either canonical {@link JobExecutionInfo} (preferred) or raw + * OCAPI {@link JobExecution} (from the legacy {@link JobExecutionError}). + * Raw OCAPI is mapped to canonical at the entry point so the rest of the + * function works on a single shape. + */ + protected async showJobLog(execution: JobExecutionInfo | JobExecution): Promise { + const canonical = isCanonical(execution) ? execution : mapOcapiExecution(execution); + const errorMessage = getCanonicalJobErrorMessage(canonical); - if (!execution.isLogFileExisting) { + if (!canonical.isLogFileExisting) { if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } @@ -51,9 +76,8 @@ export abstract class JobCommand extends InstanceComma } try { - const backend = this.createJobsBackend(); - const log = await backend.getJobLog(execution); - const logFileName = execution.logFilePath?.split('/').pop() ?? 'job.log'; + const log = await this.fetchCanonicalLog(canonical); + const logFileName = canonical.logFilePath?.split('/').pop() ?? 'job.log'; const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); this.logger.error({log, errorMessage}, `${header}\n${log}`); @@ -69,36 +93,20 @@ export abstract class JobCommand extends InstanceComma } } - private async showOcapiJobLog(execution: JobExecution): Promise { - const errorMessage = getJobErrorMessage(execution); - - if (!execution.is_log_file_existing) { - if (errorMessage) { - this.logger.error({errorMessage}, errorMessage); - } - return; - } - - try { - const log = await getJobLog(this.instance, execution); - const logFileName = execution.log_file_path?.split('/').pop() ?? 'job.log'; - - const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); - this.logger.error({log, errorMessage}, `${header}\n${log}`); - - if (errorMessage) { - this.logger.error(t('cli.job.errorMessage', 'Error: {{message}}', {message: errorMessage})); - } - } catch { - this.warn(t('cli.job.logFetchFailed', 'Could not retrieve job log')); - if (errorMessage) { - this.logger.error({errorMessage}, errorMessage); - } + private async fetchCanonicalLog(execution: JobExecutionInfo): Promise { + const logPath = execution.logFilePath; + if (!logPath) { + throw new Error('No log file path available'); } + // Both SCAPI and OCAPI return logFilePath under /Sites/LOGS/...; WebDAV + // base is /webdav/Sites, so the leading /Sites/ is stripped. + const webdavPath = logPath.replace(/^\/Sites\//, ''); + const content = await this.instance.webdav.get(webdavPath); + return new TextDecoder().decode(content); } } -function isCanonicalExecution(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { +function isCanonical(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { return 'executionStatus' in execution; } diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 9fcbf9181..8ea385476 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -326,7 +326,7 @@ export type { } from './granular-replications.js'; // SCAPI Jobs -export {createScapiJobsClient, SCAPI_JOBS_READ_SCOPES, SCAPI_JOBS_RW_SCOPES} from './scapi-jobs.js'; +export {createScapiJobsClient, SCAPI_JOBS_CASCADE} from './scapi-jobs.js'; export type { ScapiJobsClient, ScapiJobsClientConfig, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index b19db9c4d..9bb598fbf 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -130,6 +130,117 @@ export function createAuthMiddleware(auth: AuthStrategy): Middleware { }; } +/** + * Scope cascade for a SCAPI domain. The auth middleware picks `read` or + * `write` based on the per-operation `scopeMode` hint and walks the chosen + * cascade through the auth strategy until one candidate survives at AM. + * + * Each candidate is an array of scopes; the auth strategy adds any base + * scopes (e.g. tenant scope) automatically. + */ +export interface ScopeCascade { + /** Scope candidates to try for read operations, in order of preference. */ + read: string[][]; + /** Scope candidates to try for write operations, in order of preference. */ + write: string[][]; +} + +/** + * Internal request header read by {@link createScapiAuthMiddleware} to choose + * a cascade tier. Operations attach `'read'` or `'write'`; the header is + * stripped before the request leaves the middleware. + */ +export const SCOPE_MODE_HEADER = 'x-b2c-scope-mode'; + +/** + * Auth middleware for SCAPI clients with a configured {@link ScopeCascade}. + * + * Reads the {@link SCOPE_MODE_HEADER} from the request, picks the matching + * cascade, and asks the auth strategy to resolve it (cache-first, then AM + * with `invalid_scope` fallback). Strips the header before the request is + * sent. + * + * Falls back to `getAuthorizationHeader()` when: + * - the strategy doesn't implement `getAccessTokenForCascade` (e.g. + * stateful sessions, basic auth), or + * - the request didn't supply a `scopeMode` header. + * + * 401 retry behavior matches {@link createAuthMiddleware}: on a 401 after a + * prior success, invalidate the token and retry once. + */ +export function createScapiAuthMiddleware(auth: AuthStrategy, cascade: ScopeCascade): Middleware { + const logger = getLogger(); + let hasHadSuccess = false; + + async function authorize(request: Request): Promise { + const mode = request.headers.get(SCOPE_MODE_HEADER) as 'read' | 'write' | null; + request.headers.delete(SCOPE_MODE_HEADER); + + if (mode && auth.getAccessTokenForCascade) { + const candidates = cascade[mode]; + const token = await auth.getAccessTokenForCascade(candidates); + request.headers.set('Authorization', `Bearer ${token}`); + return; + } + + if (auth.getAuthorizationHeader) { + request.headers.set('Authorization', await auth.getAuthorizationHeader()); + } + } + + return { + async onRequest({request}) { + await authorize(request); + + // Clone body for potential 401 retry (body is single-use). + if (request.body && auth.invalidateToken) { + const cloned = request.clone(); + const bodyBuffer = await cloned.arrayBuffer(); + requestBodies.set(request, bodyBuffer); + } + + return request; + }, + + async onResponse({request, response}) { + if (response.status !== 401) { + hasHadSuccess = true; + } + + if (response.status === 401 && hasHadSuccess && !retriedRequests.has(request) && auth.invalidateToken) { + logger.debug('[ScapiAuthMiddleware] Received 401, invalidating token and retrying'); + retriedRequests.add(request); + auth.invalidateToken(); + + const newHeaders = new Headers(request.headers); + // The original request headers no longer include the scope-mode + // header (we stripped it on the way in). Synthesize a retry by + // re-running the cascade as a read attempt — writes that 401 likely + // need rw, which the cascade already prefers. + const retryRequest = new Request(request.url, { + method: request.method, + headers: newHeaders, + body: requestBodies.get(request) ?? undefined, + ...(requestBodies.get(request) ? {duplex: 'half'} : {}), + } as RequestInit); + + if (auth.getAccessTokenForCascade) { + const token = await auth.getAccessTokenForCascade(cascade.write); + retryRequest.headers.set('Authorization', `Bearer ${token}`); + } else if (auth.getAuthorizationHeader) { + retryRequest.headers.set('Authorization', await auth.getAuthorizationHeader()); + } + + const retryResponse = await fetch(retryRequest); + logger.debug({status: retryResponse.status}, `[ScapiAuthMiddleware] Retry response: ${retryResponse.status}`); + return retryResponse; + } + + return response; + }, + }; +} + /** * Configuration for rate limiting middleware. */ diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts index 73f272bf5..593383096 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts @@ -17,7 +17,13 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import { + createAuthMiddleware, + createLoggingMiddleware, + createRateLimitMiddleware, + createScapiAuthMiddleware, + type ScopeCascade, +} from './middleware.js'; import {globalMiddlewareRegistry, type HttpClientType, type MiddlewareRegistry} from './middleware-registry.js'; import {buildTenantScope} from './custom-apis.js'; import {withScopes} from './scapi-backend-utils.js'; @@ -33,10 +39,20 @@ export interface BuildScapiClientOptions { */ domainKey: HttpClientType; /** - * Default scopes to request when the caller doesn't override `config.scopes`. - * Typically the rw scope; the tenant scope is added automatically. + * Per-operation scope cascade. When supplied, operations attach a + * `x-b2c-scope-mode` header (`'read'` or `'write'`) and the auth + * middleware walks the matching cascade until AM accepts one. Mutually + * exclusive with {@link defaultScopes}; new domains should prefer this. */ - defaultScopes: string[]; + scopeCascade?: ScopeCascade; + /** + * Legacy: a single scope set requested for every operation. Used by + * domains that still rely on `ScopeTierManager` to switch clients + * between rw and ro. Mutually exclusive with {@link scopeCascade}. + * + * @deprecated Use {@link scopeCascade} for new domains. + */ + defaultScopes?: string[]; /** * Logging/rate-limit prefix, e.g. `'SCAPI-JOBS'`. Used in log lines. */ @@ -89,14 +105,32 @@ export function buildScapiClient

>( ): Client

{ const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + if (options.scopeCascade && options.defaultScopes) { + throw new Error(`[buildScapiClient] ${options.domainKey}: scopeCascade and defaultScopes are mutually exclusive.`); + } + if (!options.scopeCascade && !options.defaultScopes) { + throw new Error(`[buildScapiClient] ${options.domainKey}: must provide either scopeCascade or defaultScopes.`); + } + const client = createClient

({ baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/${options.pathSegment}`, }); - const requiredScopes = config.scopes ?? [...options.defaultScopes, buildTenantScope(config.tenantId)]; - const scopedAuth = withScopes(auth, requiredScopes); - - client.use(createAuthMiddleware(scopedAuth)); + if (options.scopeCascade) { + // Cascade-aware path: bake the tenant scope into the auth strategy as a + // "base scope" applied to every cascade attempt; the cascade itself only + // varies the domain (rw/ro) scope per operation. + const tenantBase = config.scopes ?? [buildTenantScope(config.tenantId)]; + const scopedAuth = withScopes(auth, tenantBase); + client.use(createScapiAuthMiddleware(scopedAuth, options.scopeCascade)); + } else { + // Legacy path: single static scope set requested for every operation. + // Used by domains still on ScopeTierManager (scripts/users/roles until + // they migrate to scopeCascade). + const requiredScopes = config.scopes ?? [...options.defaultScopes!, buildTenantScope(config.tenantId)]; + const scopedAuth = withScopes(auth, requiredScopes); + client.use(createAuthMiddleware(scopedAuth)); + } for (const middleware of registry.getMiddleware(options.domainKey)) { client.use(middleware); diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts index 5935f0444..735840e5a 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -8,6 +8,7 @@ import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-jobs.generated.js'; import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; +import type {ScopeCascade} from './middleware.js'; export {toOrganizationId, normalizeTenantId, buildTenantScope}; @@ -23,8 +24,18 @@ export type ExecutionStatus = components['schemas']['ExecutionStatus']; export type ExitStatus = components['schemas']['ExitStatus']; export type JobExecutionSearchResult = components['schemas']['JobExecutionSearchResult']; -export const SCAPI_JOBS_READ_SCOPES = ['sfcc.jobs']; -export const SCAPI_JOBS_RW_SCOPES = ['sfcc.jobs.rw']; +/** + * Per-operation scope cascade for SCAPI Jobs. + * + * Reads accept either rw or ro; writes require rw. The auth middleware tries + * each candidate against AM in order, caches the first that survives, and + * lets a broader cached token satisfy a later narrower request without an + * extra round trip. + */ +export const SCAPI_JOBS_CASCADE: ScopeCascade = { + read: [['sfcc.jobs.rw'], ['sfcc.jobs']], + write: [['sfcc.jobs.rw']], +}; export type ScapiJobsClientConfig = ScapiClientConfig; @@ -33,7 +44,7 @@ export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthS { pathSegment: 'operation/jobs/v1', domainKey: 'scapi-jobs', - defaultScopes: SCAPI_JOBS_RW_SCOPES, + scopeCascade: SCAPI_JOBS_CASCADE, logPrefix: 'SCAPI-JOBS', }, config, diff --git a/packages/b2c-tooling-sdk/src/compat/dispatcher.ts b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts new file mode 100644 index 000000000..474443bef --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts @@ -0,0 +1,144 @@ +/* + * 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 + */ +/** + * Optimistic SCAPI with cached OCAPI fallback for `apiBackend=auto`. + * + * ## Why this exists + * + * When `apiBackend=auto` and the user has no SCAPI scopes provisioned in + * Account Manager, every SCAPI call fails with `invalid_scope`. OAuth + * strategies cache successful tokens but **not** failed token requests, so + * without state, every call in a multi-call operation (e.g. `job run --wait` + * polls dozens of times) re-attempts SCAPI, re-hits Account Manager, re-fails, + * re-falls back to OCAPI. Slow, noisy, and surfaces the fallback log line + * repeatedly. + * + * The dispatcher caches the resolved backend for the lifetime of one logical + * operation: the first call probes SCAPI; the rest go straight to the + * resolved backend. Token caching handles the success path; the dispatcher + * handles the failure path. + * + * ## When to use + * + * Any interface (CLI, VSCode, MCP) that: + * - honors `apiBackend=auto`, **and** + * - performs multiple backend calls per user-initiated operation. + * + * ## When NOT to use + * + * - **Explicit `apiBackend=scapi` or `apiBackend=ocapi`.** The choice is + * known up-front; just branch once with `if/else`. + * - **Single-call operations.** A `try/catch` is shorter and clearer than + * constructing a dispatcher. + * - **SDK code that picks a backend deliberately.** Call `ScapiJobsOps` or + * the OCAPI free functions directly. No dispatcher needed. + * - **SCAPI-only operations** (no OCAPI equivalent). Just call the SCAPI + * ops; if the user forced `apiBackend=ocapi`, fail with a clear error in + * the command itself. The dispatcher's only job is fallback caching. + * + * ## Lifecycle + * + * This module lives in `compat/` because it exists to bridge the + * OCAPI → SCAPI transition. When OCAPI is removed: + * - delete every `ocapi: () => ...` branch from CLI/VSCode/MCP commands, + * - inline the SCAPI ops calls, + * - delete this directory. + * + * @module compat/dispatcher + */ +import {getLogger} from '../logging/logger.js'; +import {isInvalidScopeError, type ApiBackendPreference} from '../clients/scapi-backend-utils.js'; + +export type {ApiBackendPreference}; + +export type ResolvedBackend = 'scapi' | 'ocapi'; + +/** + * Branches passed to {@link BackendDispatcher.run}: one async function per + * backend. The SCAPI branch receives a non-null ops bundle (`S`) so callers + * don't need non-null assertions. The OCAPI branch receives no argument — + * it should call the OCAPI free functions directly with whatever instance + * handle the caller has. + */ +export interface DispatchBranches { + scapi: (ops: S) => Promise; + ocapi: () => Promise; +} + +/** + * Stateful router that runs SCAPI optimistically and falls back to OCAPI + * once on `invalid_scope`, caching the choice for the lifetime of the + * dispatcher. See the module-level docs for the full rationale. + * + * Construct one per logical operation (e.g. one per CLI command run, or + * one per VSCode user-initiated action). Sharing a dispatcher across + * unrelated operations is fine but not required. + * + * @typeParam S - The SCAPI ops bundle type (e.g., `ScapiJobsOps`). + */ +export class BackendDispatcher { + private resolved?: ResolvedBackend; + private opsCache?: S; + + /** + * @param preference - User preference (`auto` | `scapi` | `ocapi`). + * @param createScapi - Lazily builds the SCAPI ops bundle. Returns + * `undefined` when SCAPI is not configured (shortCode/tenantId/auth + * missing). + * @param domainName - Used in fallback log messages (e.g. `'jobs'`). + * + * @throws Error if `preference === 'scapi'` but `createScapi()` returns + * `undefined` — explicit SCAPI without configuration is a hard error. + */ + constructor( + preference: ApiBackendPreference, + createScapi: () => S | undefined, + private readonly domainName: string, + ) { + const probe = preference === 'ocapi' ? undefined : createScapi(); + const hasScapi = probe !== undefined; + if (probe !== undefined) this.opsCache = probe; + + if (preference === 'scapi' && !hasScapi) { + throw new Error( + `${domainName} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + + `Configure them in dw.json or set apiBackend to ocapi.`, + ); + } + if (preference === 'scapi') this.resolved = 'scapi'; + if (preference === 'ocapi') this.resolved = 'ocapi'; + if (preference === 'auto' && !hasScapi) this.resolved = 'ocapi'; + } + + /** Backend that has handled requests so far, or undefined if none yet. */ + get active(): ResolvedBackend | undefined { + return this.resolved; + } + + /** + * Runs the operation against the resolved backend. If unresolved (auto + * with SCAPI configured), tries SCAPI first; on `invalid_scope`, falls + * back to OCAPI and caches the choice. Other errors propagate without + * fallback. + */ + async run(branches: DispatchBranches): Promise { + if (this.resolved === 'ocapi') return branches.ocapi(); + if (this.resolved === 'scapi') return branches.scapi(this.opsCache!); + + try { + const result = await branches.scapi(this.opsCache!); + this.resolved = 'scapi'; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); + this.resolved = 'ocapi'; + return branches.ocapi(); + } + throw error; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/compat/index.ts b/packages/b2c-tooling-sdk/src/compat/index.ts new file mode 100644 index 000000000..f84b73c09 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/index.ts @@ -0,0 +1,14 @@ +/* + * 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 + */ +/** + * Transitional helpers that exist only to bridge the OCAPI → SCAPI + * migration. Everything in this module is scheduled for deletion when + * OCAPI is removed. + * + * @module compat + */ +export {BackendDispatcher} from './dispatcher.js'; +export type {ApiBackendPreference, ResolvedBackend, DispatchBranches} from './dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index c6e78b946..402109195 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -252,12 +252,14 @@ export { siteArchiveImport, siteArchiveExport, siteArchiveExportToPath, - // Backend abstraction - createJobsBackend, + // Canonical surface (SCAPI free functions + canonical helpers) + scapiExecuteJob, + scapiGetJobExecution, + scapiSearchJobExecutions, + scapiDeleteJobExecution, + scapiGetJobLog, waitForJobExecution, - OcapiJobsBackend, - ScapiJobsBackend, - supportsDeleteJobExecution, + CanonicalJobExecutionError, } from './operations/jobs/index.js'; export type { JobExecution, @@ -269,15 +271,12 @@ export type { WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, - // Backend abstraction types - JobsBackend, - DeletableJobsBackend, - JobsBackendConfig, - ApiBackendPreference, + // Canonical types JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults, - ScapiJobsBackendConfig, + ExecuteJobScapiOptions, + SearchJobExecutionsScapiOptions, SiteArchiveImportOptions, SiteArchiveImportResult, SiteArchiveExportOptions, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 9771a8b46..327614ae6 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -6,67 +6,16 @@ /** * Job execution operations for B2C Commerce. * - * This module provides functions for running and monitoring jobs - * on B2C Commerce instances via OCAPI. - * - * ## Core Job Functions - * - * - {@link executeJob} - Start a job execution - * - {@link getJobExecution} - Get the status of a job execution - * - {@link waitForJob} - Wait for a job to complete - * - {@link searchJobExecutions} - Search for job executions - * - {@link findRunningJobExecution} - Find a running execution - * - {@link getJobLog} - Retrieve job log file content - * - * ## System Jobs - * - * - {@link siteArchiveImport} - Import a site archive - * - {@link siteArchiveExport} - Export a site archive - * - {@link siteArchiveExportToPath} - Export and save to local path - * - * ## Usage - * - * ```typescript - * import { - * executeJob, - * waitForJob, - * searchJobExecutions, - * siteArchiveImport, - * siteArchiveExport, - * } from '@salesforce/b2c-tooling-sdk/operations/jobs'; - * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; - * - * const config = resolveConfig(); - * const instance = config.createB2CInstance(); - * - * // Run a custom job and wait for completion - * const execution = await executeJob(instance, 'my-job-id'); - * const result = await waitForJob(instance, 'my-job-id', execution.id); - * - * // Search for recent job executions - * const results = await searchJobExecutions(instance, { - * jobId: 'my-job-id', - * count: 10 - * }); - * - * // Import a site archive - * await siteArchiveImport(instance, './my-import-data'); - * - * // Export site data - * const exportResult = await siteArchiveExport(instance, { - * global_data: { meta_data: true } - * }); - * ``` - * - * ## Authentication - * - * Job operations require OAuth authentication with appropriate OCAPI permissions - * for the /jobs and /job_execution_search resources. + * SDK consumers should call SCAPI ops directly via the free functions + * exported from `./scapi-ops` (or, for legacy code, the OCAPI free + * functions exported from `./run`). The CLI's `BackendDispatcher` + * arbitrates between them based on the user's `apiBackend` preference; + * that policy lives in the CLI layer. * * @module operations/jobs */ -// Core job execution +// OCAPI ops (legacy — will be removed when OCAPI is deprecated) export { executeJob, getJobExecution, @@ -90,22 +39,22 @@ export type { JobExecutionSearchResult, } from './run.js'; -// Backend abstraction -export {createJobsBackend, waitForJobExecution} from './backend.js'; -export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; -export {OcapiJobsBackend} from './ocapi-backend.js'; -export {ScapiJobsBackend} from './scapi-backend.js'; -export type {ScapiJobsBackendConfig} from './scapi-backend.js'; -export {supportsDeleteJobExecution} from './types.js'; -export type { - JobsBackend, - DeletableJobsBackend, - JobExecutionInfo, - JobStepExecutionResult, - JobExecutionSearchResults, -} from './types.js'; +// SCAPI ops + canonical types (primary surface) +export { + executeJob as scapiExecuteJob, + getJobExecution as scapiGetJobExecution, + searchJobExecutions as scapiSearchJobExecutions, + deleteJobExecution as scapiDeleteJobExecution, + getJobLog as scapiGetJobLog, +} from './scapi-ops.js'; +export type {ExecuteJobScapiOptions, SearchJobExecutionsScapiOptions} from './scapi-ops.js'; +export type {JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; + +// Backend-agnostic helpers +export {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; +export {mapOcapiExecution, mapOcapiSearchResult} from './ocapi-mapping.js'; -// Site archive import/export +// Site archive import/export (uses OCAPI WebDAV path) export { siteArchiveImport, siteArchiveExport, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts deleted file mode 100644 index 3e86861a6..000000000 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 type {B2CInstance} from '../../instance/index.js'; -import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; -import type {ExecuteJobOptions, SearchJobExecutionsOptions, JobExecution, JobStepExecution} from './run.js'; -import { - executeJob as ocapiExecuteJob, - getJobExecution as ocapiGetJobExecution, - searchJobExecutions as ocapiSearchJobExecutions, - getJobLog as ocapiGetJobLog, -} from './run.js'; - -function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { - return { - id: step.id, - stepId: step.step_id, - executionStatus: step.execution_status, - exitStatus: step.exit_status - ? { - code: step.exit_status.code ?? '', - message: step.exit_status.message, - status: step.exit_status.status as 'ok' | 'error' | undefined, - } - : undefined, - duration: step.duration, - }; -} - -function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { - return { - id: ocapi.id ?? '', - jobId: ocapi.job_id ?? '', - executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionInfo['executionStatus'], - exitStatus: ocapi.exit_status - ? { - code: ocapi.exit_status.code ?? '', - message: ocapi.exit_status.message, - status: ocapi.exit_status.status as 'ok' | 'error' | undefined, - } - : undefined, - startTime: ocapi.start_time, - endTime: ocapi.end_time, - duration: ocapi.duration, - stepExecutions: ocapi.step_executions?.map(mapStepExecution), - logFilePath: ocapi.log_file_path, - isLogFileExisting: ocapi.is_log_file_existing, - parameters: ocapi.parameters, - _raw: ocapi, - }; -} - -export class OcapiJobsBackend implements JobsBackend { - readonly name = 'ocapi' as const; - - constructor(private instance: B2CInstance) {} - - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - const result = await ocapiExecuteJob(this.instance, jobId, options); - return mapOcapiExecution(result); - } - - async getJobExecution(jobId: string, executionId: string): Promise { - const result = await ocapiGetJobExecution(this.instance, jobId, executionId); - return mapOcapiExecution(result); - } - - async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - const result = await ocapiSearchJobExecutions(this.instance, options); - return { - total: result.total, - limit: result.count, - offset: result.start, - hits: result.hits.map(mapOcapiExecution), - }; - } - - async getJobLog(execution: JobExecutionInfo): Promise { - const ocapiExecution = execution._raw as JobExecution; - if (ocapiExecution) { - return ocapiGetJobLog(this.instance, ocapiExecution); - } - if (!execution.logFilePath) { - throw new Error('No log file path available'); - } - if (!execution.isLogFileExisting) { - throw new Error('Log file does not exist'); - } - const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); - const content = await this.instance.webdav.get(logPath); - return new TextDecoder().decode(content); - } -} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts new file mode 100644 index 000000000..616d7962c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts @@ -0,0 +1,71 @@ +/* + * 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 + */ +/** + * Mapping helpers from raw OCAPI shapes (snake_case) to canonical + * {@link JobExecutionInfo} (camelCase). + * + * These exist as transitional utilities to bridge the two API shapes. They + * will be deleted along with the OCAPI ops once OCAPI is removed. + * + * @module operations/jobs/ocapi-mapping + */ +import type {JobExecution, JobStepExecution} from './run.js'; +import type {JobExecutionInfo, JobExecutionSearchResults, JobStepExecutionResult} from './types.js'; + +function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.step_id, + executionStatus: step.execution_status, + exitStatus: step.exit_status + ? { + code: step.exit_status.code ?? '', + message: step.exit_status.message, + status: step.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + duration: step.duration, + }; +} + +/** Map a raw OCAPI {@link JobExecution} into the canonical shape. */ +export function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { + return { + id: ocapi.id ?? '', + jobId: ocapi.job_id ?? '', + executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionInfo['executionStatus'], + exitStatus: ocapi.exit_status + ? { + code: ocapi.exit_status.code ?? '', + message: ocapi.exit_status.message, + status: ocapi.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + startTime: ocapi.start_time, + endTime: ocapi.end_time, + duration: ocapi.duration, + stepExecutions: ocapi.step_executions?.map(mapStepExecution), + logFilePath: ocapi.log_file_path, + isLogFileExisting: ocapi.is_log_file_existing, + parameters: ocapi.parameters, + _raw: ocapi, + }; +} + +/** Map a raw OCAPI search result into the canonical shape. */ +export function mapOcapiSearchResult(result: { + total: number; + count: number; + start: number; + hits: JobExecution[]; +}): JobExecutionSearchResults { + return { + total: result.total, + limit: result.count, + offset: result.start, + hits: result.hits.map(mapOcapiExecution), + }; +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts deleted file mode 100644 index 804fd81da..000000000 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ /dev/null @@ -1,250 +0,0 @@ -/* - * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type { - DeletableJobsBackend, - JobExecutionInfo, - JobStepExecutionResult, - JobExecutionSearchResults, -} from './types.js'; -import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; -import { - createScapiJobsClient, - SCAPI_JOBS_RW_SCOPES, - SCAPI_JOBS_READ_SCOPES, - type ScapiJobsClient, - type ScapiJobsClientConfig, - type JobExecution as ScapiJobExecution, - type JobStepExecution as ScapiJobStepExecution, -} from '../../clients/scapi-jobs.js'; -import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; -import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; -import {getLogger} from '../../logging/logger.js'; - -function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { - return { - id: step.id, - stepId: step.stepId, - executionStatus: step.executionStatus, - exitStatus: step.exitStatus - ? { - code: step.exitStatus.code ?? '', - message: step.exitStatus.message, - status: step.exitStatus.status, - } - : undefined, - duration: step.duration, - }; -} - -function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionInfo { - return { - id: scapi.id, - jobId: scapi.jobId, - executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionInfo['executionStatus'], - exitStatus: scapi.exitStatus - ? { - code: scapi.exitStatus.code ?? '', - message: scapi.exitStatus.message, - status: scapi.exitStatus.status, - } - : undefined, - startTime: scapi.startTime, - endTime: scapi.endTime, - duration: scapi.duration, - stepExecutions: scapi.stepExecutions?.map(mapStepExecution), - logFilePath: scapi.logFilePath, - isLogFileExisting: scapi.isLogFileExisting, - parameters: scapi.parameters, - _raw: scapi, - }; -} - -export interface ScapiJobsBackendConfig { - shortCode: string; - tenantId: string; - auth: AuthStrategy; - instance: B2CInstance; -} - -export class ScapiJobsBackend implements DeletableJobsBackend { - readonly name = 'scapi' as const; - - private organizationId: string; - private scopeTier: ScopeTierManager; - - constructor(private config: ScapiJobsBackendConfig) { - this.organizationId = toOrganizationId(config.tenantId); - this.scopeTier = new ScopeTierManager({ - buildClient: (scopes) => this.buildClient(scopes), - rwScopes: SCAPI_JOBS_RW_SCOPES, - readScopes: SCAPI_JOBS_READ_SCOPES, - domainName: 'Jobs', - }); - } - - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - const client = this.scopeTier.getClientForWrite(); - const {parameters = [], body: rawBody} = options ?? {}; - - let requestBody: Record | undefined; - if (rawBody) { - requestBody = rawBody; - } else if (parameters.length > 0) { - requestBody = {parameters}; - } - - const {data, error, response} = await client.POST('/organizations/{organizationId}/jobs/{jobId}/executions', { - params: {path: {organizationId: this.organizationId, jobId}}, - body: requestBody as unknown as {parameters?: Array<{name: string; value: string}>}, - }); - - if (response.status === 400) { - const errorBody = error as unknown as {title?: string; type?: string; detail?: string; jobId?: string}; - if (errorBody?.type?.includes('job-already-running') || errorBody?.title === 'Job Already Running') { - if (options?.waitForRunning !== false) { - const logger = getLogger(); - logger.warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); - const running = await this.findRunningExecution(jobId); - if (running) { - await this.waitForTerminal(jobId, running.id); - } - return this.executeJob(jobId, {...options, waitForRunning: false}); - } - throw new Error(`Job ${jobId} is already running`); - } - } - - if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; - throw new Error(message); - } - - return mapScapiExecution(data); - } - - async getJobExecution(jobId: string, executionId: string): Promise { - const client = this.scopeTier.getClientForRead(); - - const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { - params: {path: {organizationId: this.organizationId, jobId, executionId}}, - }); - - if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; - throw new Error(message); - } - - return mapScapiExecution(data); - } - - async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - const client = this.scopeTier.getClientForRead(); - const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options ?? {}; - - const queries: unknown[] = []; - if (jobId) { - queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); - } - if (status) { - const statusValues = Array.isArray(status) ? status : [status]; - queries.push({termQuery: {fields: ['status'], operator: 'one_of', values: statusValues}}); - } - - let query: unknown; - if (queries.length === 0) { - query = {matchAllQuery: {}}; - } else if (queries.length === 1) { - query = queries[0]; - } else { - query = {boolQuery: {must: queries}}; - } - - const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { - params: {path: {organizationId: this.organizationId}}, - body: { - query, - limit: count, - offset: start, - sorts: [{field: sortBy, sortOrder}], - } as never, - }); - - if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? 'Failed to search job executions'; - throw new Error(message); - } - - const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; - return { - total: result.total ?? 0, - limit: result.limit ?? count, - offset: result.offset ?? start, - hits: (result.hits ?? []).map(mapScapiExecution), - }; - } - - async deleteJobExecution(jobId: string, executionId: string): Promise { - const client = this.scopeTier.getClientForWrite(); - - const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { - params: {path: {organizationId: this.organizationId, jobId, executionId}}, - }); - - if (error) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; - throw new Error(message); - } - } - - async getJobLog(execution: JobExecutionInfo): Promise { - if (!execution.logFilePath) { - throw new Error('No log file path available'); - } - if (!execution.isLogFileExisting) { - throw new Error('Log file does not exist'); - } - const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); - const content = await this.config.instance.webdav.get(logPath); - return new TextDecoder().decode(content); - } - - private buildClient(scopes: string[]): ScapiJobsClient { - const clientConfig: ScapiJobsClientConfig = { - shortCode: this.config.shortCode, - tenantId: this.config.tenantId, - scopes: [...scopes, buildTenantScope(this.config.tenantId)], - }; - return createScapiJobsClient(clientConfig, this.config.auth); - } - - private async findRunningExecution(jobId: string): Promise { - const results = await this.searchJobExecutions({ - jobId, - status: ['RUNNING', 'PENDING'], - sortBy: 'start_time', - sortOrder: 'asc', - count: 1, - }); - return results.hits[0]; - } - - private async waitForTerminal(jobId: string, executionId: string): Promise { - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - while (true) { - await sleep(3000); - const execution = await this.getJobExecution(jobId, executionId); - if (execution.executionStatus === 'finished' || execution.executionStatus === 'aborted') { - return; - } - } - } -} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts new file mode 100644 index 000000000..608eb31e5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts @@ -0,0 +1,276 @@ +/* + * 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 + */ +/** + * SCAPI Jobs operations. + * + * Free functions over a {@link ScapiJobsClient}. Each operation declares its + * scope tier (`read` or `write`) via the `x-b2c-scope-mode` header; the + * auth middleware on the client reads that header and resolves the + * appropriate scope cascade against Account Manager. + * + * SDK consumers (or the CLI dispatcher in auto/scapi mode) call these + * directly. This is the future primary surface for jobs once OCAPI is + * deprecated. + * + * @module operations/jobs/scapi-ops + */ +import type {B2CInstance} from '../../instance/index.js'; +import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; +import { + toOrganizationId, + type ScapiJobsClient, + type JobExecution as ScapiJobExecution, + type JobStepExecution as ScapiJobStepExecution, +} from '../../clients/scapi-jobs.js'; +import {getLogger} from '../../logging/logger.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; +import type {JobExecutionInfo, JobExecutionSearchResults, JobStepExecutionResult} from './types.js'; + +const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; +const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; + +function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.stepId, + executionStatus: step.executionStatus, + exitStatus: step.exitStatus + ? { + code: step.exitStatus.code ?? '', + message: step.exitStatus.message, + status: step.exitStatus.status, + } + : undefined, + duration: step.duration, + }; +} + +function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionInfo { + return { + id: scapi.id, + jobId: scapi.jobId, + executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionInfo['executionStatus'], + exitStatus: scapi.exitStatus + ? { + code: scapi.exitStatus.code ?? '', + message: scapi.exitStatus.message, + status: scapi.exitStatus.status, + } + : undefined, + startTime: scapi.startTime, + endTime: scapi.endTime, + duration: scapi.duration, + stepExecutions: scapi.stepExecutions?.map(mapStepExecution), + logFilePath: scapi.logFilePath, + isLogFileExisting: scapi.isLogFileExisting, + parameters: scapi.parameters, + _raw: scapi, + }; +} + +export interface ExecuteJobScapiOptions extends ExecuteJobOptions { + /** Tenant ID for organization path param. Required. */ + tenantId: string; +} + +/** + * Execute a job. Requires the rw scope (no ro fallback for writes). + * + * If the job is already running and `waitForRunning` is not `false`, polls + * until the prior run reaches a terminal state, then retries. + */ +export async function executeJob( + client: ScapiJobsClient, + jobId: string, + options: ExecuteJobScapiOptions, +): Promise { + const organizationId = toOrganizationId(options.tenantId); + const {parameters = [], body: rawBody} = options; + + let requestBody: Record | undefined; + if (rawBody) { + requestBody = rawBody; + } else if (parameters.length > 0) { + requestBody = {parameters}; + } + + const {data, error, response} = await client.POST('/organizations/{organizationId}/jobs/{jobId}/executions', { + params: {path: {organizationId, jobId}}, + headers: WRITE_HEADERS, + body: requestBody as unknown as {parameters?: Array<{name: string; value: string}>}, + }); + + if (response.status === 400) { + const errorBody = error as unknown as {title?: string; type?: string; detail?: string}; + if (errorBody?.type?.includes('job-already-running') || errorBody?.title === 'Job Already Running') { + if (options.waitForRunning !== false) { + getLogger().warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); + const running = await findRunningExecution(client, jobId, options.tenantId); + if (running) { + await waitForTerminal(client, jobId, running.id, options.tenantId); + } + return executeJob(client, jobId, {...options, waitForRunning: false}); + } + throw new Error(`Job ${jobId} is already running`); + } + } + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; + throw new Error(message); + } + + return mapScapiExecution(data); +} + +export async function getJobExecution( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const organizationId = toOrganizationId(tenantId); + + const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId, jobId, executionId}}, + headers: READ_HEADERS, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; + throw new Error(message); + } + + return mapScapiExecution(data); +} + +export interface SearchJobExecutionsScapiOptions extends SearchJobExecutionsOptions { + /** Tenant ID for organization path param. Required. */ + tenantId: string; +} + +export async function searchJobExecutions( + client: ScapiJobsClient, + options: SearchJobExecutionsScapiOptions, +): Promise { + const organizationId = toOrganizationId(options.tenantId); + const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options; + + const queries: unknown[] = []; + if (jobId) { + queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); + } + if (status) { + const statusValues = Array.isArray(status) ? status : [status]; + queries.push({termQuery: {fields: ['status'], operator: 'one_of', values: statusValues}}); + } + + let query: unknown; + if (queries.length === 0) { + query = {matchAllQuery: {}}; + } else if (queries.length === 1) { + query = queries[0]; + } else { + query = {boolQuery: {must: queries}}; + } + + const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { + params: {path: {organizationId}}, + headers: READ_HEADERS, + body: { + query, + limit: count, + offset: start, + sorts: [{field: sortBy, sortOrder}], + } as never, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? 'Failed to search job executions'; + throw new Error(message); + } + + const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; + return { + total: result.total ?? 0, + limit: result.limit ?? count, + offset: result.offset ?? start, + hits: (result.hits ?? []).map(mapScapiExecution), + }; +} + +export async function deleteJobExecution( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const organizationId = toOrganizationId(tenantId); + + const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId, jobId, executionId}}, + headers: WRITE_HEADERS, + }); + + if (error) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; + throw new Error(message); + } +} + +/** + * Retrieves a job's log file content over WebDAV. Both backends + * (SCAPI and OCAPI) expose `logFilePath` under `/Sites/LOGS/...`; WebDAV is + * shared, so this lives in jobs/scapi-ops.ts only as a convenience for SDK + * consumers building purely against SCAPI ops. + */ +export async function getJobLog(instance: B2CInstance, execution: JobExecutionInfo): Promise { + if (!execution.logFilePath) { + throw new Error('No log file path available'); + } + if (!execution.isLogFileExisting) { + throw new Error('Log file does not exist'); + } + const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await instance.webdav.get(logPath); + return new TextDecoder().decode(content); +} + +async function findRunningExecution( + client: ScapiJobsClient, + jobId: string, + tenantId: string, +): Promise { + const results = await searchJobExecutions(client, { + jobId, + status: ['RUNNING', 'PENDING'], + sortBy: 'start_time', + sortOrder: 'asc', + count: 1, + tenantId, + }); + return results.hits[0]; +} + +async function waitForTerminal( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + while (true) { + await sleep(3000); + const execution = await getJobExecution(client, jobId, executionId, tenantId); + if (execution.executionStatus === 'finished' || execution.executionStatus === 'aborted') { + return; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts index d218867e0..847f3083d 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -7,6 +7,12 @@ import type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} f export type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions}; +/** + * Canonical, backend-agnostic job execution shape (camelCase). + * + * SCAPI ops return this directly; OCAPI ops return raw snake_case which the + * caller maps via {@link mapOcapiExecution}. + */ export interface JobExecutionInfo { id: string; jobId: string; @@ -50,28 +56,3 @@ export interface JobExecutionSearchResults { offset: number; hits: JobExecutionInfo[]; } - -export interface JobsBackend { - readonly name: 'ocapi' | 'scapi'; - executeJob(jobId: string, options?: ExecuteJobOptions): Promise; - getJobExecution(jobId: string, executionId: string): Promise; - searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; - getJobLog(execution: JobExecutionInfo): Promise; -} - -/** - * Capability extension for backends that can delete job execution records. - * Only SCAPI exposes this — OCAPI's Data API has no equivalent endpoint. - * - * Use {@link supportsDeleteJobExecution} to narrow at runtime. - */ -export interface DeletableJobsBackend extends JobsBackend { - deleteJobExecution(jobId: string, executionId: string): Promise; -} - -/** - * Type guard: returns true if the backend supports deleting job executions. - */ -export function supportsDeleteJobExecution(backend: JobsBackend): backend is DeletableJobsBackend { - return typeof (backend as DeletableJobsBackend).deleteJobExecution === 'function'; -} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts similarity index 52% rename from packages/b2c-tooling-sdk/src/operations/jobs/backend.ts rename to packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts index cf05bf49a..5227ec83b 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts @@ -3,26 +3,42 @@ * 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 type {JobsBackend, JobExecutionInfo} from './types.js'; +/** + * Backend-agnostic poll loop over canonical {@link JobExecutionInfo}. + * + * Takes a `getExecution` callback so callers can supply either the SCAPI + * ops `getJobExecution` method or an OCAPI fetch wrapped in + * {@link mapOcapiExecution}. Decouples polling logic from any specific + * backend abstraction. + * + * @module operations/jobs/wait-canonical + */ import type {WaitForJobOptions, WaitForJobPollInfo} from './run.js'; -import {OcapiJobsBackend} from './ocapi-backend.js'; -import {ScapiJobsBackend} from './scapi-backend.js'; -import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -import type {ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; - -export type {ApiBackendPreference}; -export type JobsBackendConfig = DualBackendConfig; +import type {JobExecutionInfo} from './types.js'; -export function createJobsBackend(config: JobsBackendConfig): JobsBackend { - return createDualBackend(config, { - domainName: 'Jobs', - Scapi: ScapiJobsBackend, - Ocapi: OcapiJobsBackend, - }); +/** + * Thrown by {@link waitForJobExecution} when a job reaches a failure state. + * Carries the canonical {@link JobExecutionInfo} so callers can read fields + * (`exitStatus.code`, `logFilePath`, etc.) without knowing which backend + * served the response. + */ +export class CanonicalJobExecutionError extends Error { + constructor( + message: string, + public readonly execution: JobExecutionInfo, + ) { + super(message); + this.name = 'CanonicalJobExecutionError'; + } } +/** + * Polls `getExecution(jobId, executionId)` until the job reaches a terminal + * state, returning the final {@link JobExecutionInfo}. Throws + * {@link JobExecutionError} on failure or `Error` on timeout. + */ export async function waitForJobExecution( - backend: JobsBackend, + getExecution: (jobId: string, executionId: string) => Promise, jobId: string, executionId: string, options: WaitForJobOptions = {}, @@ -32,6 +48,7 @@ export async function waitForJobExecution( const startTime = Date.now(); const pollIntervalMs = pollIntervalSeconds * 1000; const timeoutMs = timeoutSeconds * 1000; + await sleepFn(pollIntervalMs); while (true) { @@ -41,15 +58,13 @@ export async function waitForJobExecution( throw new Error(`Timeout waiting for job ${jobId} execution ${executionId}`); } - const execution = await backend.getJobExecution(jobId, executionId); + const execution = await getExecution(jobId, executionId); const currentStatus = execution.executionStatus; - const pollInfo: WaitForJobPollInfo = {jobId, executionId, elapsedSeconds, status: currentStatus}; onPoll?.(pollInfo); if (execution.executionStatus === 'aborted' || execution.exitStatus?.status === 'error') { - const {JobExecutionError} = await import('./run.js'); - throw new JobExecutionError(`Job ${jobId} failed`, execution._raw as never); + throw new CanonicalJobExecutionError(`Job ${jobId} failed`, execution); } if (execution.executionStatus === 'finished') { diff --git a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts index bb0fdc42d..a0b90b82e 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts @@ -460,6 +460,149 @@ describe('auth/oauth', () => { expect(extended).to.be.instanceOf(OAuthStrategy); }); }); + + describe('getAccessTokenForCascade', () => { + it('returns the first candidate that AM accepts', async () => { + const mockToken = createMockJWT({sub: 'test-client-cascade-1'}); + let lastRequestedScope: string | null = null; + + server.use( + http.post(AM_URL, async ({request}) => { + const body = await request.text(); + const params = new URLSearchParams(body); + lastRequestedScope = params.get('scope'); + // Reject anything containing the rw scope; accept the read-only + // candidate. + if (lastRequestedScope?.includes('sfcc.jobs.rw')) { + return HttpResponse.json({error: 'invalid_scope'}, {status: 400}); + } + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: lastRequestedScope ?? '', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-1', + clientSecret: 'test-secret', + }); + + const token = await strategy.getAccessTokenForCascade([['sfcc.jobs.rw'], ['sfcc.jobs']]); + + expect(token).to.equal(mockToken); + // Last successful AM call should have used the read-only candidate. + expect(lastRequestedScope).to.equal('sfcc.jobs'); + }); + + it('returns a cached broader-scope token without hitting AM', async () => { + // Pre-warm: first call grants rw. + const rwToken = createMockJWT({sub: 'test-client-cascade-2'}); + let amCallCount = 0; + + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({ + access_token: rwToken, + expires_in: 1800, + scope: 'sfcc.jobs.rw', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-2', + clientSecret: 'test-secret', + }); + + // First request: cascade tries rw, AM grants it. amCallCount = 1. + await strategy.getAccessTokenForCascade([['sfcc.jobs.rw']]); + expect(amCallCount).to.equal(1); + + // Second request: read-only cascade. The cached rw token's scopes + // include 'sfcc.jobs.rw' — should it satisfy a request for ['sfcc.jobs']? + // Per design: the satisfies-check looks for tokens whose scopes ⊇ + // the requested set. 'sfcc.jobs' is NOT in the rw token's scopes, + // so it does not satisfy. AM gets called again. This test confirms + // that hierarchical scope semantics are NOT inferred — caches are + // exact-set matches. + await strategy.getAccessTokenForCascade([['sfcc.jobs']]); + expect(amCallCount).to.equal(2); + }); + + it('reuses cached token when a candidate exactly matches', async () => { + const mockToken = createMockJWT({sub: 'test-client-cascade-3'}); + let amCallCount = 0; + + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: 'sfcc.jobs', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-3', + clientSecret: 'test-secret', + }); + + await strategy.getAccessTokenForCascade([['sfcc.jobs']]); + await strategy.getAccessTokenForCascade([['sfcc.jobs']]); + + // Second call should hit cache. + expect(amCallCount).to.equal(1); + }); + + it('throws the last invalid_scope when all candidates fail', async () => { + server.use( + http.post(AM_URL, async () => { + return HttpResponse.json({error: 'invalid_scope'}, {status: 400}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-4', + clientSecret: 'test-secret', + }); + + try { + await strategy.getAccessTokenForCascade([['sfcc.jobs.rw'], ['sfcc.jobs']]); + expect.fail('should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('invalid_scope'); + } + }); + + it('rethrows non-invalid_scope errors without trying further candidates', async () => { + let amCallCount = 0; + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({error: 'invalid_client'}, {status: 401}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-5', + clientSecret: 'test-secret', + }); + + try { + await strategy.getAccessTokenForCascade([['sfcc.jobs.rw'], ['sfcc.jobs']]); + expect.fail('should have thrown'); + } catch { + // expected + } + // Should not have tried the second candidate. + expect(amCallCount).to.equal(1); + }); + }); }); }); diff --git a/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts new file mode 100644 index 000000000..208492dfe --- /dev/null +++ b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts @@ -0,0 +1,121 @@ +/* + * 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 {BackendDispatcher} from '../../src/compat/dispatcher.js'; + +interface FakeOps { + doRead(): Promise; +} + +const makeOps = (): FakeOps => ({doRead: async () => 'scapi-result'}); + +const invalidScopeError = () => new Error('Failed to get access token: 400 invalid_scope'); + +describe('BackendDispatcher', () => { + describe('preference handling', () => { + it('throws when scapi is forced but not configured', () => { + expect(() => new BackendDispatcher('scapi', () => undefined, 'jobs')).to.throw( + /shortCode, tenantId, and OAuth/, + ); + }); + + it('resolves to ocapi immediately when forced', () => { + const d = new BackendDispatcher('ocapi', () => makeOps(), 'jobs'); + expect(d.active).to.equal('ocapi'); + }); + + it('resolves to scapi when forced and configured', () => { + const d = new BackendDispatcher('scapi', () => makeOps(), 'jobs'); + expect(d.active).to.equal('scapi'); + }); + + it('resolves to ocapi in auto when scapi not configured', () => { + const d = new BackendDispatcher('auto', () => undefined, 'jobs'); + expect(d.active).to.equal('ocapi'); + }); + + it('stays unresolved in auto when scapi configured', () => { + const d = new BackendDispatcher('auto', () => makeOps(), 'jobs'); + expect(d.active).to.equal(undefined); + }); + }); + + describe('run', () => { + it('routes to scapi branch and caches the choice', async () => { + const ops = makeOps(); + const d = new BackendDispatcher('auto', () => ops, 'jobs'); + let scapiCalls = 0; + const branches = { + scapi: async (received: FakeOps) => { + expect(received).to.equal(ops); + scapiCalls++; + return 'scapi'; + }, + ocapi: async () => 'ocapi', + }; + expect(await d.run(branches)).to.equal('scapi'); + expect(await d.run(branches)).to.equal('scapi'); + expect(scapiCalls).to.equal(2); + expect(d.active).to.equal('scapi'); + }); + + it('falls back to ocapi on invalid_scope and caches the choice', async () => { + let scapiCalls = 0; + let ocapiCalls = 0; + const d = new BackendDispatcher('auto', () => makeOps(), 'jobs'); + const branches = { + scapi: async () => { + scapiCalls++; + throw invalidScopeError(); + }, + ocapi: async () => { + ocapiCalls++; + return 'ocapi'; + }, + }; + expect(await d.run(branches)).to.equal('ocapi'); + expect(await d.run(branches)).to.equal('ocapi'); + expect(scapiCalls).to.equal(1); + expect(ocapiCalls).to.equal(2); + expect(d.active).to.equal('ocapi'); + }); + + it('rethrows non-invalid_scope errors without falling back', async () => { + const d = new BackendDispatcher('auto', () => makeOps(), 'jobs'); + try { + await d.run({ + scapi: async () => { + throw new Error('something else broke'); + }, + ocapi: async () => 'should-not-reach', + }); + expect.fail('should have thrown'); + } catch (error) { + expect((error as Error).message).to.equal('something else broke'); + } + // Did NOT cache scapi (the call did not succeed) — but also didn't cache ocapi. + expect(d.active).to.equal(undefined); + }); + + it('routes directly to ocapi when forced', async () => { + const d = new BackendDispatcher('ocapi', () => makeOps(), 'jobs'); + let scapiCalls = 0; + let ocapiCalls = 0; + await d.run({ + scapi: async () => { + scapiCalls++; + return 's'; + }, + ocapi: async () => { + ocapiCalls++; + return 'o'; + }, + }); + expect(scapiCalls).to.equal(0); + expect(ocapiCalls).to.equal(1); + }); + }); +}); From d91d2f43fb6b71d60e8adbc94722d1952f98486a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 21 May 2026 12:02:17 -0400 Subject: [PATCH 11/11] Fix b2c-vs-extension reloadCodeVersion typecheck after SCAPI migration reloadCodeVersion now takes a ScriptsBackend (was B2CInstance). Wrap the extension's instance in OcapiScriptsBackend at the two call sites so the extension keeps its OCAPI-only behavior while satisfying the new type. Mirrors how b2c-cli/src/commands/code/deploy.ts:207 already handles this. --- packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts | 3 ++- packages/b2c-vs-extension/src/code-sync/deploy-command.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts index c40b71d0c..2453236cc 100644 --- a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -11,6 +11,7 @@ import { createCodeVersion, reloadCodeVersion, deleteCodeVersion, + OcapiScriptsBackend, } from '@salesforce/b2c-tooling-sdk/operations/code'; import { addCartridge, @@ -289,7 +290,7 @@ function createListCodeVersionsCommand( } else if (actionPick.action === 'reload') { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Reloading "${versionId}"...`}, - () => reloadCodeVersion(instance, versionId), + () => reloadCodeVersion(new OcapiScriptsBackend(instance), versionId), ); vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" reloaded.`); } else if (actionPick.action === 'delete') { diff --git a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts index f3be4c955..9bca0ad1e 100644 --- a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -10,6 +10,7 @@ import { getActiveCodeVersion, activateCodeVersion, reloadCodeVersion, + OcapiScriptsBackend, } from '@salesforce/b2c-tooling-sdk/operations/code'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; @@ -98,7 +99,7 @@ export function createDeployCommand( outputChannel.appendLine(`Code version "${codeVersion}" activated`); } else if (actionPick.action === 'reload') { progress.report({message: 'Reloading code version...'}); - await reloadCodeVersion(instance, codeVersion); + await reloadCodeVersion(new OcapiScriptsBackend(instance), codeVersion); outputChannel.appendLine(`Code version "${codeVersion}" reloaded`); }