diff --git a/.changeset/bm-roles-management.md b/.changeset/bm-roles-management.md new file mode 100644 index 00000000..9856a6de --- /dev/null +++ b/.changeset/bm-roles-management.md @@ -0,0 +1,7 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-dx-docs': patch +--- + +Add Business Manager role management commands (`bm roles`) for instance-level access role CRUD, user assignment, and permissions via OCAPI Data API diff --git a/.changeset/sdk-migration-tutorial.md b/.changeset/sdk-migration-tutorial.md new file mode 100644 index 00000000..150a75b3 --- /dev/null +++ b/.changeset/sdk-migration-tutorial.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-docs': patch +--- + +Add SDK migration tutorial for sfcc-ci programmatic API users diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d0434090..8d046cd6 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -44,6 +44,7 @@ const guidesSidebar = [ {text: 'Authentication Setup', link: '/guide/authentication'}, {text: 'CI/CD with GitHub Actions', link: '/guide/ci-cd'}, {text: 'sfcc-ci Migration', link: '/guide/sfcc-ci-migration'}, + {text: 'sfcc-ci SDK Migration', link: '/guide/sdk-migration'}, {text: 'Account Manager', link: '/guide/account-manager'}, {text: 'Analytics Reports (CIP/CCAC)', link: '/guide/analytics-reports-cip-ccac'}, {text: 'IDE Integration', link: '/guide/ide-integration'}, @@ -79,6 +80,7 @@ const referenceSidebar = [ {text: 'Overview', link: '/cli/'}, {text: 'Account Manager', link: '/cli/account-manager'}, {text: 'Auth', link: '/cli/auth'}, + {text: 'BM Roles', link: '/cli/bm-roles'}, {text: 'CIP', link: '/cli/cip'}, {text: 'Code', link: '/cli/code'}, {text: 'Content', link: '/cli/content'}, diff --git a/docs/cli/bm-roles.md b/docs/cli/bm-roles.md new file mode 100644 index 00000000..1b5abd72 --- /dev/null +++ b/docs/cli/bm-roles.md @@ -0,0 +1,309 @@ +--- +description: Commands for managing Business Manager access roles, user assignments, and permissions on B2C Commerce instances. +--- + +# BM Roles Commands + +Commands for managing instance-level Business Manager access roles on B2C Commerce instances. These are distinct from [Account Manager roles](/cli/account-manager#roles) which manage roles at the Account Manager level. + +## Authentication + +BM roles commands require OAuth authentication with OCAPI permissions for the `/roles` resource. + +### Required OCAPI Permissions + +| Resource | Methods | +|----------|---------| +| `/roles` | GET | +| `/roles/*` | GET, PUT, DELETE | +| `/roles/*/users` | GET, PUT, DELETE | + +### Configuration + +```bash +export SFCC_CLIENT_ID=your-client-id +export SFCC_CLIENT_SECRET=your-client-secret +``` + +For complete setup instructions, see the [Authentication Guide](/guide/authentication). + +--- + +## b2c bm roles list + +List all Business Manager access roles on an instance. + +### Usage + +```bash +b2c bm roles list [--count ] [--start ] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--count`, `-n` | Number of roles to return (default 25) | +| `--start` | Start index for pagination (default 0) | + +Uses [global instance and authentication flags](./index#global-flags). + +### Examples + +```bash +b2c bm roles list --server my-sandbox.demandware.net +b2c bm roles list --count 50 --json +``` + +--- + +## b2c bm roles get + +Get details of a specific access role. + +### Usage + +```bash +b2c bm roles get [--expand ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `role` | Role ID (e.g. "Administrator") | + +### Flags + +| Flag | Description | +|------|-------------| +| `--expand`, `-e` | Expansions to apply (e.g. `users`, `permissions`). Can be specified multiple times. | + +### Examples + +```bash +b2c bm roles get Administrator +b2c bm roles get Administrator --expand users +b2c bm roles get Administrator --json +``` + +--- + +## b2c bm roles create + +Create a new custom access role on an instance. + +### Usage + +```bash +b2c bm roles create [--description ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `role` | Role ID to create | + +### Flags + +| Flag | Description | +|------|-------------| +| `--description`, `-d` | Description for the role | + +### Examples + +```bash +b2c bm roles create ContentEditor --description "Role for content editors" +b2c bm roles create ContentEditor --json +``` + +::: warning +Reserved role IDs ("Support", "Business Support") cannot be created. +::: + +--- + +## b2c bm roles delete + +Delete a custom access role from an instance. + +### Usage + +```bash +b2c bm roles delete +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `role` | Role ID to delete | + +### Examples + +```bash +b2c bm roles delete ContentEditor +``` + +::: warning +System roles (e.g. "Administrator") cannot be deleted. +::: + +--- + +## b2c bm roles grant + +Assign a user to an access role on an instance. + +### Usage + +```bash +b2c bm roles grant --role +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `login` | User login (email) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to grant (required) | + +### Examples + +```bash +b2c bm roles grant user@example.com --role Administrator +b2c bm roles grant user@example.com --role ContentEditor --json +``` + +--- + +## b2c bm roles revoke + +Unassign a user from an access role on an instance. + +### Usage + +```bash +b2c bm roles revoke --role +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `login` | User login (email) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to revoke (required) | + +### Examples + +```bash +b2c bm roles revoke user@example.com --role Administrator +``` + +--- + +## b2c bm roles permissions get + +Get permissions for an access role. + +### Usage + +```bash +b2c bm roles permissions get [--output ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `role` | Role ID (e.g. "Administrator") | + +### Flags + +| Flag | Description | +|------|-------------| +| `--output`, `-o` | Write full permissions JSON to a file for editing | + +### Examples + +```bash +# View summary +b2c bm roles permissions get Administrator + +# Export to file for editing +b2c bm roles permissions get Administrator --output admin-perms.json + +# Get raw JSON +b2c bm roles permissions get Administrator --json +``` + +--- + +## b2c bm roles permissions set + +Set (replace) all permissions for an access role from a JSON file. + +### Usage + +```bash +b2c bm roles permissions set --file +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `role` | Role ID | + +### Flags + +| Flag | Description | +|------|-------------| +| `--file`, `-f` | JSON file containing permissions (`role_permissions` schema) (required) | + +### Examples + +```bash +# Export, edit, then apply +b2c bm roles permissions get MyRole --output perms.json +# ... edit perms.json ... +b2c bm roles permissions set MyRole --file perms.json +``` + +::: warning +This command replaces **all** existing permissions for the role. Use `permissions get --output` first to ensure you have the complete set. +::: + +### Permissions JSON Structure + +The JSON file follows the OCAPI `role_permissions` schema with four sections: + +```json +{ + "functional": { + "organization": [{"name": "PERMISSION_NAME", "type": "functional", "value": "ACCESS"}], + "site": [] + }, + "module": { + "organization": [{"application": "bm", "name": "ModuleName", "type": "module", "system": true, "value": "ACCESS"}], + "site": [] + }, + "locale": { + "unscoped": [{"locale_id": "default", "type": "locale", "value": "ACCESS"}] + }, + "webdav": { + "unscoped": [{"folder": "Catalogs", "type": "webdav", "value": "ACCESS"}] + } +} +``` diff --git a/docs/guide/sdk-migration.md b/docs/guide/sdk-migration.md new file mode 100644 index 00000000..21ae7343 --- /dev/null +++ b/docs/guide/sdk-migration.md @@ -0,0 +1,471 @@ +--- +description: Migrate from sfcc-ci's programmatic JavaScript API to @salesforce/b2c-tooling-sdk with side-by-side code examples and API mapping. +--- + +# Migrating from sfcc-ci's JavaScript API + +This guide is for users who call sfcc-ci programmatically via `require('sfcc-ci')` in Node.js scripts. If you only use the CLI, see the [CLI migration guide](./sfcc-ci-migration) instead. + +## Key Paradigm Shifts + +### Callbacks to async/await + +sfcc-ci uses Node.js-style callbacks. The SDK uses Promises and async/await. + +**sfcc-ci:** + +```javascript +const sfcc = require('sfcc-ci'); + +sfcc.code.list('my-instance.demandware.net', token, function (err, versions) { + if (err) { + console.error(err); + return; + } + console.log(versions); +}); +``` + +**b2c-tooling-sdk:** + +```typescript +import {listCodeVersions} from '@salesforce/b2c-tooling-sdk/operations/code'; + +const versions = await listCodeVersions(instance); +console.log(versions); +``` + +The SDK is TypeScript-first with full type definitions. All examples use TypeScript, but the SDK works in plain JavaScript (ESM) as well. + +### Token Passing to Config-Based Auth + +With sfcc-ci you authenticate once, then thread the token through every call: + +```javascript +sfcc.auth.auth(clientId, clientSecret, function (err, token) { + sfcc.code.list(instance, token, function (err, versions) { + sfcc.job.run(instance, 'my-job', [], token, function (err, execution) { + // ... + }); + }); +}); +``` + +The SDK resolves credentials from `dw.json`, environment variables, or explicit overrides. You never pass a token — the SDK creates authenticated clients that handle token lifecycle internally: + +```typescript +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; + +const config = await resolveConfig(); + +// Instance operations (code, jobs, WebDAV, BM roles) use a B2CInstance +const instance = config.createB2CInstance(); +const versions = await listCodeVersions(instance); + +// Account Manager operations (users, AM roles, orgs) use a separate client +const amClient = createAccountManagerClient({}, config.createOAuth()); +const users = await listUsers(amClient.users, {size: 25}); +``` + +Both `instance` and `amClient` manage their own tokens — you never see or pass a token string. See the [Authentication Setup](./authentication) guide for credential configuration details. + +### Untyped Objects to Typed Clients + +sfcc-ci returns plain objects with no type information. The SDK provides: + +- **Typed operation results** — `CodeVersion`, `JobExecution`, etc. +- **openapi-fetch typed clients** — OCAPI, SLAS, ODS with full IDE autocompletion +- **TypeScript generics** — request params, bodies, and response shapes are all type-checked + +```typescript +// IDE autocomplete knows `versions` is CodeVersion[], each with .id, .active, etc. +const versions = await listCodeVersions(instance); + +// The OCAPI client is fully typed — invalid paths or params are compile-time errors +const {data, error} = await instance.ocapi.GET('/sites/{site_id}', { + params: {path: {site_id: 'RefArch'}}, +}); +``` + +## Quick Start: Replacing a Typical sfcc-ci Script + +Here is a realistic before/after for a CI/CD deploy script. + +**Before (sfcc-ci):** + +```javascript +const sfcc = require('sfcc-ci'); + +sfcc.auth.auth(process.env.CLIENT_ID, process.env.CLIENT_SECRET, function (err, token) { + if (err) throw err; + + sfcc.code.deploy('my-sandbox.demandware.net', './build/code.zip', token, {}, function (err) { + if (err) throw err; + + sfcc.code.activate('my-sandbox.demandware.net', 'version1', token, function (err) { + if (err) throw err; + console.log('Deployed and activated!'); + }); + }); +}); +``` + +**After (b2c-tooling-sdk):** + +```typescript +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {findAndDeployCartridges, activateCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; + +const config = await resolveConfig(); +const instance = config.createB2CInstance(); + +await findAndDeployCartridges(instance, './cartridges', {reload: true}); +await activateCodeVersion(instance, 'version1'); +console.log('Deployed and activated!'); +``` + +Key differences: + +- **No ZIP file** — the SDK discovers cartridges from source directories and handles zipping/uploading +- **No token threading** — credentials come from `dw.json` or environment variables +- **Flat control flow** — async/await instead of nested callbacks +- **Error handling** — use standard try/catch + +## API Mapping by Module + +### Authentication (`sfcc.auth`) + +sfcc-ci requires an explicit auth call that returns a token: + +```javascript +sfcc.auth.auth(clientId, clientSecret, function (err, token) { + // token must be passed to all subsequent calls +}); +``` + +The SDK resolves config from multiple sources (environment variables, `dw.json`, explicit overrides) and creates authenticated instances automatically: + +```typescript +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; + +// Credentials from SFCC_OAUTH_CLIENT_ID / SFCC_OAUTH_CLIENT_SECRET env vars, +// or clientId / clientSecret in dw.json, or explicit overrides: +const config = await resolveConfig({ + clientId: 'my-client-id', + clientSecret: 'my-client-secret', +}); + +// Instance handles token lifecycle internally +const instance = config.createB2CInstance(); +``` + +### Code Management (`sfcc.code`) + +| sfcc-ci | SDK | +|---------|-----| +| `code.list(instance, token, cb)` | `listCodeVersions(instance)` | +| `code.deploy(instance, archive, token, opts, cb)` | `findAndDeployCartridges(instance, dir, opts)` | +| `code.activate(instance, version, token, cb)` | `activateCodeVersion(instance, versionId)` | + +```typescript +import { + listCodeVersions, + activateCodeVersion, + deleteCodeVersion, + findAndDeployCartridges, +} from '@salesforce/b2c-tooling-sdk/operations/code'; + +// List code versions +const versions = await listCodeVersions(instance); +for (const v of versions) { + console.log(`${v.id} active=${v.active}`); +} + +// Deploy cartridges from source directory (discovers, zips, uploads via WebDAV) +const result = await findAndDeployCartridges(instance, './cartridges', {reload: true}); + +// Activate a specific version +await activateCodeVersion(instance, 'version1'); + +// Delete a code version +await deleteCodeVersion(instance, 'old-version'); +``` + +::: tip +The SDK deploys from cartridge **source directories**, not pre-built ZIP archives. `findAndDeployCartridges` handles discovery (via `.project` files), archiving, and upload in one call. +::: + +### Job Execution (`sfcc.job`) + +| sfcc-ci | SDK | +|---------|-----| +| `job.run(instance, jobId, params, token, cb)` | `executeJob(instance, jobId, opts?)` | +| `job.status(instance, jobId, execId, token, cb)` | `getJobExecution(instance, jobId, execId)` | +| _(no equivalent)_ | `waitForJob(instance, jobId, execId, opts?)` | + +```typescript +import {executeJob, waitForJob, siteArchiveImport} from '@salesforce/b2c-tooling-sdk/operations/jobs'; + +// Run a custom job +const execution = await executeJob(instance, 'my-custom-job', { + parameters: [{key: 'param1', value: 'value1'}], +}); + +// Wait for completion (polls automatically) +const completed = await waitForJob(instance, 'my-custom-job', execution.id); +console.log(`Job finished: ${completed.execution_status}`); + +// Import a site archive (upload + run import job + wait) +const importResult = await siteArchiveImport(instance, './site-data.zip'); +``` + +`waitForJob` has no sfcc-ci equivalent — sfcc-ci scripts typically implement their own polling loop. The SDK handles this with configurable polling intervals and timeouts. + +### Instance / WebDAV (`sfcc.instance`, `sfcc.webdav`) + +| sfcc-ci | SDK | +|---------|-----| +| `instance.upload(instance, file, token, opts, cb)` | `instance.webdav.put(path, data)` | +| `instance.import(instance, file, token, cb)` | `siteArchiveImport(instance, zipPath)` | +| `webdav.upload(instance, path, file, token, opts, cb)` | `instance.webdav.put(path, data)` | + +```typescript +import {readFileSync} from 'node:fs'; + +// Upload a file via WebDAV +const data = readFileSync('./my-file.zip'); +await instance.webdav.put('/cartridges/my-file.zip', data); + +// Check if a file exists +const exists = await instance.webdav.exists('/cartridges/my-file.zip'); + +// List directory contents +const listing = await instance.webdav.propfind('/cartridges/'); + +// Delete a file +await instance.webdav.delete('/cartridges/my-file.zip'); +``` + +The WebDAV client exposes a richer API than sfcc-ci: `put`, `propfind`, `mkcol`, `delete`, `exists`, `copy`, `move`. + +### Account Manager Users (`sfcc.user`) + +| sfcc-ci | SDK | +|---------|-----| +| `user.create(org, user, mail, ...)` | `createUser(client, opts)` | +| `user.list(org, role, login, ...)` | `listUsers(client, opts)` | +| `user.update(login, changes, ...)` | `updateUser(client, opts)` | +| `user.delete(login, purge, ...)` | `deleteUser(client, userId)` / `purgeUser(client, userId)` | +| `user.reset(login, ...)` | `resetUser(client, userId)` | +| `user.grant(login, role, scope, ...)` | `grantRole(client, opts)` | +| `user.revoke(login, role, scope, ...)` | `revokeRole(client, opts)` | + +Account Manager operations use a separate client (not `instance`), since they talk to Account Manager rather than a B2C instance: + +```typescript +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {listUsers, createUser, grantRole} from '@salesforce/b2c-tooling-sdk/operations/users'; + +const config = await resolveConfig(); + +// Unified client for users, roles, orgs, and API clients +const client = createAccountManagerClient({}, config.createOAuth()); + +// List users +const users = await listUsers(client.users, {size: 25}); + +// Create a user +const newUser = await createUser(client.users, { + mail: 'user@example.com', + firstName: 'Test', + lastName: 'User', + organizationId: 'my-org-id', +}); + +// Grant a role +await grantRole(client.users, { + userId: newUser.id, + roleId: 'bm-admin', + scope: 'my-realm', +}); +``` + +See the [Account Manager guide](./account-manager) for more details on AM operations. + +### Account Manager & BM Roles (`sfcc.role`) + +The SDK separates Account Manager roles from Business Manager instance roles into distinct modules: + +| sfcc-ci | SDK | Module | +|---------|-----|--------| +| `role.list(token, count)` | `listRoles(client, opts)` | `operations/roles` | +| `role.listLocal(instance, ...)` | `listBmRoles(instance, opts)` | `operations/bm-roles` | +| `user.grantLocal(instance, login, role, ...)` | `grantBmRole(instance, roleId, login)` | `operations/bm-roles` | +| `user.revokeLocal(instance, login, role, ...)` | `revokeBmRole(instance, roleId, login)` | `operations/bm-roles` | + +```typescript +// Account Manager roles (organization-level) +import {listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; +const amRoles = await listRoles(client.roles, {size: 50}); + +// Business Manager roles (instance-level) +import {listBmRoles, grantBmRole, revokeBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +const bmRoles = await listBmRoles(instance); +await grantBmRole(instance, 'Administrator', 'user@example.com'); +await revokeBmRole(instance, 'Administrator', 'user@example.com'); +``` + +### Organizations (`sfcc.org`) + +```typescript +import {listOrgs, getOrg} from '@salesforce/b2c-tooling-sdk/operations/orgs'; + +const orgs = await listOrgs(client.orgs, {size: 25}); +const org = await getOrg(client.orgs, 'my-org-id'); +``` + +## Using Typed Clients Directly + +For APIs that have a typed client but no high-level operations wrapper, use the openapi-fetch client directly. All typed clients follow the same pattern: `client.METHOD('/path', {params, body})` returning `{data, error}`. + +### SLAS Client Management + +sfcc-ci has `slas.tenant.*` and `slas.client.*` methods. The SDK provides a typed SLAS client: + +```typescript +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {createSlasClient} from '@salesforce/b2c-tooling-sdk/clients'; + +const config = await resolveConfig(); +const slas = createSlasClient({shortCode: 'kv7kzm78'}, config.createOAuth()); + +// List tenants (sfcc-ci: slas.tenant.list(...)) +const {data: tenants} = await slas.GET('/tenants'); + +// Get a specific tenant (sfcc-ci: slas.tenant.get(...)) +const {data: tenant} = await slas.GET('/tenants/{tenantId}', { + params: {path: {tenantId: 'my-tenant'}}, +}); + +// List SLAS clients (sfcc-ci: slas.client.list(...)) +const {data: clients} = await slas.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'my-tenant'}}, +}); +``` + +### Direct OCAPI Access + +For any OCAPI Data API endpoint, use `instance.ocapi`: + +```typescript +// Get site details +const {data: site} = await instance.ocapi.GET('/sites/{site_id}', { + params: {path: {site_id: 'RefArch'}}, +}); + +// Search for something via OCAPI +const {data, error} = await instance.ocapi.POST('/customer_search', { + body: {query: {text_query: {fields: ['email'], search_phrase: 'test@example.com'}}}, +}); + +if (error) { + console.error('Search failed:', error); +} +``` + +The OCAPI client is generated from the OpenAPI spec, so all paths, parameters, and response types are fully typed. + +## Comprehensive Mapping Table + +| sfcc-ci Method | SDK Equivalent | Import Path | +|---------------|----------------|-------------| +| `auth.auth(clientId, secret, cb)` | `resolveConfig()` + `config.createB2CInstance()` | `*/config` | +| `code.list(instance, token, cb)` | `listCodeVersions(instance)` | `*/operations/code` | +| `code.deploy(instance, archive, token, opts, cb)` | `findAndDeployCartridges(instance, dir, opts)` | `*/operations/code` | +| `code.activate(instance, version, token, cb)` | `activateCodeVersion(instance, versionId)` | `*/operations/code` | +| `code.compare(...)` | _Not ported_ | | +| `code.diffdeploy(...)` | _Not ported_ | | +| `manifest.generate(...)` | _Not ported_ | | +| `cartridge.add(...)` | _Planned for a future release_ | | +| `instance.upload(instance, file, token, opts, cb)` | `instance.webdav.put(path, data)` | `*/instance` | +| `instance.import(instance, file, token, cb)` | `siteArchiveImport(instance, zipPath)` | `*/operations/jobs` | +| `job.run(instance, jobId, params, token, cb)` | `executeJob(instance, jobId, opts?)` | `*/operations/jobs` | +| `job.status(instance, jobId, execId, token, cb)` | `getJobExecution(instance, jobId, execId)` | `*/operations/jobs` | +| `webdav.upload(instance, path, file, token, opts, cb)` | `instance.webdav.put(path, data)` | `*/instance` | +| `user.create(org, user, ...)` | `createUser(client, opts)` | `*/operations/users` | +| `user.list(org, role, ...)` | `listUsers(client, opts)` | `*/operations/users` | +| `user.update(login, changes, ...)` | `updateUser(client, opts)` | `*/operations/users` | +| `user.delete(login, purge, ...)` | `deleteUser(client, id)` / `purgeUser(client, id)` | `*/operations/users` | +| `user.reset(login, ...)` | `resetUser(client, id)` | `*/operations/users` | +| `user.grant(login, role, scope, ...)` | `grantRole(client, opts)` | `*/operations/users` | +| `user.revoke(login, role, scope, ...)` | `revokeRole(client, opts)` | `*/operations/users` | +| `user.createLocal(...)` | _Not ported_ | | +| `user.searchLocal(...)` | _Not ported_ | | +| `user.updateLocal(...)` | _Not ported_ | | +| `user.deleteLocal(...)` | _Not ported_ | | +| `user.grantLocal(instance, login, role, ...)` | `grantBmRole(instance, roleId, login)` | `*/operations/bm-roles` | +| `user.revokeLocal(instance, login, role, ...)` | `revokeBmRole(instance, roleId, login)` | `*/operations/bm-roles` | +| `role.list(token, count)` | `listRoles(client, opts)` | `*/operations/roles` | +| `role.listLocal(instance, ...)` | `listBmRoles(instance, opts)` | `*/operations/bm-roles` | +| `slas.tenant.add/get/list/delete(...)` | `createSlasClient()` typed client | `*/clients` | +| `slas.client.add/get/list/delete(...)` | `createSlasClient()` typed client | `*/clients` | + +All import paths use the `@salesforce/b2c-tooling-sdk` prefix (abbreviated as `*` above). + +## What the SDK Adds Beyond sfcc-ci + +The SDK provides many capabilities that sfcc-ci does not have: + +- **Content library operations** — fetch, parse, and export content libraries +- **CIP analytics** — run SQL queries against Commerce Intelligence Platform +- **MRT operations** — full Managed Runtime lifecycle (projects, bundles, deployments, environments, redirects, notifications) +- **Log operations** — tail and search instance logs in real-time +- **Scaffold system** — generate project templates from scaffolds +- **Workspace discovery** — detect project types (PWA Kit, SFRA, Storefront Next, cartridges) +- **Plugin/middleware system** — extend with custom config sources, HTTP middleware, lifecycle hooks +- **CDN zones management** — manage CDN configuration +- **Full TypeScript type safety** — typed API clients generated from OpenAPI specs + +See the [SDK Reference](/api/) for the complete API surface. + +## Error Handling + +sfcc-ci uses callback error parameters: + +```javascript +sfcc.code.list(instance, token, function (err, result) { + if (err) { + console.error('Failed:', err); + return; + } + // use result +}); +``` + +The SDK throws errors that you catch with try/catch: + +```typescript +import {HTTPError} from '@salesforce/b2c-tooling-sdk/errors'; + +try { + const versions = await listCodeVersions(instance); +} catch (error) { + if (error instanceof HTTPError) { + console.error(`HTTP ${error.statusCode}: ${error.message}`); + } else { + console.error('Unexpected error:', error); + } +} +``` + +SDK operations throw `Error` with descriptive messages. HTTP-level errors include status codes and response details via the `cause` property. + +## Next Steps + +- [Authentication Setup](./authentication) — configure API clients, OCAPI, and WebDAV +- [sfcc-ci CLI Migration](./sfcc-ci-migration) — CLI command mapping +- [SDK Reference](/api/) — full API documentation +- [Extending the CLI](./extending) — custom plugins, middleware, and hooks +- [CI/CD with GitHub Actions](./ci-cd) — official GitHub Actions for automation diff --git a/docs/guide/sfcc-ci-migration.md b/docs/guide/sfcc-ci-migration.md index 84b47192..1c859bab 100644 --- a/docs/guide/sfcc-ci-migration.md +++ b/docs/guide/sfcc-ci-migration.md @@ -8,6 +8,11 @@ description: Migrate from sfcc-ci to @salesforce/b2c-cli with command mappings, If you haven't installed the B2C CLI yet, see the [Installation guide](./installation). +::: tip Migrating from sfcc-ci's JavaScript API? +If you use `require('sfcc-ci')` in Node.js scripts, see the +[SDK Migration Guide](./sdk-migration) for side-by-side code examples. +::: + ## Authentication The biggest change from sfcc-ci is how authentication works by default. @@ -106,19 +111,22 @@ Sandbox commands map directly, with spaces replacing colons: | `sfcc-ci slas:client:list` | `b2c slas client list` | | `sfcc-ci slas:client:delete` | `b2c slas client delete` | -### User / Org / Role (Account Manager) +### User / Org / Role -Account Manager operations are under the `am` topic: +Account Manager operations are under the `am` topic. Instance-level Business Manager role management is under the `bm` topic: -| sfcc-ci | b2c-cli | -|---------|---------| -| `sfcc-ci user:list` | `b2c am users list` | -| `sfcc-ci user:create` | `b2c am users create` | -| `sfcc-ci user:delete` | `b2c am users delete` | -| `sfcc-ci org:list` | `b2c am orgs list` | -| `sfcc-ci role:list` | `b2c am roles list` | -| `sfcc-ci role:grant` | `b2c am roles grant` | -| `sfcc-ci role:revoke` | `b2c am roles revoke` | +| sfcc-ci | b2c-cli | Notes | +|---------|---------|-------| +| `sfcc-ci user:list` | `b2c am users list` | | +| `sfcc-ci user:create` | `b2c am users create` | | +| `sfcc-ci user:delete` | `b2c am users delete` | | +| `sfcc-ci org:list` | `b2c am orgs list` | | +| `sfcc-ci role:list` | `b2c am roles list` | Account Manager roles | +| `sfcc-ci role:list -i ` | `b2c bm roles list` | Instance BM roles | +| `sfcc-ci role:grant` | `b2c am roles grant` | Account Manager roles | +| `sfcc-ci role:grant -i ` | `b2c bm roles grant` | Instance BM roles | +| `sfcc-ci role:revoke` | `b2c am roles revoke` | Account Manager roles | +| `sfcc-ci role:revoke -i ` | `b2c bm roles revoke` | Instance BM roles | ## Environment Variables @@ -212,4 +220,5 @@ The B2C CLI provides official GitHub Actions that handle installation, credentia - [Authentication Setup](./authentication) — configure API clients, OCAPI, and WebDAV - [Configuration](./configuration) — environment variables, dw.json, and instance management - [CI/CD with GitHub Actions](./ci-cd) — official GitHub Actions for automation +- [SDK Migration (Programmatic API)](./sdk-migration) — migrate from sfcc-ci's JavaScript API to the SDK - [CLI Reference](/cli/) — browse all available commands diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 64f9b452..b5e469f1 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -98,6 +98,19 @@ }, "topicSeparator": " ", "topics": { + "bm": { + "description": "Manage Business Manager resources on an instance", + "subtopics": { + "roles": { + "description": "Manage instance-level Business Manager access roles", + "subtopics": { + "permissions": { + "description": "Get and set permissions for Business Manager roles" + } + } + } + } + }, "auth": { "description": "Manage authentication credentials and tokens\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/auth.html" }, diff --git a/packages/b2c-cli/src/commands/bm/roles/create.ts b/packages/b2c-cli/src/commands/bm/roles/create.ts new file mode 100644 index 00000000..14682eee --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/create.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, 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 {t} from '../../../i18n/index.js'; + +export default class BmRolesCreate extends InstanceCommand { + static args = { + role: Args.string({ + description: 'Role ID to create', + required: true, + }), + }; + + static description = t('commands.bm.roles.create.description', 'Create a Business Manager access role'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> MyCustomRole', + '<%= config.bin %> <%= command.id %> MyCustomRole --description "Custom role for content editors"', + '<%= config.bin %> <%= command.id %> MyCustomRole --json', + ]; + + static flags = { + description: Flags.string({ + char: 'd', + description: 'Description for the role', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {role: roleId} = this.args; + const {description} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log(t('commands.bm.roles.create.creating', 'Creating role {{roleId}} on {{hostname}}...', {roleId, hostname})); + + const role = await createBmRole(this.instance, roleId, {description}); + + if (this.jsonEnabled()) { + return role; + } + + this.log(t('commands.bm.roles.create.success', 'Role {{roleId}} created on {{hostname}}.', {roleId, hostname})); + + return role; + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/delete.ts b/packages/b2c-cli/src/commands/bm/roles/delete.ts new file mode 100644 index 00000000..163e9b51 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/delete.ts @@ -0,0 +1,56 @@ +/* + * 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 {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {deleteBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {t} from '../../../i18n/index.js'; + +interface DeleteResult { + success: boolean; + role: string; + hostname: string; +} + +export default class BmRolesDelete extends InstanceCommand { + static args = { + role: Args.string({ + description: 'Role ID to delete', + required: true, + }), + }; + + static description = t('commands.bm.roles.delete.description', 'Delete a Business Manager access role'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> MyCustomRole', + '<%= config.bin %> <%= command.id %> MyCustomRole --json', + ]; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {role: roleId} = this.args; + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.bm.roles.delete.deleting', 'Deleting role {{roleId}} from {{hostname}}...', {roleId, hostname}), + ); + + await deleteBmRole(this.instance, roleId); + + const result = {success: true, role: roleId, hostname}; + + if (this.jsonEnabled()) { + return result; + } + + this.log(t('commands.bm.roles.delete.success', 'Role {{roleId}} deleted from {{hostname}}.', {roleId, hostname})); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/get.ts b/packages/b2c-cli/src/commands/bm/roles/get.ts new file mode 100644 index 00000000..2d80ef87 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/get.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getBmRole, type BmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {t} from '../../../i18n/index.js'; + +export default class BmRolesGet extends InstanceCommand { + static args = { + role: Args.string({ + description: 'Role ID (e.g. "Administrator")', + required: true, + }), + }; + + static description = t('commands.bm.roles.get.description', 'Get details of a Business Manager access role'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> Administrator', + '<%= config.bin %> <%= command.id %> Administrator --expand users', + '<%= config.bin %> <%= command.id %> Administrator --json', + ]; + + static flags = { + expand: Flags.string({ + char: 'e', + description: 'Expansions to apply (e.g. users, permissions)', + multiple: true, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {role: roleId} = this.args; + const {expand} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log(t('commands.bm.roles.get.fetching', 'Fetching role {{roleId}} from {{hostname}}...', {roleId, hostname})); + + const role = await getBmRole(this.instance, roleId, {expand}); + + if (this.jsonEnabled()) { + return role; + } + + this.printRoleDetails(role); + + return role; + } + + private printRoleDetails(role: BmRole): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Role Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['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], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + + if (role.users && role.users.length > 0) { + ui.div({text: '', padding: [1, 0, 0, 0]}); + ui.div({text: 'Assigned Users', padding: [0, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const user of role.users) { + ui.div( + {text: user.login || '-', width: 40, padding: [0, 2, 0, 0]}, + {text: [user.first_name, user.last_name].filter(Boolean).join(' ') || '', padding: [0, 0, 0, 0]}, + ); + } + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/grant.ts b/packages/b2c-cli/src/commands/bm/roles/grant.ts new file mode 100644 index 00000000..95a0434f --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/grant.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {grantBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import type {OcapiComponents} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +type OcapiUser = OcapiComponents['schemas']['user']; + +export default class BmRolesGrant extends InstanceCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t( + 'commands.bm.roles.grant.description', + 'Grant a Business Manager role to a user on an instance', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --role Administrator', + '<%= config.bin %> <%= command.id %> user@example.com --role Administrator --json', + ]; + + static flags = { + role: Flags.string({ + char: 'r', + description: 'Role ID to grant', + required: true, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {login} = this.args; + const {role} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.bm.roles.grant.granting', 'Granting role {{role}} to {{login}} on {{hostname}}...', { + role, + login, + hostname, + }), + ); + + const user = await grantBmRole(this.instance, role, login); + + if (this.jsonEnabled()) { + return user; + } + + this.log( + t('commands.bm.roles.grant.success', 'User {{login}} granted role {{role}} on {{hostname}}.', { + login, + role, + hostname, + }), + ); + + return user; + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/list.ts b/packages/b2c-cli/src/commands/bm/roles/list.ts new file mode 100644 index 00000000..521e141a --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/list.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {InstanceCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {listBmRoles, type BmRole, type BmRoles} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {t} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (r) => r.id || '-', + }, + description: { + header: 'Description', + get: (r) => r.description || '-', + extended: true, + }, + userCount: { + header: 'Users', + get: (r) => r.user_count?.toString() ?? '-', + }, + userManager: { + header: 'User Manager', + get: (r) => (r.user_manager ? 'Yes' : 'No'), + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['id', 'userCount']; + +export default class BmRolesList extends InstanceCommand { + static description = t('commands.bm.roles.list.description', 'List Business Manager access roles on an instance'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --server my-sandbox.demandware.net', + '<%= config.bin %> <%= command.id %> --count 50', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + count: Flags.integer({ + char: 'n', + description: 'Number of roles to return (default 25)', + }), + start: Flags.integer({ + description: 'Start index for pagination (default 0)', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const hostname = this.resolvedConfig.values.hostname!; + const {count, start} = this.flags; + + this.log(t('commands.bm.roles.list.fetching', 'Fetching roles from {{hostname}}...', {hostname})); + + const roles = await listBmRoles(this.instance, {count, start}); + + if (this.jsonEnabled()) { + return roles; + } + + const items = roles.data ?? []; + if (items.length === 0) { + this.log(t('commands.bm.roles.list.noRoles', 'No roles found.')); + return roles; + } + + createTable(COLUMNS).render(items, DEFAULT_COLUMNS); + + if (roles.total && roles.total > items.length) { + this.log( + t('commands.bm.roles.list.moreRoles', '{{count}} of {{total}} roles shown.', { + count: items.length, + total: roles.total, + }), + ); + } + + return roles; + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts b/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts new file mode 100644 index 00000000..54473654 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts @@ -0,0 +1,129 @@ +/* + * 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 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 {t} from '../../../../i18n/index.js'; + +export default class BmRolesPermissionsGet extends InstanceCommand { + static args = { + role: Args.string({ + description: 'Role ID (e.g. "Administrator")', + required: true, + }), + }; + + static description = t( + 'commands.bm.roles.permissions.get.description', + 'Get permissions for a Business Manager access role', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> Administrator', + '<%= config.bin %> <%= command.id %> Administrator --output admin-perms.json', + '<%= config.bin %> <%= command.id %> Administrator --json', + ]; + + static flags = { + output: Flags.string({ + char: 'o', + description: 'Write full permissions JSON to file', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {role: roleId} = this.args; + const {output} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.bm.roles.permissions.get.fetching', 'Fetching permissions for role {{roleId}} on {{hostname}}...', { + roleId, + hostname, + }), + ); + + const permissions = await getBmRolePermissions(this.instance, roleId); + + if (output) { + fs.writeFileSync(output, JSON.stringify(permissions, null, 2) + '\n', 'utf8'); + this.log(t('commands.bm.roles.permissions.get.written', 'Permissions written to {{output}}.', {output})); + return permissions; + } + + if (this.jsonEnabled()) { + return permissions; + } + + this.printPermissionsSummary(roleId, permissions); + + return permissions; + } + + private printPermissionsSummary(roleId: string, permissions: BmRolePermissions): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: `Permissions for ${roleId}`, padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const functionalOrg = permissions.functional?.organization ?? []; + const functionalSite = permissions.functional?.site ?? []; + const moduleOrg = permissions.module?.organization ?? []; + const moduleSite = permissions.module?.site ?? []; + const localeUnscoped = permissions.locale?.unscoped ?? []; + const webdavUnscoped = permissions.webdav?.unscoped ?? []; + + const sections: [string, number, string[]][] = [ + ['Functional (organization)', functionalOrg.length, functionalOrg.map((p) => 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)], + ['WebDAV', webdavUnscoped.length, webdavUnscoped.map((p) => p.folder)], + ]; + + for (const [label, count, names] of sections) { + if (count > 0) { + ui.div( + {text: `${label}:`, width: 30, padding: [0, 2, 0, 0]}, + {text: `${count} permission(s)`, padding: [0, 0, 0, 0]}, + ); + for (const name of names.slice(0, 10)) { + ui.div({text: '', width: 30}, {text: ` ${name}`, padding: [0, 0, 0, 0]}); + } + if (names.length > 10) { + ui.div({text: '', width: 30}, {text: ` ... and ${names.length - 10} more`, padding: [0, 0, 0, 0]}); + } + } + } + + const totalCount = + functionalOrg.length + + functionalSite.length + + moduleOrg.length + + moduleSite.length + + localeUnscoped.length + + webdavUnscoped.length; + + if (totalCount === 0) { + ui.div({text: 'No permissions assigned.', padding: [0, 0, 0, 0]}); + } + + ui.div({text: '', padding: [1, 0, 0, 0]}); + ui.div({ + text: 'Use --output to export the full permissions JSON for editing.', + padding: [0, 0, 0, 0], + }); + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts b/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts new file mode 100644 index 00000000..adc4d8b4 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/permissions/set.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 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 {t} from '../../../../i18n/index.js'; + +export default class BmRolesPermissionsSet extends InstanceCommand { + static args = { + role: Args.string({ + description: 'Role ID (e.g. "Administrator")', + required: true, + }), + }; + + static description = t( + 'commands.bm.roles.permissions.set.description', + 'Set permissions for a Business Manager access role (replaces all existing permissions)', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %> MyRole --file perms.json']; + + static flags = { + file: Flags.string({ + char: 'f', + description: 'JSON file containing permissions (role_permissions schema)', + required: true, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {role: roleId} = this.args; + const {file} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + if (!fs.existsSync(file)) { + this.error(t('commands.bm.roles.permissions.set.fileNotFound', 'File not found: {{file}}', {file})); + } + + let permissions: BmRolePermissions; + try { + const content = fs.readFileSync(file, 'utf8'); + permissions = JSON.parse(content) as BmRolePermissions; + } catch { + this.error(t('commands.bm.roles.permissions.set.parseError', 'Failed to parse JSON from {{file}}', {file})); + } + + this.log( + t( + 'commands.bm.roles.permissions.set.setting', + 'Setting permissions for role {{roleId}} on {{hostname}} (this replaces all existing permissions)...', + {roleId, hostname}, + ), + ); + + const result = await setBmRolePermissions(this.instance, roleId, permissions); + + if (this.jsonEnabled()) { + return result; + } + + this.log( + t('commands.bm.roles.permissions.set.success', 'Permissions updated for role {{roleId}} on {{hostname}}.', { + roleId, + hostname, + }), + ); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/revoke.ts b/packages/b2c-cli/src/commands/bm/roles/revoke.ts new file mode 100644 index 00000000..ebdefd57 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/roles/revoke.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 {Args, Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {revokeBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {t} from '../../../i18n/index.js'; + +interface RevokeResult { + success: boolean; + role: string; + login: string; + hostname: string; +} + +export default class BmRolesRevoke extends InstanceCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t( + 'commands.bm.roles.revoke.description', + 'Revoke a Business Manager role from a user on an instance', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --role Administrator', + '<%= config.bin %> <%= command.id %> user@example.com --role Administrator --json', + ]; + + static flags = { + role: Flags.string({ + char: 'r', + description: 'Role ID to revoke', + required: true, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {login} = this.args; + const {role} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.bm.roles.revoke.revoking', 'Revoking role {{role}} from {{login}} on {{hostname}}...', { + role, + login, + hostname, + }), + ); + + await revokeBmRole(this.instance, role, login); + + const result = {success: true, role, login, hostname}; + + if (this.jsonEnabled()) { + return result; + } + + this.log( + t('commands.bm.roles.revoke.success', 'User {{login}} revoked from role {{role}} on {{hostname}}.', { + login, + role, + hostname, + }), + ); + + return result; + } +} diff --git a/packages/b2c-cli/test/commands/bm/roles/create.test.ts b/packages/b2c-cli/test/commands/bm/roles/create.test.ts new file mode 100644 index 00000000..0e2bcc83 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/roles/create.test.ts @@ -0,0 +1,74 @@ +/* + * 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 BmRolesCreate from '../../../../src/commands/bm/roles/create.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('bm roles create', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmRolesCreate, hooks.getConfig(), flags, args); + } + + 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, 'jsonEnabled').returns(jsonEnabled); + } + + it('creates role and returns in JSON mode', async () => { + const command: any = await createCommand({description: 'Test role'}, {role: 'TestRole'}); + 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}})); + + const result = await command.run(); + expect(result.id).to.equal('TestRole'); + expect(ocapiPut.calledOnce).to.equal(true); + }); + + it('logs success in non-JSON mode', async () => { + const command: any = await createCommand({}, {role: 'TestRole'}); + 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}})); + + await command.run(); + expect(logStub.calledWith(sinon.match('TestRole'))).to.equal(true); + }); + + 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 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}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to create role'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/roles/delete.test.ts b/packages/b2c-cli/test/commands/bm/roles/delete.test.ts new file mode 100644 index 00000000..18521ad9 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/roles/delete.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmRolesDelete from '../../../../src/commands/bm/roles/delete.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('bm roles delete', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmRolesDelete, hooks.getConfig(), flags, args); + } + + 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, 'jsonEnabled').returns(jsonEnabled); + } + + it('deletes role and returns result in JSON mode', async () => { + const command: any = await createCommand({}, {role: 'TestRole'}); + stubCommon(command, {jsonEnabled: true}); + + const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + const result = await command.run(); + expect(result.success).to.equal(true); + expect(result.role).to.equal('TestRole'); + expect(ocapiDelete.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 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}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete role'); + } + }); + + 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 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}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete role'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/roles/get.test.ts b/packages/b2c-cli/test/commands/bm/roles/get.test.ts new file mode 100644 index 00000000..d9bd5a86 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/roles/get.test.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 {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmRolesGet from '../../../../src/commands/bm/roles/get.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('bm roles get', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmRolesGet, hooks.getConfig(), flags, args); + } + + 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, 'jsonEnabled').returns(jsonEnabled); + } + + it('returns role details in JSON mode', async () => { + const command: any = await createCommand({}, {role: 'Administrator'}); + 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}})); + + const result = await command.run(); + expect(result.id).to.equal('Administrator'); + expect(result.user_count).to.equal(5); + }); + + it('displays role details in non-JSON mode', async () => { + const command: any = await createCommand({}, {role: 'Administrator'}); + 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}})); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); + + const result = await command.run(); + expect(result.id).to.equal('Administrator'); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + 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 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}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to get role'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/roles/grant.test.ts b/packages/b2c-cli/test/commands/bm/roles/grant.test.ts new file mode 100644 index 00000000..4f4bb5d9 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/roles/grant.test.ts @@ -0,0 +1,74 @@ +/* + * 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 BmRolesGrant from '../../../../src/commands/bm/roles/grant.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('bm roles grant', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmRolesGrant, hooks.getConfig(), flags, args); + } + + 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, 'jsonEnabled').returns(jsonEnabled); + } + + it('grants role and returns user in JSON mode', async () => { + const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); + 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}})); + + const result = await command.run(); + expect(result.login).to.equal('user@example.com'); + expect(ocapiPut.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 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}})); + + await command.run(); + expect(logStub.calledWith(sinon.match('user@example.com'))).to.equal(true); + }); + + 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 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}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to grant role'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/roles/list.test.ts b/packages/b2c-cli/test/commands/bm/roles/list.test.ts new file mode 100644 index 00000000..c7501dc3 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/roles/list.test.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 + */ + +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmRolesList from '../../../../src/commands/bm/roles/list.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('bm roles list', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}) { + return createTestCommand(BmRolesList, hooks.getConfig(), flags); + } + + 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, 'jsonEnabled').returns(jsonEnabled); + } + + it('returns data in JSON mode', async () => { + const command: any = await createCommand(); + 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}})); + + const result = await command.run(); + expect(result.count).to.equal(2); + expect(result.data).to.have.length(2); + expect(ocapiGet.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}); + 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}})); + + const result = await command.run(); + expect(result.count).to.equal(0); + }); + + it('throws when OCAPI 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 ocapiGet = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'boom'}}, + response: {status: 500, statusText: 'Error'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to list roles'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts b/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts new file mode 100644 index 00000000..900a1bfb --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/roles/revoke.test.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 + */ + +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmRolesRevoke from '../../../../src/commands/bm/roles/revoke.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; + +describe('bm roles revoke', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmRolesRevoke, hooks.getConfig(), flags, args); + } + + 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, 'jsonEnabled').returns(jsonEnabled); + } + + 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 ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + 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); + }); + + it('logs success in non-JSON mode', async () => { + const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); + 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}})); + + await command.run(); + expect(logStub.calledWith(sinon.match('user@example.com'))).to.equal(true); + }); + + 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 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}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to revoke role'); + } + }); +}); diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index be99e609..c7c28177 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -324,6 +324,12 @@ configureLogger({level: 'debug'}); configureLogger({level: 'trace'}); ``` +## Migrating from sfcc-ci + +If you're migrating from sfcc-ci's programmatic JavaScript API (`require('sfcc-ci')`), +see the [SDK Migration Tutorial](https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/sdk-migration) +for side-by-side code examples, paradigm changes, and a full API mapping table. + ## Documentation Full documentation is available at: https://salesforcecommercecloud.github.io/b2c-developer-tooling/ diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 60edb86a..fbd49ec3 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -145,6 +145,17 @@ "default": "./dist/cjs/operations/roles/index.js" } }, + "./operations/bm-roles": { + "development": "./src/operations/bm-roles/index.ts", + "import": { + "types": "./dist/esm/operations/bm-roles/index.d.ts", + "default": "./dist/esm/operations/bm-roles/index.js" + }, + "require": { + "types": "./dist/cjs/operations/bm-roles/index.d.ts", + "default": "./dist/cjs/operations/bm-roles/index.js" + } + }, "./operations/orgs": { "development": "./src/operations/orgs/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts new file mode 100644 index 00000000..96e3e74b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts @@ -0,0 +1,67 @@ +/* + * 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 + */ +/** + * Business Manager role operations for B2C Commerce instances. + * + * This module provides functions for managing instance-level access roles + * on B2C Commerce instances via the OCAPI Data API. These are distinct from + * Account Manager roles managed via {@link @salesforce/b2c-tooling-sdk/operations/roles | operations/roles}. + * + * ## Core Role Functions + * + * - {@link listBmRoles} - List all access roles on an instance + * - {@link getBmRole} - Get role details with optional expansion + * - {@link createBmRole} - Create a new access role + * - {@link deleteBmRole} - Delete an access role + * + * ## User Assignment + * + * - {@link grantBmRole} - Assign a user to a role + * - {@link revokeBmRole} - Unassign a user from a role + * + * ## Permissions + * + * - {@link getBmRolePermissions} - Get permissions for a role + * - {@link setBmRolePermissions} - Replace all permissions for a role + * + * ## Usage + * + * ```typescript + * import {listBmRoles, grantBmRole, getBmRolePermissions} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; + * import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig(); + * const instance = config.createB2CInstance(); + * + * // List all roles + * const roles = await listBmRoles(instance); + * + * // Grant a role to a user + * await grantBmRole(instance, 'Administrator', 'user@example.com'); + * + * // Get permissions for a role + * const permissions = await getBmRolePermissions(instance, 'Administrator'); + * ``` + * + * ## Authentication + * + * BM role operations require OAuth authentication with appropriate OCAPI permissions + * for the `/roles` resource. + * + * @module operations/bm-roles + */ +export { + listBmRoles, + getBmRole, + createBmRole, + deleteBmRole, + grantBmRole, + revokeBmRole, + getBmRolePermissions, + setBmRolePermissions, +} from './roles.js'; + +export type {BmRole, BmRoles, BmRolePermissions, ListBmRolesOptions, GetBmRoleOptions} from './roles.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts new file mode 100644 index 00000000..b1df4bf7 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts @@ -0,0 +1,278 @@ +/* + * 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 + */ +/** + * Business Manager role operations for B2C Commerce instances. + * + * Provides functions for managing instance-level access roles via OCAPI Data API. + */ +import type {B2CInstance} from '../../instance/index.js'; +import type {components} from '../../clients/ocapi.generated.js'; +import {getApiErrorMessage} from '../../clients/error-utils.js'; + +/** + * BM access role from OCAPI. + */ +export type BmRole = components['schemas']['role']; + +/** + * BM access roles collection from OCAPI. + */ +export type BmRoles = components['schemas']['roles']; + +/** + * BM role permissions from OCAPI. + */ +export type BmRolePermissions = components['schemas']['role_permissions']; + +/** + * Options for listing BM roles. + */ +export interface ListBmRolesOptions { + /** Start index (default 0) */ + start?: number; + /** Number of items to return (default 25) */ + count?: number; +} + +/** + * Options for getting a BM role. + */ +export interface GetBmRoleOptions { + /** Expansions to apply (e.g. 'users', 'permissions') */ + expand?: string[]; +} + +/** + * Lists all access roles on a B2C Commerce instance. + * + * @param instance - B2C instance to query + * @param options - Pagination options + * @returns Roles collection with pagination info + * + * @example + * ```typescript + * const roles = await listBmRoles(instance); + * for (const role of roles.data ?? []) { + * console.log(role.id, role.description); + * } + * ``` + */ +export async function listBmRoles(instance: B2CInstance, options: ListBmRolesOptions = {}): Promise { + const {start, count} = options; + + const {data, error, response} = await instance.ocapi.GET('/roles', { + params: {query: {start, count, select: '(**)'}}, + }); + + if (error) { + throw new Error(`Failed to list roles: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmRoles; +} + +/** + * Gets details of a specific access role. + * + * @param instance - B2C instance to query + * @param roleId - Role ID (e.g. "Administrator") + * @param options - Expand options + * @returns Role details + * + * @example + * ```typescript + * const role = await getBmRole(instance, 'Administrator', { expand: ['users'] }); + * console.log(role.id, role.user_count); + * ``` + */ +export async function getBmRole( + instance: B2CInstance, + roleId: string, + options: GetBmRoleOptions = {}, +): Promise { + const {expand} = options; + + const {data, error, response} = await instance.ocapi.GET('/roles/{id}', { + params: {path: {id: roleId}, query: {expand}}, + }); + + if (error) { + throw new Error(`Failed to get role ${roleId}: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmRole; +} + +/** + * Creates a new access role on an instance. + * + * @param instance - B2C instance + * @param roleId - Role ID to create + * @param role - Role properties (description, etc.) + * @returns Created role + * + * @example + * ```typescript + * const role = await createBmRole(instance, 'MyCustomRole', { description: 'A custom role' }); + * ``` + */ +export async function createBmRole( + instance: B2CInstance, + roleId: string, + role: {description?: string} = {}, +): Promise { + const {data, error, response} = await instance.ocapi.PUT('/roles/{id}', { + params: {path: {id: roleId}}, + body: {id: roleId, ...role} as components['schemas']['role'], + }); + + if (error) { + throw new Error(`Failed to create role ${roleId}: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmRole; +} + +/** + * Deletes an access role from an instance. + * + * System roles (e.g. "Administrator", "Support") cannot be deleted. + * + * @param instance - B2C instance + * @param roleId - Role ID to delete + * + * @example + * ```typescript + * await deleteBmRole(instance, 'MyCustomRole'); + * ``` + */ +export async function deleteBmRole(instance: B2CInstance, roleId: string): Promise { + const {error, response} = await instance.ocapi.DELETE('/roles/{id}', { + params: {path: {id: roleId}}, + }); + + if (error) { + throw new Error(`Failed to delete role ${roleId}: ${getApiErrorMessage(error, response)}`, {cause: error}); + } +} + +/** + * Assigns a user to an access role on an instance. + * + * @param instance - B2C instance + * @param roleId - Role ID to grant + * @param login - User login (email) + * @returns The user object after assignment + * + * @example + * ```typescript + * const user = await grantBmRole(instance, 'Administrator', 'user@example.com'); + * ``` + */ +export async function grantBmRole( + instance: B2CInstance, + roleId: string, + login: string, +): Promise { + const {data, error, response} = await instance.ocapi.PUT('/roles/{id}/users/{login}', { + params: {path: {id: roleId, login}}, + }); + + if (error) { + throw new Error(`Failed to grant role ${roleId} to ${login}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + return data as components['schemas']['user']; +} + +/** + * Unassigns a user from an access role on an instance. + * + * @param instance - B2C instance + * @param roleId - Role ID to revoke + * @param login - User login (email) + * + * @example + * ```typescript + * await revokeBmRole(instance, 'Administrator', 'user@example.com'); + * ``` + */ +export async function revokeBmRole(instance: B2CInstance, roleId: string, login: string): Promise { + const {error, response} = await instance.ocapi.DELETE('/roles/{id}/users/{login}', { + params: {path: {id: roleId, login}}, + }); + + if (error) { + throw new Error(`Failed to revoke role ${roleId} from ${login}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } +} + +/** + * Gets permissions assigned to an access role. + * + * @param instance - B2C instance + * @param roleId - Role ID + * @returns Role permissions object + * + * @example + * ```typescript + * const permissions = await getBmRolePermissions(instance, 'Administrator'); + * console.log(permissions.functional?.organization?.length); + * ``` + */ +export async function getBmRolePermissions(instance: B2CInstance, roleId: string): Promise { + const {data, error, response} = await instance.ocapi.GET('/roles/{id}/permissions', { + params: {path: {id: roleId}}, + }); + + if (error) { + throw new Error(`Failed to get permissions for role ${roleId}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + return data as BmRolePermissions; +} + +/** + * Sets (replaces) all permissions for an access role. + * + * This is a full replacement — all existing permissions are replaced with the provided set. + * + * @param instance - B2C instance + * @param roleId - Role ID + * @param permissions - Complete permissions object + * @returns Updated permissions + * + * @example + * ```typescript + * const perms = await getBmRolePermissions(instance, 'MyRole'); + * // ... modify perms ... + * await setBmRolePermissions(instance, 'MyRole', perms); + * ``` + */ +export async function setBmRolePermissions( + instance: B2CInstance, + roleId: string, + permissions: BmRolePermissions, +): Promise { + const {data, error, response} = await instance.ocapi.PUT('/roles/{id}/permissions', { + params: {path: {id: roleId}}, + body: permissions as components['schemas']['role_permissions'], + }); + + if (error) { + throw new Error(`Failed to set permissions for role ${roleId}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + return data as BmRolePermissions; +} diff --git a/skills/b2c-cli/skills/b2c-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-users-roles/SKILL.md new file mode 100644 index 00000000..56bfd755 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-users-roles/SKILL.md @@ -0,0 +1,143 @@ +--- +name: b2c-users-roles +description: Manage users and roles with the b2c cli. Covers Account Manager (AM) user CRUD, AM role grant/revoke with scoping, AM organizations, AM API clients, and Business Manager (BM) instance-level role CRUD, user assignment, and permissions. Use when managing users, roles, permissions, organizations, or API clients in Account Manager or Business Manager. +--- + +# B2C Users and Roles Skill + +Use the `b2c` CLI to manage users and roles across Account Manager (AM) and Business Manager (BM). + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead. + +## Overview + +| Area | Topic | Description | +|------|-------|-------------| +| Account Manager | `am users` | Create, update, delete AM users | +| Account Manager | `am roles` | List, grant, revoke AM roles (with optional tenant scope) | +| Account Manager | `am orgs` | List organizations | +| Account Manager | `am clients` | Manage API clients | +| Business Manager | `bm roles` | Create, delete instance-level BM roles | +| Business Manager | `bm roles grant/revoke` | Assign/unassign users to BM roles on an instance | +| Business Manager | `bm roles permissions` | Get/set role permissions on an instance | + +## Account Manager Users + +```bash +# list all users +b2c am users list + +# create a user +b2c am users create --mail user@example.com --first-name Jane --last-name Doe --org MyOrg + +# get a user by login +b2c am users get user@example.com + +# update a user +b2c am users update user@example.com --first-name Janet + +# delete (disable) a user +b2c am users delete user@example.com + +# reset a user to INITIAL state +b2c am users reset user@example.com +``` + +## Account Manager Roles + +```bash +# list all AM roles +b2c am roles list + +# list roles filtered by target type +b2c am roles list --target-type User + +# get role details +b2c am roles get bm-admin + +# grant a role to a user +b2c am roles grant user@example.com --role bm-admin + +# grant a role with tenant scope +b2c am roles grant user@example.com --role bm-admin --scope tenant1,tenant2 + +# revoke a role +b2c am roles revoke user@example.com --role bm-admin + +# revoke only specific scope +b2c am roles revoke user@example.com --role bm-admin --scope tenant1 +``` + +## Account Manager Organizations and API Clients + +```bash +# list organizations +b2c am orgs list + +# list API clients +b2c am clients list + +# create an API client +b2c am clients create --display-name "My Client" --org MyOrg + +# reset API client password +b2c am clients password my-client-id +``` + +## Business Manager Roles + +BM role commands operate on a specific Commerce Cloud instance (via `--server` or config). + +```bash +# list BM roles on an instance +b2c bm roles list --server my-sandbox.demandware.net + +# get role details (with user list) +b2c bm roles get Administrator --expand users + +# create a custom role +b2c bm roles create MyCustomRole --description "Custom role for content editors" + +# delete a custom role (system roles cannot be deleted) +b2c bm roles delete MyCustomRole + +# grant a BM role to a user on the instance +b2c bm roles grant user@example.com --role Administrator + +# revoke a BM role from a user +b2c bm roles revoke user@example.com --role Administrator + +# all commands support --json for machine-readable output +b2c bm roles list --json +``` + +## Business Manager Role Permissions + +Permissions use a file-based get/set workflow since the API replaces all permissions at once. + +```bash +# view permission summary +b2c bm roles permissions get Administrator + +# export permissions to a JSON file for editing +b2c bm roles permissions get Administrator --output admin-perms.json + +# edit the file, then apply +b2c bm roles permissions set Administrator --file admin-perms.json +``` + +The permissions JSON has four sections: `functional`, `module`, `locale`, and `webdav`. Each can be scoped to organization, site, or unscoped depending on type. + +## Authentication Requirements + +| Operations | Client Credentials | User Auth | +|---|---|---| +| AM Users and Roles | User Administrator role on API client | Account Administrator or User Administrator | +| AM Organizations | Not supported | Account Administrator | +| AM API Clients | Not supported | Account Administrator or API Administrator | +| BM Roles | OCAPI permissions for `/roles` resource | OCAPI permissions for `/roles` resource | + +## Related Skills + +- `b2c-cli:b2c-config` - Configure authentication credentials and instance settings +- `b2c-cli:b2c-sandbox` - Create and manage sandboxes (instances)